From 44c67ab71b0ffe88ba49217be223f928cfdd11d9 Mon Sep 17 00:00:00 2001 From: alydev Date: Thu, 4 Dec 2025 04:39:08 +1000 Subject: [PATCH] qstcompanion v1.0.1 --- QuestionableCompanion/--y__InlineArray5.cs | 8 + QuestionableCompanion/--y__InlineArray7.cs | 8 + .../-PrivateImplementationDetails-.cs | 0 QuestionableCompanion/ChauffeurModeService.cs | 2902 ++++++++++++++ QuestionableCompanion/QSTCompanion.csproj | 37 + .../ExpansionProgress.cs | 18 + .../MSQExpansionData.cs | 479 +++ .../ImGuiDragDrop.cs | 22 + .../AlliedSocietyCharacterStatus.cs | 15 + .../AlliedSocietyConfiguration.cs | 24 + .../AlliedSocietyPriority.cs | 10 + .../AlliedSocietyProgress.cs | 12 + .../AlliedSocietyQuestMode.cs | 7 + .../AlliedSocietyRotationPhase.cs | 13 + .../AlliedSocietyRotationStatus.cs | 8 + .../CharacterProgressInfo.cs | 25 + .../ExecutionState.cs | 23 + .../ExecutionStatus.cs | 11 + .../QuestionableCompanion.Models/LogEntry.cs | 30 + .../QuestionableCompanion.Models/LogLevel.cs | 10 + .../QuestConfig.cs | 19 + .../QuestProfile.cs | 16 + .../RotationPhase.cs | 25 + .../RotationState.cs | 249 ++ .../SequenceConfig.cs | 13 + .../SequenceType.cs | 7 + .../QuestionableCompanion.Models/StopPoint.cs | 30 + .../TriggerType.cs | 7 + .../ARPostProcessEventQuestService.cs | 454 +++ .../AlliedSocietyDatabase.cs | 162 + .../AlliedSocietyQuestSelector.cs | 87 + .../AlliedSocietyRotationService.cs | 496 +++ .../AutoRetainerIPC.cs | 513 +++ .../CharacterSafeWaitService.cs | 233 ++ .../CombatDutyDetectionService.cs | 368 ++ .../CrossProcessIPC.cs | 514 +++ .../CurrentTask.cs | 14 + .../DCTravelService.cs | 374 ++ .../DataCenterService.cs | 206 + .../DeathHandlerService.cs | 252 ++ .../DungeonAutomationService.cs | 378 ++ .../EventQuestExecutionService.cs | 609 +++ .../EventQuestPhase.cs | 16 + .../EventQuestResolver.cs | 230 ++ .../EventQuestState.cs | 37 + .../ExecutionService.cs | 655 ++++ .../ExpansionInfo.cs | 14 + .../ExpansionProgressInfo.cs | 14 + .../HelperManager.cs | 444 +++ .../LifestreamIPC.cs | 262 ++ .../MSQProgressionService.cs | 602 +++ .../MemoryHelper.cs | 119 + .../MovementMonitorService.cs | 167 + .../MultiClientIPC.cs | 230 ++ .../PartyInviteAutoAccept.cs | 148 + .../PartyInviteService.cs | 193 + .../PluginLogger.cs | 39 + .../QuestDetectionService.cs | 256 ++ .../QuestIdParser.cs | 68 + .../QuestIdType.cs | 9 + .../QuestPreCheckService.cs | 301 ++ .../QuestRotationExecutionService.cs | 1425 +++++++ .../QuestTrackingService.cs | 87 + .../QuestionableIPC.cs | 959 +++++ .../StepData.cs | 18 + .../StepsOfFaithHandler.cs | 168 + .../StopConditionData.cs | 8 + .../SubmarineManager.cs | 388 ++ .../VNavmeshIPC.cs | 62 + .../AlliedSocietyPriorityWindow.cs | 171 + .../ConfigWindow.cs | 564 +++ .../DebugWindow.cs | 161 + .../NewMainWindow.cs | 3368 +++++++++++++++++ .../AlliedSocietySettings.cs | 17 + .../QuestionableCompanion/Configuration.cs | 211 ++ .../QuestionableCompanion/HelperStatus.cs | 8 + .../HighLevelHelperConfig.cs | 13 + .../QuestionableCompanion/MSQDisplayMode.cs | 8 + .../QuestionableCompanion/Plugin.cs | 1020 +++++ 79 files changed, 21148 insertions(+) create mode 100644 QuestionableCompanion/--y__InlineArray5.cs create mode 100644 QuestionableCompanion/--y__InlineArray7.cs create mode 100644 QuestionableCompanion/-PrivateImplementationDetails-.cs create mode 100644 QuestionableCompanion/ChauffeurModeService.cs create mode 100644 QuestionableCompanion/QSTCompanion.csproj create mode 100644 QuestionableCompanion/QuestionableCompanion.Data/ExpansionProgress.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Data/MSQExpansionData.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Helpers/ImGuiDragDrop.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyCharacterStatus.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyConfiguration.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyPriority.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyProgress.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyQuestMode.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyRotationPhase.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyRotationStatus.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Models/CharacterProgressInfo.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Models/ExecutionState.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Models/ExecutionStatus.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Models/LogEntry.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Models/LogLevel.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Models/QuestConfig.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Models/QuestProfile.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Models/RotationPhase.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Models/RotationState.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Models/SequenceConfig.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Models/SequenceType.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Models/StopPoint.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Models/TriggerType.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/ARPostProcessEventQuestService.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/AlliedSocietyDatabase.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/AlliedSocietyQuestSelector.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/AlliedSocietyRotationService.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/AutoRetainerIPC.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/CharacterSafeWaitService.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/CombatDutyDetectionService.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/CrossProcessIPC.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/CurrentTask.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/DCTravelService.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/DataCenterService.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/DeathHandlerService.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/DungeonAutomationService.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/EventQuestExecutionService.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/EventQuestPhase.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/EventQuestResolver.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/EventQuestState.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/ExecutionService.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/ExpansionInfo.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/ExpansionProgressInfo.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/HelperManager.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/LifestreamIPC.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/MSQProgressionService.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/MemoryHelper.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/MovementMonitorService.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/MultiClientIPC.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/PartyInviteAutoAccept.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/PartyInviteService.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/PluginLogger.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/QuestDetectionService.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/QuestIdParser.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/QuestIdType.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/QuestPreCheckService.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/QuestRotationExecutionService.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/QuestTrackingService.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/QuestionableIPC.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/StepData.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/StepsOfFaithHandler.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/StopConditionData.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/SubmarineManager.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Services/VNavmeshIPC.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Windows/AlliedSocietyPriorityWindow.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Windows/ConfigWindow.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Windows/DebugWindow.cs create mode 100644 QuestionableCompanion/QuestionableCompanion.Windows/NewMainWindow.cs create mode 100644 QuestionableCompanion/QuestionableCompanion/AlliedSocietySettings.cs create mode 100644 QuestionableCompanion/QuestionableCompanion/Configuration.cs create mode 100644 QuestionableCompanion/QuestionableCompanion/HelperStatus.cs create mode 100644 QuestionableCompanion/QuestionableCompanion/HighLevelHelperConfig.cs create mode 100644 QuestionableCompanion/QuestionableCompanion/MSQDisplayMode.cs create mode 100644 QuestionableCompanion/QuestionableCompanion/Plugin.cs diff --git a/QuestionableCompanion/--y__InlineArray5.cs b/QuestionableCompanion/--y__InlineArray5.cs new file mode 100644 index 0000000..452839e --- /dev/null +++ b/QuestionableCompanion/--y__InlineArray5.cs @@ -0,0 +1,8 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +[StructLayout(LayoutKind.Auto)] +[InlineArray(5)] +internal struct _003C_003Ey__InlineArray5 +{ +} diff --git a/QuestionableCompanion/--y__InlineArray7.cs b/QuestionableCompanion/--y__InlineArray7.cs new file mode 100644 index 0000000..54c6b1e --- /dev/null +++ b/QuestionableCompanion/--y__InlineArray7.cs @@ -0,0 +1,8 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +[StructLayout(LayoutKind.Auto)] +[InlineArray(7)] +internal struct _003C_003Ey__InlineArray7 +{ +} diff --git a/QuestionableCompanion/-PrivateImplementationDetails-.cs b/QuestionableCompanion/-PrivateImplementationDetails-.cs new file mode 100644 index 0000000..e69de29 diff --git a/QuestionableCompanion/ChauffeurModeService.cs b/QuestionableCompanion/ChauffeurModeService.cs new file mode 100644 index 0000000..4412e13 --- /dev/null +++ b/QuestionableCompanion/ChauffeurModeService.cs @@ -0,0 +1,2902 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Numerics; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Dalamud.Game.ClientState.Conditions; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Game.ClientState.Party; +using Dalamud.Plugin; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game; +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using FFXIVClientStructs.FFXIV.Client.Game.Control; +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using FFXIVClientStructs.FFXIV.Client.Game.UI; +using Lumina.Excel; +using Lumina.Excel.Sheets; +using Newtonsoft.Json.Linq; +using QuestionableCompanion; +using QuestionableCompanion.Services; + +public class ChauffeurModeService : IDisposable +{ + private readonly Configuration config; + + private readonly IPluginLog log; + + private readonly IClientState clientState; + + private readonly ICondition condition; + + private readonly IFramework framework; + + private readonly ICommandManager commandManager; + + private readonly IDataManager dataManager; + + private readonly IPartyList partyList; + + private readonly IObjectTable objectTable; + + private readonly QuestionableIPC questionableIPC; + + private readonly CrossProcessIPC crossProcessIPC; + + private readonly PartyInviteService partyInviteService; + + private readonly PartyInviteAutoAccept partyInviteAutoAccept; + + private readonly IDalamudPluginInterface pluginInterface; + + private readonly MemoryHelper memoryHelper; + + private readonly MovementMonitorService? movementMonitor; + + private readonly VNavmeshIPC vnavmeshIPC; + + private bool isWaitingForHelper; + + private bool isTransportingQuester; + + private bool hasExecutedRidePillion; + + private Vector3? targetPosition; + + private uint targetZoneId; + + private string? questerName; + + private DateTime lastZoneUpdate = DateTime.MinValue; + + private bool isDisposed; + + private CancellationTokenSource? helperWorkflowCts; + + private Vector3? lastMoveToPosition; + + private DateTime? lastZoneChangeTime; + + private bool isFollowingQuester; + + private DateTime lastFollowCheck = DateTime.MinValue; + + private Vector3? lastQuesterPosition; + + private uint lastQuesterZone; + + private string? followingQuesterName; + + private static readonly HashSet BLACKLISTED_ZONES = new HashSet { 478u }; + + private static readonly Dictionary FLYING_INDICATOR_QUESTS = new Dictionary { { 1669u, 402u } }; + + private Dictionary helperStatuses = new Dictionary(); + + private Dictionary discoveredQuesters = new Dictionary(); + + private readonly HashSet restrictedZones = new HashSet + { + 128u, 129u, 130u, 131u, 132u, 133u, 418u, 419u, 819u, 820u, + 962u, 963u, 1185u, 1186u, 250u + }; + + public bool IsWaitingForHelper => isWaitingForHelper; + + public bool IsTransportingQuester => isTransportingQuester; + + public string? GetHelperStatus(string helperKey) + { + if (!helperStatuses.TryGetValue(helperKey, out string status)) + { + return null; + } + return status; + } + + public List GetDiscoveredQuesters() + { + DateTime now = DateTime.Now; + foreach (string stale in (from kvp in discoveredQuesters + where (now - kvp.Value).TotalSeconds > 60.0 + select kvp.Key).ToList()) + { + discoveredQuesters.Remove(stale); + } + return discoveredQuesters.Keys.ToList(); + } + + 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) + { + this.config = config; + this.log = log; + this.clientState = clientState; + this.condition = condition; + this.framework = framework; + this.commandManager = commandManager; + this.dataManager = dataManager; + this.partyList = partyList; + this.objectTable = objectTable; + this.questionableIPC = questionableIPC; + this.crossProcessIPC = crossProcessIPC; + this.partyInviteService = partyInviteService; + this.partyInviteAutoAccept = partyInviteAutoAccept; + this.pluginInterface = pluginInterface; + this.memoryHelper = memoryHelper; + this.movementMonitor = movementMonitor; + vnavmeshIPC = new VNavmeshIPC(pluginInterface); + crossProcessIPC.OnChauffeurSummonRequest += OnChauffeurSummonRequest; + crossProcessIPC.OnChauffeurReadyForPickup += OnChauffeurReadyForPickup; + crossProcessIPC.OnChauffeurArrived += OnChauffeurArrived; + crossProcessIPC.OnChauffeurZoneUpdate += OnChauffeurZoneUpdate; + crossProcessIPC.OnChauffeurMountReady += OnChauffeurMountReady; + crossProcessIPC.OnChauffeurPassengerMounted += OnChauffeurPassengerMounted; + crossProcessIPC.OnHelperStatusUpdate += OnHelperStatusUpdate; + crossProcessIPC.OnQuesterPositionUpdate += OnQuesterPositionUpdate; + clientState.TerritoryChanged += OnTerritoryChanged; + if (config.IsHighLevelHelper) + { + framework.RunOnTick(delegate + { + BroadcastHelperStatusPeriodically(); + }, TimeSpan.FromSeconds(10L)); + log.Information("[ChauffeurMode] Periodic helper status broadcast enabled (every 10s)"); + } + framework.Update += OnFrameworkUpdate; + log.Information("[ChauffeurMode] Service initialized"); + } + + private void OnFrameworkUpdate(IFramework framework) + { + if (config.IsHighLevelHelper && config.EnableHelperFollowing && (DateTime.Now - lastFollowCheck).TotalSeconds >= (double)config.HelperFollowCheckInterval) + { + CheckHelperFollowing(); + } + if (config.IsQuester && !string.IsNullOrEmpty(config.AssignedHelperForFollowing) && config.EnableHelperFollowing) + { + DateTime now = DateTime.Now; + if ((now - lastFollowCheck).TotalSeconds >= 5.0) + { + BroadcastQuesterPosition(); + lastFollowCheck = now; + } + } + } + + public void CheckWaitTerritoryTask() + { + if (!questionableIPC.IsRunning()) + { + return; + } + if (lastZoneChangeTime.HasValue) + { + double timeSinceZoneChange = (DateTime.Now - lastZoneChangeTime.Value).TotalSeconds; + if (timeSinceZoneChange < 8.0) + { + log.Debug($"[WaitTerritory] Territory Load State: Waiting for zone load before checking Wait tasks (elapsed: {timeSinceZoneChange:F1}s / 8.0s)"); + return; + } + } + if (clientState.LocalPlayer == null) + { + return; + } + object task = questionableIPC.GetCurrentTask(); + if (task == null) + { + return; + } + try + { + if (!(task is JObject jObject)) + { + return; + } + JToken taskNameToken = jObject["TaskName"]; + if (taskNameToken == null) + { + return; + } + string taskName = taskNameToken.ToString(); + if (string.IsNullOrEmpty(taskName)) + { + return; + } + Match waitTerritoryMatch = new Regex("Wait\\(territory:\\s*(.+?)\\s*\\((\\d+)\\)\\)").Match(taskName); + if (!waitTerritoryMatch.Success) + { + return; + } + string territoryName = waitTerritoryMatch.Groups[1].Value.Trim(); + uint territoryId = uint.Parse(waitTerritoryMatch.Groups[2].Value); + if (clientState.TerritoryType == territoryId) + { + log.Debug($"[WaitTerritory] Already in target territory {territoryName} ({territoryId}) - skipping teleport"); + return; + } + string mappedName = MapTerritoryName(territoryName); + log.Information($"[WaitTerritory] Wait(territory) detected: {territoryName} ({territoryId}) → Auto-teleporting to {mappedName}"); + framework.RunOnFrameworkThread(delegate + { + try + { + string content = "/li " + mappedName; + commandManager.ProcessCommand(content); + } + catch (Exception ex2) + { + log.Error("[WaitTerritory] Failed to teleport to " + mappedName + ": " + ex2.Message); + } + }); + } + catch (Exception ex) + { + log.Error("[WaitTerritory] Error checking Wait(territory) task: " + ex.Message); + } + } + + public void CheckTaskDistance() + { + if (!config.ChauffeurModeEnabled || !config.IsQuester || !questionableIPC.IsAvailable || !questionableIPC.IsRunning()) + { + return; + } + IPlayerCharacter localPlayer = clientState.LocalPlayer; + if (localPlayer == null) + { + return; + } + if (lastZoneChangeTime.HasValue) + { + double timeSinceZoneChange = (DateTime.Now - lastZoneChangeTime.Value).TotalSeconds; + if (timeSinceZoneChange < 8.0) + { + log.Debug($"[ChauffeurMode] Territory Load State: Waiting for zone load before checking summon (elapsed: {timeSinceZoneChange:F1}s / 8.0s)"); + return; + } + } + ushort currentZoneId = clientState.TerritoryType; + if (BLACKLISTED_ZONES.Contains(currentZoneId)) + { + log.Debug($"[ChauffeurMode] Zone {currentZoneId} is blacklisted (no flying), cannot use Chauffeur Mode"); + return; + } + if (IsRestrictedZone(currentZoneId)) + { + log.Debug($"[ChauffeurMode] Zone {currentZoneId} is restricted (Main City), cannot use Chauffeur Mode"); + return; + } + if (IsSoloDutyOrInstance(currentZoneId)) + { + log.Debug($"[ChauffeurMode] Zone {currentZoneId} is a Solo Duty/Instance, cannot use Chauffeur Mode"); + return; + } + if (!IsMountingAllowed(currentZoneId)) + { + log.Debug($"[ChauffeurMode] Zone {currentZoneId} does not allow mounting, cannot use Chauffeur Mode"); + return; + } + if (HasFlyingInZone(currentZoneId)) + { + log.Debug($"[ChauffeurMode] Flying already unlocked in zone {currentZoneId}, no helper needed"); + return; + } + string currentQuestStr = questionableIPC.GetCurrentQuestId(); + if (!string.IsNullOrEmpty(currentQuestStr) && uint.TryParse(currentQuestStr, out var currentQuestId) && FLYING_INDICATOR_QUESTS.TryGetValue(currentQuestId, out var flyingZoneId) && flyingZoneId == currentZoneId) + { + log.Debug($"[ChauffeurMode] Current quest {currentQuestId} indicates flying is already unlocked in zone {currentZoneId} - no helper needed"); + return; + } + object task = questionableIPC.GetCurrentTask(); + if (task == null) + { + log.Debug("[ChauffeurMode] No current task"); + return; + } + var (taskPosition, isAttuneAetheryte) = ParseTaskPositionWithType(task); + if (!taskPosition.HasValue) + { + log.Debug("[ChauffeurMode] Could not parse task position"); + return; + } + Vector3 currentPosition = localPlayer.Position; + float distance = Vector3.Distance(currentPosition, taskPosition.Value); + float threshold = (isAttuneAetheryte ? 10f : config.ChauffeurDistanceThreshold); + log.Information($"[ChauffeurMode] Current Position: ({currentPosition.X:F2}, {currentPosition.Y:F2}, {currentPosition.Z:F2})"); + log.Information($"[ChauffeurMode] Target Position: ({taskPosition.Value.X:F2}, {taskPosition.Value.Y:F2}, {taskPosition.Value.Z:F2})"); + log.Information($"[ChauffeurMode] Distance to task: {distance:F2} yalms (threshold: {threshold})"); + if (distance > threshold) + { + log.Information($"[ChauffeurMode] Task distance ({distance:F2} yalms) exceeds threshold, checking combat status"); + if (condition[ConditionFlag.InCombat]) + { + log.Information("[ChauffeurMode] Player is in combat - waiting for combat to end before summoning helper"); + return; + } + log.Information("[ChauffeurMode] Not in combat - summoning helper"); + SummonHelper(taskPosition.Value, currentZoneId); + } + else + { + log.Debug($"[ChauffeurMode] Task is close enough ({distance:F2} yalms), no helper needed"); + } + } + + private (Vector3? position, bool isAttuneAetheryte) ParseTaskPositionWithType(object task) + { + try + { + if (task is JObject jObject) + { + JToken taskNameToken = jObject["TaskName"]; + if (taskNameToken == null) + { + log.Warning("[ChauffeurMode] Task has no TaskName property"); + return (position: null, isAttuneAetheryte: false); + } + string taskName = taskNameToken.ToString(); + if (string.IsNullOrEmpty(taskName)) + { + log.Warning("[ChauffeurMode] TaskName is empty"); + return (position: null, isAttuneAetheryte: false); + } + bool isAttuneAetheryte = taskName.StartsWith("AttuneAetheryte("); + return (position: ParseTaskName(taskName), isAttuneAetheryte: isAttuneAetheryte); + } + log.Warning("[ChauffeurMode] Task is not a JObject"); + return (position: null, isAttuneAetheryte: false); + } + catch (Exception ex) + { + log.Error("[ChauffeurMode] Error parsing task position: " + ex.Message); + return (position: null, isAttuneAetheryte: false); + } + } + + private Vector3? ParseTaskPosition(object task) + { + return ParseTaskPositionWithType(task).position; + } + + private string MapTerritoryName(string territoryName) + { + if (territoryName.Contains("Dravanian Hinterlands", StringComparison.OrdinalIgnoreCase)) + { + log.Information("[ChauffeurMode] Mapping 'Dravanian Hinterlands' → 'Epilogue Gate'"); + return "Epilogue Gate"; + } + if (territoryName.Contains("Old Gridania", StringComparison.OrdinalIgnoreCase)) + { + log.Information("[ChauffeurMode] Mapping 'Old Gridania' → 'Mih Khetto'"); + return "Mih Khetto"; + } + if (territoryName.Contains("Upper Decks", StringComparison.OrdinalIgnoreCase)) + { + log.Information("[ChauffeurMode] Mapping 'Upper Decks' → 'Aftcastle'"); + return "Aftcastle"; + } + if (territoryName.Contains("Coerthas Central Highlands", StringComparison.OrdinalIgnoreCase)) + { + log.Information("[ChauffeurMode] Mapping 'Coerthas Central Highlands' → 'Camp Dragonhead'"); + return "Camp Dragonhead"; + } + if (territoryName.Contains("The Pillars", StringComparison.OrdinalIgnoreCase)) + { + log.Information("[ChauffeurMode] Mapping 'The Pillars' → 'The Last Vigil'"); + return "The Last Vigil"; + } + return territoryName; + } + + private Vector3? ParseTaskName(string taskName) + { + try + { + log.Debug("[ChauffeurMode] Parsing task: " + taskName); + Match waitTerritoryMatch = new Regex("Wait\\(territory:\\s*(.+?)\\s*\\((\\d+)\\)\\)").Match(taskName); + if (waitTerritoryMatch.Success) + { + string territoryName = waitTerritoryMatch.Groups[1].Value.Trim(); + uint territoryId = uint.Parse(waitTerritoryMatch.Groups[2].Value); + log.Information("[ChauffeurMode] ========================================"); + log.Information("[ChauffeurMode] === WAIT(TERRITORY) TASK DETECTED ==="); + log.Information("[ChauffeurMode] ========================================"); + log.Information($"[ChauffeurMode] Territory: {territoryName} (ID: {territoryId})"); + log.Information("[ChauffeurMode] This means Questionable has no aetheryte shortcut!"); + log.Information("[ChauffeurMode] Auto-teleporting via Lifestream..."); + string mappedName = MapTerritoryName(territoryName); + framework.RunOnFrameworkThread(delegate + { + try + { + string text = "/li " + mappedName; + commandManager.ProcessCommand(text); + log.Information("[ChauffeurMode] ✓ Teleport command sent: " + text); + } + catch (Exception ex2) + { + log.Error("[ChauffeurMode] Failed to send teleport command: " + ex2.Message); + } + }); + return null; + } + Match moveToMatch = new Regex("MoveTo\\(<([-\\d.]+),\\s*([-\\d.]+),\\s*([-\\d.]+)>\\)").Match(taskName); + if (moveToMatch.Success) + { + float x = float.Parse(moveToMatch.Groups[1].Value, CultureInfo.InvariantCulture); + float y = float.Parse(moveToMatch.Groups[2].Value, CultureInfo.InvariantCulture); + float z = float.Parse(moveToMatch.Groups[3].Value, CultureInfo.InvariantCulture); + Vector3 position = new Vector3(x, y, z); + lastMoveToPosition = position; + log.Debug($"[ChauffeurMode] Parsed MoveTo position: ({x:F2}, {y:F2}, {z:F2})"); + return position; + } + Match attuneMatch = new Regex("AttuneAetheryte\\((.+?)\\)").Match(taskName); + if (attuneMatch.Success) + { + string aetheryteName = attuneMatch.Groups[1].Value; + log.Information("[ChauffeurMode] ========================================"); + log.Information("[ChauffeurMode] === ATTUNE AETHERYTE TASK DETECTED ==="); + log.Information("[ChauffeurMode] ========================================"); + log.Information("[ChauffeurMode] Aetheryte: " + aetheryteName); + if (lastMoveToPosition.HasValue) + { + log.Information($"[ChauffeurMode] Using last MoveTo position: ({lastMoveToPosition.Value.X:F2}, {lastMoveToPosition.Value.Y:F2}, {lastMoveToPosition.Value.Z:F2})"); + log.Information("[ChauffeurMode] Using reduced threshold of 10 yalms for AttuneAetheryte"); + return lastMoveToPosition; + } + log.Warning("[ChauffeurMode] No previous MoveTo position found for AttuneAetheryte task"); + return null; + } + Match objectIdMatch = new Regex("(?:Interact|Talk)\\((\\d+)\\)").Match(taskName); + if (objectIdMatch.Success) + { + uint objectId = uint.Parse(objectIdMatch.Groups[1].Value); + log.Debug($"[ChauffeurMode] Parsed ObjectId: {objectId}"); + Vector3? position2 = GetObjectPosition(objectId); + if (position2.HasValue) + { + log.Debug($"[ChauffeurMode] Found object position: ({position2.Value.X}, {position2.Value.Y}, {position2.Value.Z})"); + return position2; + } + } + log.Warning("[ChauffeurMode] Could not parse task format: " + taskName); + return null; + } + catch (Exception ex) + { + log.Error("[ChauffeurMode] Error parsing task position: " + ex.Message); + return null; + } + } + + private Vector3? GetObjectPosition(uint objectId) + { + try + { + ExcelSheet eObjSheet = dataManager.GetExcelSheet(); + if (eObjSheet != null && eObjSheet.GetRowOrDefault(objectId).HasValue) + { + log.Debug($"[ChauffeurMode] Found EObj: {objectId}"); + } + ExcelSheet levelSheet = dataManager.GetExcelSheet(); + if (levelSheet != null) + { + foreach (Level level in levelSheet) + { + if (level.Object.RowId == objectId) + { + Vector3 position = new Vector3(level.X, level.Y, level.Z); + log.Debug($"[ChauffeurMode] Found Level position for object {objectId}: ({position.X}, {position.Y}, {position.Z})"); + return position; + } + } + } + log.Warning($"[ChauffeurMode] Could not find position for object {objectId}"); + return null; + } + catch (Exception ex) + { + log.Error("[ChauffeurMode] Error getting object position: " + ex.Message); + return null; + } + } + + private unsafe bool HasFlyingInZone(uint zoneId) + { + try + { + ExcelSheet territorySheet = dataManager.GetExcelSheet(); + if (territorySheet == null) + { + log.Debug("[ChauffeurMode] TerritoryType sheet is null"); + return false; + } + TerritoryType? territory = territorySheet.GetRowOrDefault(zoneId); + if (!territory.HasValue) + { + log.Debug($"[ChauffeurMode] Territory {zoneId} not found"); + return false; + } + if (!territory.Value.Mount) + { + log.Debug($"[ChauffeurMode] Zone {zoneId} does not allow mounting"); + return false; + } + RowRef aetherCurrentCompFlgSet = territory.Value.AetherCurrentCompFlgSet; + if (!aetherCurrentCompFlgSet.IsValid || aetherCurrentCompFlgSet.RowId == 0) + { + log.Debug($"[ChauffeurMode] Zone {zoneId} has no aether currents (AetherCurrentCompFlgSet invalid or 0)"); + return false; + } + PlayerState* playerState = PlayerState.Instance(); + if (playerState == null) + { + log.Debug("[ChauffeurMode] PlayerState is null"); + return false; + } + byte aetherCurrentId = (byte)aetherCurrentCompFlgSet.RowId; + bool hasFlying = playerState->IsAetherCurrentZoneComplete(aetherCurrentId); + log.Debug($"[ChauffeurMode] Zone {zoneId} (AetherCurrentId: {aetherCurrentId}) flying check: {hasFlying}"); + return hasFlying; + } + catch (Exception ex) + { + log.Error("[ChauffeurMode] Error checking flying availability: " + ex.Message); + return false; + } + } + + private async void SummonHelper(Vector3 targetPos, uint zoneId) + { + IPlayerCharacter localPlayer = clientState.LocalPlayer; + if (localPlayer == null) + { + return; + } + if (condition[ConditionFlag.InCombat]) + { + log.Warning("[ChauffeurMode] Still in combat - cannot summon helper yet"); + return; + } + log.Information("[ChauffeurMode] ========================================"); + log.Information("[ChauffeurMode] === SUMMONING HELPER ==="); + log.Information("[ChauffeurMode] ========================================"); + if (!string.IsNullOrEmpty(config.PreferredHelper)) + { + string preferredHelper = config.PreferredHelper; + log.Information("[ChauffeurMode] [QUESTER] Preferred Helper: " + preferredHelper); + if (!helperStatuses.TryGetValue(preferredHelper, out string status)) + { + log.Warning("[ChauffeurMode] [QUESTER] No status received from preferred helper yet - walking to destination"); + return; + } + log.Information("[ChauffeurMode] [QUESTER] Helper status: " + status); + if (status != "Available") + { + log.Warning("[ChauffeurMode] [QUESTER] Preferred helper is " + status + " - walking to destination instead"); + log.Warning("[ChauffeurMode] [QUESTER] Continuing quest without helper"); + return; + } + } + log.Information("[ChauffeurMode] Stopping Questionable to wait for helper"); + if (movementMonitor != null && movementMonitor.IsMonitoring) + { + log.Information("[ChauffeurMode] [QUESTER] Stopping Movement Monitor during transport"); + movementMonitor.StopMonitoring(); + } + log.Information("[ChauffeurMode] Stopping Questionable to wait for helper"); + TaskCompletionSource stopTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + commandManager.ProcessCommand("/qst stop"); + stopTask.SetResult(result: true); + } + catch (Exception ex2) + { + log.Error("[ChauffeurMode] Error stopping Questionable: " + ex2.Message); + stopTask.SetResult(result: false); + } + }); + await stopTask.Task; + log.Information("[ChauffeurMode] [QUESTER] Enabling auto-accept for party invites"); + log.Information($"[ChauffeurMode] [QUESTER] Current role - IsQuester: {config.IsQuester}, IsHelper: {config.IsHighLevelHelper}"); + partyInviteAutoAccept.EnableAutoAccept(); + log.Information("[ChauffeurMode] [QUESTER] Auto-accept enabled - will accept invites for 30 seconds"); + string questerName = localPlayer.Name.ToString(); + ushort questerWorld = (ushort)localPlayer.HomeWorld.RowId; + targetPosition = targetPos; + targetZoneId = zoneId; + isWaitingForHelper = true; + Vector3 questerPos = localPlayer.Position; + bool isAttuneAetheryte = false; + try + { + StepData stepData = questionableIPC.GetCurrentStepData(); + if (stepData != null && stepData.InteractionType == "AttuneAetheryte") + { + isAttuneAetheryte = true; + log.Information("[ChauffeurMode] Current step is AttuneAetheryte - Helper will find landable spot"); + } + else + { + log.Information("[ChauffeurMode] Current step InteractionType: " + (stepData?.InteractionType ?? "null") + " - Helper will go to exact position"); + } + } + catch (Exception ex) + { + log.Warning("[ChauffeurMode] Failed to get step data: " + ex.Message); + } + log.Information("[ChauffeurMode] Requesting helper pickup"); + log.Information($"[ChauffeurMode] Quester: {questerName}@{questerWorld}"); + log.Information($"[ChauffeurMode] Zone: {zoneId}"); + 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] AttuneAetheryte: {isAttuneAetheryte}"); + crossProcessIPC.SendChauffeurSummonRequest(questerName, questerWorld, zoneId, targetPos, questerPos, isAttuneAetheryte); + } + + public bool IsRestrictedZone(uint zoneId) + { + return restrictedZones.Contains(zoneId); + } + + public bool IsSoloDutyOrInstance(uint zoneId) + { + try + { + ExcelSheet territorySheet = dataManager.GetExcelSheet(); + if (territorySheet == null) + { + return false; + } + TerritoryType? territory = territorySheet.GetRowOrDefault(zoneId); + if (!territory.HasValue) + { + return false; + } + uint intendedUse = territory.Value.TerritoryIntendedUse.RowId; + switch (intendedUse) + { + case 8u: + case 9u: + log.Debug($"[ChauffeurMode] Zone {zoneId} is Solo Duty/Quest Battle (IntendedUse: {intendedUse})"); + return true; + case 2u: + case 3u: + case 4u: + case 5u: + log.Debug($"[ChauffeurMode] Zone {zoneId} is party content (IntendedUse: {intendedUse})"); + return true; + default: + if (intendedUse == 13 || intendedUse == 16 || intendedUse == 17) + { + log.Debug($"[ChauffeurMode] Zone {zoneId} is special content (IntendedUse: {intendedUse})"); + return true; + } + return false; + } + } + catch (Exception ex) + { + log.Error("[ChauffeurMode] Error checking solo duty status: " + ex.Message); + return false; + } + } + + public bool IsMountingAllowed(uint zoneId) + { + try + { + ExcelSheet territorySheet = dataManager.GetExcelSheet(); + if (territorySheet == null) + { + return false; + } + TerritoryType? territory = territorySheet.GetRowOrDefault(zoneId); + if (!territory.HasValue) + { + return false; + } + return territory.Value.Mount; + } + catch (Exception ex) + { + log.Error("[ChauffeurMode] Error checking mount permission: " + ex.Message); + return false; + } + } + + public List<(uint Id, string Name, byte Seats)> GetMultiSeaterMounts() + { + List<(uint, string, byte)> mounts = new List<(uint, string, byte)>(); + try + { + ExcelSheet mountSheet = dataManager.GetExcelSheet(); + if (mountSheet == null) + { + log.Error("[ChauffeurMode] Could not load Mount sheet"); + return mounts; + } + foreach (Mount mount in mountSheet) + { + if (mount.ExtraSeats > 0) + { + string name = mount.Singular.ToString(); + if (!string.IsNullOrEmpty(name)) + { + mounts.Add((mount.RowId, name, mount.ExtraSeats)); + } + } + } + log.Debug($"[ChauffeurMode] Found {mounts.Count} multi-seater mounts"); + } + catch (Exception ex) + { + log.Error("[ChauffeurMode] Error loading multi-seater mounts: " + ex.Message); + } + return mounts; + } + + private void OnChauffeurSummonRequest(string questerName, ushort questerWorld, uint zoneId, Vector3 targetPos, Vector3 questerPos, bool isAttuneAetheryte) + { + if (!config.ChauffeurModeEnabled) + { + return; + } + if (!config.IsHighLevelHelper) + { + log.Debug("[ChauffeurMode] Not a helper, ignoring summon"); + return; + } + if (config.CurrentHelperStatus == HelperStatus.Transporting) + { + log.Warning($"[ChauffeurMode] [HELPER] Already transporting {config.AssignedQuester} - rejecting summon from {questerName}@{questerWorld}"); + return; + } + if (config.CurrentHelperStatus == HelperStatus.InDungeon) + { + log.Warning($"[ChauffeurMode] [HELPER] Currently in dungeon - rejecting summon from {questerName}@{questerWorld}"); + return; + } + this.questerName = $"{questerName}@{questerWorld}"; + targetZoneId = zoneId; + targetPosition = targetPos; + IPlayerCharacter localPlayer = clientState.LocalPlayer; + if (localPlayer != null) + { + string myName = localPlayer.Name.ToString(); + ushort myWorld = (ushort)localPlayer.HomeWorld.RowId; + if (myName == questerName && myWorld == questerWorld) + { + log.Debug("[ChauffeurMode] Ignoring own summon request"); + return; + } + } + log.Information("[ChauffeurMode] ========================================"); + log.Information("[ChauffeurMode] === HELPER SUMMON REQUEST ==="); + 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] Quester Position: ({questerPos.X:F2}, {questerPos.Y:F2}, {questerPos.Z:F2})"); + if (BLACKLISTED_ZONES.Contains(zoneId)) + { + log.Warning($"[ChauffeurMode] Zone {zoneId} is blacklisted (no flying available), cannot use Chauffeur Mode"); + return; + } + if (IsRestrictedZone(zoneId)) + { + log.Warning($"[ChauffeurMode] Zone {zoneId} is restricted (Main City), cannot follow"); + return; + } + if (IsSoloDutyOrInstance(zoneId)) + { + log.Warning($"[ChauffeurMode] Zone {zoneId} is a Solo Duty/Instance, cannot follow"); + return; + } + if (!IsMountingAllowed(zoneId)) + { + log.Warning($"[ChauffeurMode] Zone {zoneId} does not allow mounting, cannot use Chauffeur Mode"); + return; + } + if (config.ChauffeurMountId == 0) + { + log.Error("[ChauffeurMode] No mount configured! Please select a multi-seater mount in settings"); + return; + } + if (isTransportingQuester) + { + log.Warning($"[ChauffeurMode] [HELPER] Already transporting a quester! Ignoring new request from {questerName}@{questerWorld}"); + return; + } + this.questerName = questerName; + targetPosition = targetPos; + targetZoneId = zoneId; + isTransportingQuester = true; + config.AssignedQuester = $"{questerName}@{questerWorld}"; + config.CurrentHelperStatus = HelperStatus.Transporting; + config.Save(); + log.Information("[ChauffeurMode] [HELPER] Assigned to quester: " + config.AssignedQuester + " (Status: Transporting)"); + if (localPlayer != null) + { + string helperName = localPlayer.Name.ToString(); + ushort helperWorld = (ushort)localPlayer.HomeWorld.RowId; + crossProcessIPC.BroadcastHelperStatus(helperName, helperWorld, "Transporting"); + } + helperWorkflowCts?.Cancel(); + helperWorkflowCts?.Dispose(); + helperWorkflowCts = new CancellationTokenSource(); + CancellationTokenSource cts = helperWorkflowCts; + Task.Run(async delegate + { + await HelperWorkflow(questerName, questerWorld, zoneId, targetPos, questerPos, isAttuneAetheryte, cts.Token); + }, cts.Token); + } + + private unsafe async Task HelperWorkflow(string questerName, ushort questerWorld, uint zoneId, Vector3 targetPos, Vector3 questerPos, bool isAttuneAetheryte, CancellationToken cancellationToken) + { + try + { + log.Information("[ChauffeurMode] [WORKFLOW] Starting helper workflow"); + log.Information($"[ChauffeurMode] [WORKFLOW] Thread ID: {Thread.CurrentThread.ManagedThreadId}"); + if (cancellationToken.IsCancellationRequested) + { + log.Information("[ChauffeurMode] [WORKFLOW] Workflow cancelled before start"); + framework.RunOnFrameworkThread(delegate + { + ResetHelperTransportState(); + }); + return; + } + TaskCompletionSource<(bool success, ushort helperWorld, uint helperZone, uint questerZone)> worldCheckTask = new TaskCompletionSource<(bool, ushort, uint, uint)>(); + framework.RunOnFrameworkThread(delegate + { + try + { + IPlayerCharacter localPlayer2 = clientState.LocalPlayer; + if (localPlayer2 == null) + { + log.Error("[ChauffeurMode] [WORKFLOW] LocalPlayer is null!"); + worldCheckTask.SetResult((false, 0, 0u, 0u)); + } + else + { + ushort item = (ushort)localPlayer2.CurrentWorld.RowId; + ushort territoryType = clientState.TerritoryType; + worldCheckTask.SetResult((true, item, territoryType, zoneId)); + } + } + catch (Exception ex2) + { + log.Error("[ChauffeurMode] [WORKFLOW] Error checking world: " + ex2.Message); + worldCheckTask.SetResult((false, 0, 0u, 0u)); + } + }); + var (flag, helperCurrentWorld, helperCurrentZone, questerTargetZone) = await worldCheckTask.Task; + if (!flag) + { + log.Error("[ChauffeurMode] [WORKFLOW] Failed to check helper world!"); + framework.RunOnFrameworkThread(delegate + { + ResetHelperTransportState(); + }); + return; + } + log.Information($"[ChauffeurMode] [WORKFLOW] Helper on world {helperCurrentWorld}, zone {helperCurrentZone}"); + log.Information($"[ChauffeurMode] [WORKFLOW] Quester needs pickup in zone {questerTargetZone}"); + log.Information($"[ChauffeurMode] [WORKFLOW] Quester invite ID: {questerName}@{questerWorld}"); + ushort currentZone = clientState.TerritoryType; + log.Information("[ChauffeurMode] [WORKFLOW] Step 1: Checking mount status"); + TaskCompletionSource isMountedTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + bool flag2 = IsMounted(); + log.Information($"[ChauffeurMode] [WORKFLOW] Currently mounted: {flag2}"); + isMountedTask.SetResult(flag2); + } + catch (Exception ex2) + { + log.Error("[ChauffeurMode] [WORKFLOW] Error checking mount: " + ex2.Message); + isMountedTask.SetResult(result: false); + } + }); + if (!(await isMountedTask.Task)) + { + TaskCompletionSource canMountTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + bool flag2 = !condition[ConditionFlag.InCombat] && !condition[ConditionFlag.Mounted] && !condition[ConditionFlag.Casting] && !condition[ConditionFlag.BetweenAreas] && !condition[ConditionFlag.Jumping] && !condition[ConditionFlag.OccupiedInQuestEvent] && !condition[ConditionFlag.OccupiedInCutSceneEvent] && !condition[ConditionFlag.BoundByDuty] && !condition[ConditionFlag.BoundByDuty56] && !condition[ConditionFlag.BoundByDuty95]; + log.Information($"[ChauffeurMode] [WORKFLOW] Can mount in Helper's zone {helperCurrentZone}: {flag2}"); + canMountTask.SetResult(flag2); + } + catch (Exception ex2) + { + log.Error("[ChauffeurMode] [WORKFLOW] Error checking mount conditions: " + ex2.Message); + canMountTask.SetResult(result: false); + } + }); + if (!(await canMountTask.Task)) + { + log.Warning("[ChauffeurMode] [WORKFLOW] Cannot mount in Helper's current zone - will try after teleport"); + } + else + { + log.Information($"[ChauffeurMode] [WORKFLOW] Not mounted, summoning mount {config.ChauffeurMountId}"); + TaskCompletionSource mountTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + log.Information("[ChauffeurMode] [WORKFLOW] Executing mount summon on framework thread"); + bool result = SummonMountDirect(config.ChauffeurMountId); + mountTask.SetResult(result); + }); + if (!(await mountTask.Task)) + { + log.Warning("[ChauffeurMode] [WORKFLOW] Failed to summon mount - will try after teleport"); + } + log.Information("[ChauffeurMode] [WORKFLOW] Mount summon command sent, waiting for mount animation"); + await Task.Delay(3000); + TaskCompletionSource verifyTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + bool flag2 = IsMounted(); + log.Information($"[ChauffeurMode] [WORKFLOW] Mount verification: {flag2}"); + verifyTask.SetResult(flag2); + } + catch (Exception ex2) + { + log.Error("[ChauffeurMode] [WORKFLOW] Error verifying mount: " + ex2.Message); + verifyTask.SetResult(result: false); + } + }); + if (!(await verifyTask.Task)) + { + log.Warning("[ChauffeurMode] [WORKFLOW] Mount verification failed - will try after teleport"); + } + } + log.Information("[ChauffeurMode] [WORKFLOW] Mount verified successfully"); + } + else + { + log.Information("[ChauffeurMode] [WORKFLOW] Already mounted, skipping mount summon"); + } + bool didTeleport = false; + if (currentZone != zoneId) + { + log.Information($"[ChauffeurMode] [WORKFLOW] Step 2: Teleporting to zone {zoneId}"); + if (!(await TeleportToZone(zoneId))) + { + log.Error("[ChauffeurMode] [WORKFLOW] Failed to teleport to zone"); + return; + } + log.Information("[ChauffeurMode] [WORKFLOW] Waiting 10s for zone load and player spawn"); + await Task.Delay(10000); + didTeleport = true; + } + else + { + log.Information($"[ChauffeurMode] [WORKFLOW] Already in zone {zoneId}"); + } + if (didTeleport) + { + log.Information("[ChauffeurMode] [WORKFLOW] Step 2.5: Waiting additional 3s for player spawn and loading screen to complete"); + await Task.Delay(3000); + log.Information("[ChauffeurMode] [WORKFLOW] Verifying zone after teleport"); + TaskCompletionSource verifyZoneTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + ushort territoryType = clientState.TerritoryType; + bool flag2 = territoryType == zoneId; + log.Information($"[ChauffeurMode] [WORKFLOW] Current zone: {territoryType}, Target zone: {zoneId}, Match: {flag2}"); + verifyZoneTask.SetResult(flag2); + } + catch (Exception ex2) + { + log.Error("[ChauffeurMode] [WORKFLOW] Error verifying zone: " + ex2.Message); + verifyZoneTask.SetResult(result: false); + } + }); + bool inCorrectZone = await verifyZoneTask.Task; + if (!inCorrectZone) + { + log.Warning("[ChauffeurMode] [WORKFLOW] Not in correct zone yet! Will keep checking for up to 30 seconds..."); + int maxAttempts = 10; + for (int attempt = 1; attempt <= maxAttempts; attempt++) + { + if (inCorrectZone) + { + break; + } + await Task.Delay(3000); + TaskCompletionSource verifyZoneTaskRetry = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + ushort territoryType = clientState.TerritoryType; + bool flag2 = territoryType == zoneId; + log.Information($"[ChauffeurMode] [WORKFLOW] Zone check attempt {attempt}/{maxAttempts} - Current zone: {territoryType}, Target zone: {zoneId}, Match: {flag2}"); + verifyZoneTaskRetry.SetResult(flag2); + } + catch (Exception ex2) + { + log.Error($"[ChauffeurMode] [WORKFLOW] Error verifying zone (attempt {attempt}): {ex2.Message}"); + verifyZoneTaskRetry.SetResult(result: false); + } + }); + inCorrectZone = await verifyZoneTaskRetry.Task; + if (inCorrectZone) + { + log.Information($"[ChauffeurMode] [WORKFLOW] Zone verified after {attempt} attempts!"); + break; + } + } + if (!inCorrectZone) + { + log.Error("[ChauffeurMode] [WORKFLOW] Still not in correct zone after 30 seconds! Aborting."); + framework.RunOnFrameworkThread(delegate + { + ResetHelperTransportState(); + }); + return; + } + } + log.Information("[ChauffeurMode] [WORKFLOW] Zone verified - waiting 2s for loading screen to fully complete"); + await Task.Delay(2000); + log.Information("[ChauffeurMode] [WORKFLOW] In correct zone - checking mount status"); + TaskCompletionSource checkMountTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + bool flag2 = IsMounted(); + log.Information($"[ChauffeurMode] [WORKFLOW] Mount status after teleport: {flag2}"); + checkMountTask.SetResult(flag2); + } + catch (Exception ex2) + { + log.Error("[ChauffeurMode] [WORKFLOW] Error checking mount: " + ex2.Message); + checkMountTask.SetResult(result: false); + } + }); + if (!(await checkMountTask.Task)) + { + log.Information("[ChauffeurMode] [WORKFLOW] Not mounted after teleport - waiting 1s then re-mounting"); + await Task.Delay(1000); + log.Information("[ChauffeurMode] [WORKFLOW] Re-mounting now"); + TaskCompletionSource remountTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + log.Information("[ChauffeurMode] [WORKFLOW] Executing re-mount on framework thread"); + bool result = SummonMountDirect(config.ChauffeurMountId); + remountTask.SetResult(result); + }); + if (!(await remountTask.Task)) + { + log.Error("[ChauffeurMode] [WORKFLOW] Failed to re-summon mount after teleport"); + return; + } + log.Information("[ChauffeurMode] [WORKFLOW] Re-mount command sent, waiting for mount animation"); + await Task.Delay(3000); + TaskCompletionSource verifyRemountTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + bool flag2 = IsMounted(); + log.Information($"[ChauffeurMode] [WORKFLOW] Re-mount verification: {flag2}"); + verifyRemountTask.SetResult(flag2); + } + catch (Exception ex2) + { + log.Error("[ChauffeurMode] [WORKFLOW] Error verifying re-mount: " + ex2.Message); + verifyRemountTask.SetResult(result: false); + } + }); + if (!(await verifyRemountTask.Task)) + { + log.Error("[ChauffeurMode] [WORKFLOW] Re-mount verification failed - not mounted after teleport!"); + framework.RunOnFrameworkThread(delegate + { + ResetHelperTransportState(); + }); + return; + } + log.Information("[ChauffeurMode] [WORKFLOW] Re-mount verified successfully"); + } + else + { + log.Information("[ChauffeurMode] [WORKFLOW] Still mounted after teleport - no need to re-mount"); + } + } + Vector3 finalTargetPos = targetPos; + if (isAttuneAetheryte) + { + log.Information("[ChauffeurMode] [WORKFLOW] Step 2.9: AttuneAetheryte detected - finding landable spot"); + if (vnavmeshIPC.IsReady()) + { + log.Information($"[ChauffeurMode] [WORKFLOW] Searching for landable spot near target ({targetPos.X:F2}, {targetPos.Y:F2}, {targetPos.Z:F2})"); + Vector3? landableSpot = vnavmeshIPC.FindPointOnFloor(targetPos, allowUnlandable: false, 15f); + if (landableSpot.HasValue) + { + float distance = Vector3.Distance(targetPos, landableSpot.Value); + log.Information($"[ChauffeurMode] [WORKFLOW] Found landable spot {distance:F2} yalms from target: ({landableSpot.Value.X:F2}, {landableSpot.Value.Y:F2}, {landableSpot.Value.Z:F2})"); + finalTargetPos = landableSpot.Value; + } + else + { + log.Warning("[ChauffeurMode] [WORKFLOW] No landable spot found, using original target position"); + } + } + else + { + log.Warning("[ChauffeurMode] [WORKFLOW] vnavmesh not ready, using original target position"); + } + } + else + { + log.Information("[ChauffeurMode] [WORKFLOW] Step 2.9: Not AttuneAetheryte - using exact target position"); + } + log.Information($"[ChauffeurMode] [WORKFLOW] Step 3: Navigating to quester at ({questerPos.X:F2}, {questerPos.Y:F2}, {questerPos.Z:F2})"); + await NavigateToPosition(questerPos); + log.Information("[ChauffeurMode] [WORKFLOW] Arrived at quester position"); + TaskCompletionSource finalDistTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + IPlayerCharacter localPlayer2 = clientState.LocalPlayer; + if (localPlayer2 != null) + { + float result = Vector3.Distance(localPlayer2.Position, questerPos); + finalDistTask.SetResult(result); + } + else + { + finalDistTask.SetResult(999f); + } + } + catch + { + finalDistTask.SetResult(999f); + } + }); + float finalDist = await finalDistTask.Task; + if (finalDist > 5f) + { + log.Warning($"[ChauffeurMode] [WORKFLOW] Still too far ({finalDist:F2}y), moving closer manually"); + Vector3 closerPos = new Vector3(questerPos.X, questerPos.Y - 1f, questerPos.Z); + await NavigateToPosition(closerPos); + await Task.Delay(1000); + } + else + { + log.Information($"[ChauffeurMode] [WORKFLOW] Distance OK: {finalDist:F2}y"); + } + if (cancellationToken.IsCancellationRequested) + { + log.Information("[ChauffeurMode] [WORKFLOW] Workflow cancelled before party formation"); + framework.RunOnFrameworkThread(delegate + { + ResetHelperTransportState(); + }); + return; + } + log.Information("[ChauffeurMode] [WORKFLOW] Step 4: Ensuring party formation"); + bool inParty = false; + for (int partyAttempt = 0; partyAttempt < 10; partyAttempt++) + { + TaskCompletionSource partyCheckTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + bool result = partyList.Length > 0; + partyCheckTask.SetResult(result); + } + catch (Exception ex2) + { + log.Error("[ChauffeurMode] [WORKFLOW] Error checking party: " + ex2.Message); + partyCheckTask.SetResult(result: false); + } + }); + inParty = await partyCheckTask.Task; + if (inParty) + { + log.Information($"[ChauffeurMode] [WORKFLOW] Party formed! ({partyList.Length} members)"); + break; + } + log.Information($"[ChauffeurMode] [WORKFLOW] Not in party yet, sending invite (attempt {partyAttempt + 1}/10)"); + TaskCompletionSource inviteTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + bool result = partyInviteService.InviteToParty(questerName, questerWorld); + inviteTask.SetResult(result); + } + catch (Exception ex2) + { + log.Error("[ChauffeurMode] [WORKFLOW] Error inviting: " + ex2.Message); + inviteTask.SetResult(result: false); + } + }); + await inviteTask.Task; + await Task.Delay(2000); + } + if (!inParty) + { + log.Error("[ChauffeurMode] [WORKFLOW] Failed to form party after 10 attempts (20s)"); + log.Error("[ChauffeurMode] [WORKFLOW] Resetting helper state"); + ResetChauffeurState(); + return; + } + log.Information("[ChauffeurMode] [HELPER] ========================================"); + log.Information("[ChauffeurMode] [HELPER] === SIGNALING MOUNT READY ==="); + log.Information("[ChauffeurMode] [HELPER] ========================================"); + log.Information($"[ChauffeurMode] [HELPER] Sending mount ready signal to: {questerName}@{questerWorld}"); + log.Information($"[ChauffeurMode] [HELPER] Helper is mounted: {IsMounted()}"); + log.Information($"[ChauffeurMode] [HELPER] Helper position: ({clientState.LocalPlayer?.Position.X:F2}, {clientState.LocalPlayer?.Position.Y:F2}, {clientState.LocalPlayer?.Position.Z:F2})"); + crossProcessIPC.SendChauffeurMountReady(questerName, questerWorld); + log.Information("[ChauffeurMode] [HELPER] Mount ready signal sent via IPC"); + log.Information("[ChauffeurMode] [WORKFLOW] Waiting 8 seconds for quester to mount..."); + await Task.Delay(8000); + log.Information($"[ChauffeurMode] [WORKFLOW] Step 6: Transporting to target ({finalTargetPos.X:F2}, {finalTargetPos.Y:F2}, {finalTargetPos.Z:F2})"); + isTransportingQuester = true; + await NavigateToPositionWithPassengerMonitoring(finalTargetPos, questerPos, questerName, questerWorld); + log.Information("[ChauffeurMode] [HELPER] Arrived at destination"); + log.Information("[ChauffeurMode] [HELPER] Dismounting at destination"); + for (int partyAttempt = 0; partyAttempt < 3; partyAttempt++) + { + TaskCompletionSource checkMountTask2 = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + bool result = condition[ConditionFlag.Mounted]; + checkMountTask2.SetResult(result); + }); + if (!(await checkMountTask2.Task)) + { + log.Information("[ChauffeurMode] [HELPER] Already dismounted"); + break; + } + log.Information($"[ChauffeurMode] [HELPER] Dismount attempt {partyAttempt + 1}/3"); + await framework.RunOnFrameworkThread(delegate + { + ActionManager* ptr = ActionManager.Instance(); + if (ptr != null) + { + ptr->UseAction(ActionType.Mount, 0u, 3758096384uL, 0u, ActionManager.UseActionMode.None, 0u, null); + log.Information("[ChauffeurMode] [HELPER] Dismount action executed via ActionManager"); + } + else + { + log.Error("[ChauffeurMode] [HELPER] ActionManager is null!"); + } + }); + await Task.Delay(2000); + } + TaskCompletionSource dismountTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + bool flag2 = condition[ConditionFlag.Mounted]; + log.Information($"[ChauffeurMode] [HELPER] After dismount - Still mounted: {flag2}"); + dismountTask.SetResult(!flag2); + }); + await dismountTask.Task; + isTransportingQuester = false; + hasExecutedRidePillion = false; + config.AssignedQuester = ""; + config.CurrentHelperStatus = HelperStatus.Available; + config.Save(); + IPlayerCharacter localPlayer = clientState.LocalPlayer; + if (localPlayer != null) + { + string helperName = localPlayer.Name.ToString(); + ushort helperWorld = (ushort)localPlayer.HomeWorld.RowId; + crossProcessIPC.BroadcastHelperStatus(helperName, helperWorld, "Available"); + } + log.Information("[ChauffeurMode] [HELPER] Transport complete - FLAGS RESET + STATUS AVAILABLE (before notification)"); + log.Information($"[ChauffeurMode] [HELPER] Notifying Quester of arrival: {questerName}@{questerWorld}"); + crossProcessIPC.SendChauffeurArrived(questerName, questerWorld); + log.Information("[ChauffeurMode] [HELPER] Waiting for quester to restart Questionable and checking for AttuneAetheryte task..."); + await Task.Delay(3000); + bool isAttuneAetheryteTask = false; + await framework.RunOnFrameworkThread(delegate + { + try + { + object currentTask = questionableIPC.GetCurrentTask(); + if (currentTask != null && currentTask is JObject jObject) + { + JToken jToken = jObject["TaskName"]; + if (jToken != null) + { + string text = jToken.ToString(); + if (text.StartsWith("AttuneAetheryte(")) + { + isAttuneAetheryteTask = true; + log.Information("[ChauffeurMode] [HELPER] ✓ AttuneAetheryte task detected: " + text); + } + } + } + } + catch (Exception ex2) + { + log.Error("[ChauffeurMode] [HELPER] Error checking quester task: " + ex2.Message); + } + }); + if (isAttuneAetheryteTask && targetPosition.HasValue) + { + log.Information("[ChauffeurMode] [HELPER] AttuneAetheryte detected - flying 10 yalms away from target before dismount"); + Vector3 direction = Vector3.Normalize(await framework.RunOnFrameworkThread(() => clientState.LocalPlayer?.Position ?? Vector3.Zero) - targetPosition.Value); + Vector3 flyAwayPosition = targetPosition.Value + direction * 10f; + log.Information($"[ChauffeurMode] [HELPER] Flying to position 10 yalms away: ({flyAwayPosition.X:F2}, {flyAwayPosition.Y:F2}, {flyAwayPosition.Z:F2})"); + await framework.RunOnFrameworkThread(delegate + { + try + { + string text = $"/vnav flyto {flyAwayPosition.X:F2} {flyAwayPosition.Y:F2} {flyAwayPosition.Z:F2}"; + commandManager.ProcessCommand(text); + log.Information("[ChauffeurMode] [HELPER] Sent vnav flyto command: " + text); + } + catch (Exception ex2) + { + log.Error("[ChauffeurMode] [HELPER] Failed to send vnav flyto command: " + ex2.Message); + } + }); + DateTime timeout = DateTime.Now.AddSeconds(10.0); + while (DateTime.Now < timeout) + { + float distanceToTarget = Vector3.Distance(await framework.RunOnFrameworkThread(() => clientState.LocalPlayer?.Position ?? Vector3.Zero), targetPosition.Value); + if (distanceToTarget >= 10f) + { + log.Information($"[ChauffeurMode] [HELPER] Successfully flew away (distance: {distanceToTarget:F2} yalms)"); + break; + } + await Task.Delay(500); + } + await framework.RunOnFrameworkThread(delegate + { + commandManager.ProcessCommand("/vnav stop"); + }); + await Task.Delay(1000); + } + log.Information("[ChauffeurMode] [HELPER] Disbanding party"); + await framework.RunOnFrameworkThread(delegate + { + memoryHelper.SendChatMessage("/leave"); + log.Information("[ChauffeurMode] [HELPER] /leave command sent via UIModule"); + }); + } + catch (Exception ex) + { + log.Error("[ChauffeurMode] Helper workflow error: " + ex.Message); + log.Error("[ChauffeurMode] Stack trace: " + ex.StackTrace); + framework.RunOnFrameworkThread(delegate + { + ResetHelperTransportState(); + }); + } + } + + private async Task TeleportToZone(uint zoneId) + { + try + { + ExcelSheet territorySheet = dataManager.GetExcelSheet(); + if (territorySheet == null) + { + return false; + } + TerritoryType? territory = territorySheet.GetRowOrDefault(zoneId); + if (!territory.HasValue) + { + return false; + } + uint aetheryteId = territory.Value.Aetheryte.RowId; + if (aetheryteId == 0) + { + log.Warning($"[ChauffeurMode] No aetheryte found for zone {zoneId}"); + return false; + } + ExcelSheet aetheryteSheet = dataManager.GetExcelSheet(); + if (aetheryteSheet == null) + { + log.Warning("[ChauffeurMode] Could not load Aetheryte sheet"); + return false; + } + Lumina.Excel.Sheets.Aetheryte? aetheryte = aetheryteSheet.GetRowOrDefault(aetheryteId); + if (!aetheryte.HasValue) + { + log.Warning($"[ChauffeurMode] Aetheryte {aetheryteId} not found"); + return false; + } + string aetheryteName = aetheryte.Value.PlaceName.ValueNullable?.Name.ToString() ?? ""; + if (string.IsNullOrEmpty(aetheryteName)) + { + log.Warning($"[ChauffeurMode] Aetheryte {aetheryteId} has no name"); + return false; + } + string territoryName = territory.Value.PlaceName.ValueNullable?.Name.ToString() ?? ""; + string mappedName = MapTerritoryName(territoryName); + log.Information($"[ChauffeurMode] Teleporting to {mappedName} (Territory: {territoryName}, Aetheryte: {aetheryteName}, ID: {aetheryteId})"); + TaskCompletionSource tpTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + commandManager.ProcessCommand("/li " + mappedName); + tpTask.SetResult(result: true); + } + catch (Exception ex2) + { + log.Error("[ChauffeurMode] Error teleporting: " + ex2.Message); + tpTask.SetResult(result: false); + } + }); + await tpTask.Task; + return true; + } + catch (Exception ex) + { + log.Error("[ChauffeurMode] Teleport error: " + ex.Message); + return false; + } + } + + private unsafe bool SummonMountDirect(uint mountId) + { + try + { + log.Information($"[ChauffeurMode] [MOUNT] Summoning mount ID: {mountId}"); + ActionManager* actionManager = ActionManager.Instance(); + if (actionManager == null) + { + log.Error("[ChauffeurMode] [MOUNT] ActionManager is null"); + return false; + } + bool result = actionManager->UseAction(ActionType.Mount, mountId, 3758096384uL, 0u, ActionManager.UseActionMode.None, 0u, null); + log.Information($"[ChauffeurMode] [MOUNT] ActionManager.UseAction result: {result}"); + if (!result) + { + log.Warning("[ChauffeurMode] [MOUNT] ActionManager failed, trying command fallback"); + string mountName = GetMountName(mountId); + if (!string.IsNullOrEmpty(mountName)) + { + commandManager.ProcessCommand("/mount \"" + mountName + "\""); + log.Information("[ChauffeurMode] [MOUNT] Command sent: /mount \"" + mountName + "\""); + return true; + } + return false; + } + return true; + } + catch (Exception ex) + { + log.Error("[ChauffeurMode] [MOUNT] Exception: " + ex.Message); + log.Error("[ChauffeurMode] [MOUNT] StackTrace: " + ex.StackTrace); + return false; + } + } + + private string GetMountName(uint mountId) + { + try + { + ExcelSheet mountSheet = dataManager.GetExcelSheet(); + if (mountSheet == null) + { + return ""; + } + return mountSheet.GetRowOrDefault(mountId)?.Singular.ToString() ?? ""; + } + catch + { + return ""; + } + } + + private bool IsMounted() + { + try + { + if (clientState.LocalPlayer == null) + { + return false; + } + return condition[ConditionFlag.Mounted]; + } + catch (Exception ex) + { + log.Error("[ChauffeurMode] IsMounted error: " + ex.Message); + return false; + } + } + + private async Task NavigateToPosition(Vector3 targetPos) + { + try + { + log.Information("[ChauffeurMode] [NAV] ========================================"); + log.Information($"[ChauffeurMode] [NAV] Starting navigation to ({targetPos.X:F2}, {targetPos.Y:F2}, {targetPos.Z:F2})"); + log.Information($"[ChauffeurMode] [NAV] Thread ID: {Thread.CurrentThread.ManagedThreadId}"); + TaskCompletionSource stopNavTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + commandManager.ProcessCommand("/vnav stop"); + log.Information("[ChauffeurMode] [NAV] Stopped any existing navigation"); + stopNavTask.SetResult(result: true); + } + catch (Exception ex2) + { + log.Error("[ChauffeurMode] [NAV] Error stopping nav: " + ex2.Message); + stopNavTask.SetResult(result: false); + } + }); + await stopNavTask.Task; + await Task.Delay(500); + TaskCompletionSource flyTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + string text = $"/vnav flyto {targetPos.X.ToString(CultureInfo.InvariantCulture)} {targetPos.Y.ToString(CultureInfo.InvariantCulture)} {targetPos.Z.ToString(CultureInfo.InvariantCulture)}"; + log.Information("[ChauffeurMode] [NAV] Executing: " + text); + commandManager.ProcessCommand(text); + flyTask.SetResult(result: true); + } + catch (Exception ex2) + { + log.Error("[ChauffeurMode] [NAV] Command error: " + ex2.Message); + flyTask.SetResult(result: false); + } + }); + await flyTask.Task; + await Task.Delay(1000); + DateTime startTime = DateTime.Now; + TimeSpan timeout = TimeSpan.FromMinutes(5L); + DateTime lastLogTime = DateTime.Now; + int stuckCounter = 0; + float lastDistance = float.MaxValue; + while (DateTime.Now - startTime < timeout) + { + if (!isTransportingQuester) + { + log.Information("[ChauffeurMode] [NAV] Transport cancelled, stopping navigation"); + TaskCompletionSource cancelTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + commandManager.ProcessCommand("/vnav stop"); + cancelTask.SetResult(result: true); + } + catch + { + cancelTask.SetResult(result: false); + } + }); + await cancelTask.Task; + return; + } + TaskCompletionSource posTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + IPlayerCharacter localPlayer = clientState.LocalPlayer; + posTask.SetResult(localPlayer?.Position); + } + catch (Exception ex2) + { + log.Error("[ChauffeurMode] [NAV] Error getting player position: " + ex2.Message); + posTask.SetResult(null); + } + }); + Vector3? playerPos = await posTask.Task; + if (!playerPos.HasValue) + { + log.Warning("[ChauffeurMode] [NAV] Could not get player position"); + break; + } + float distance = Vector3.Distance(playerPos.Value, targetPos); + if (Math.Abs(distance - lastDistance) < 1f) + { + stuckCounter++; + if (stuckCounter > 10) + { + log.Warning($"[ChauffeurMode] [NAV] Stuck at distance {distance:F2}, aborting"); + break; + } + } + else + { + stuckCounter = 0; + } + lastDistance = distance; + float stopDistance = Math.Clamp(config.ChauffeurStopDistance, 2f, 15f); + if (distance < stopDistance) + { + log.Information($"Arrived at destination, distance {distance:F2} yalms"); + TaskCompletionSource arrivedTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + commandManager.ProcessCommand("/vnav stop"); + log.Information("[ChauffeurMode] [NAV] Navigation stopped"); + arrivedTask.SetResult(result: true); + } + catch + { + arrivedTask.SetResult(result: false); + } + }); + await arrivedTask.Task; + return; + } + if ((DateTime.Now - lastLogTime).TotalSeconds >= 5.0) + { + log.Information($"[ChauffeurMode] [NAV] Distance to target: {distance:F2} yalms"); + lastLogTime = DateTime.Now; + } + await Task.Delay(1000); + } + log.Warning("[ChauffeurMode] [NAV] Navigation timeout"); + TaskCompletionSource timeoutTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + commandManager.ProcessCommand("/vnav stop"); + log.Information("[ChauffeurMode] [NAV] Navigation stopped (timeout)"); + timeoutTask.SetResult(result: true); + } + catch + { + timeoutTask.SetResult(result: false); + } + }); + await timeoutTask.Task; + } + catch (Exception ex) + { + log.Error("[ChauffeurMode] [NAV] Navigation error: " + ex.Message); + log.Error("[ChauffeurMode] [NAV] StackTrace: " + ex.StackTrace); + } + } + + private async Task NavigateToPositionWithPassengerMonitoring(Vector3 targetPos, Vector3 questerStartPos, string questerName, ushort questerWorld) + { + float arrivalThreshold = Math.Clamp(config.ChauffeurStopDistance, 2f, 15f); + int stuckCounter = 0; + float lastDistance = 0f; + for (int attempt = 1; attempt <= 5; attempt++) + { + log.Information($"[ChauffeurMode] [TRANSPORT] === ATTEMPT {attempt}/{5} ==="); + log.Information("[ChauffeurMode] [TRANSPORT] Starting navigation to target"); + TaskCompletionSource startNavTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + commandManager.ProcessCommand("/vnav stop"); + Thread.Sleep(500); + string text = $"/vnav flyto {targetPos.X.ToString(CultureInfo.InvariantCulture)} {targetPos.Y.ToString(CultureInfo.InvariantCulture)} {targetPos.Z.ToString(CultureInfo.InvariantCulture)}"; + commandManager.ProcessCommand(text); + log.Information("[ChauffeurMode] [TRANSPORT] Navigation started: " + text); + startNavTask.SetResult(result: true); + } + catch (Exception ex) + { + log.Error("[ChauffeurMode] [TRANSPORT] Failed to start navigation: " + ex.Message); + startNavTask.SetResult(result: false); + } + }); + await startNavTask.Task; + await Task.Delay(2000); + TaskCompletionSource helperStartPosTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + Vector3? result = clientState.LocalPlayer?.Position; + helperStartPosTask.SetResult(result); + } + catch + { + helperStartPosTask.SetResult(null); + } + }); + Vector3? helperStartPos = await helperStartPosTask.Task; + if (!helperStartPos.HasValue) + { + log.Error("[ChauffeurMode] [TRANSPORT] Could not get helper position"); + continue; + } + log.Information($"[ChauffeurMode] [TRANSPORT] Helper start: ({helperStartPos.Value.X:F2}, {helperStartPos.Value.Y:F2}, {helperStartPos.Value.Z:F2})"); + log.Information("[ChauffeurMode] [TRANSPORT] Waiting 5 seconds to check if helper moved..."); + await Task.Delay(5000); + TaskCompletionSource helperCurrentPosTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + Vector3? result = clientState.LocalPlayer?.Position; + helperCurrentPosTask.SetResult(result); + } + catch + { + helperCurrentPosTask.SetResult(null); + } + }); + Vector3? helperCurrentPos = await helperCurrentPosTask.Task; + bool questerMovingWithUs = true; + if (helperCurrentPos.HasValue) + { + float helperDistanceMoved = Vector3.Distance(helperStartPos.Value, helperCurrentPos.Value); + log.Information($"[ChauffeurMode] [TRANSPORT] Helper moved {helperDistanceMoved:F2} yalms after 5 seconds"); + if (helperDistanceMoved < 10f) + { + log.Warning($"[ChauffeurMode] [TRANSPORT] ❌ Helper barely moved ({helperDistanceMoved:F2}y) - quester likely not on mount!"); + questerMovingWithUs = false; + } + else + { + log.Information($"[ChauffeurMode] [TRANSPORT] ✓ Helper moved significantly ({helperDistanceMoved:F2}y) - quester is on mount!"); + } + } + if (!questerMovingWithUs) + { + log.Warning("[ChauffeurMode] [TRANSPORT] Quester failed to stay on mount - returning to quester position"); + TaskCompletionSource stopTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + commandManager.ProcessCommand("/vnav stop"); + stopTask.SetResult(result: true); + } + catch + { + stopTask.SetResult(result: false); + } + }); + await stopTask.Task; + await Task.Delay(1000); + log.Information("[ChauffeurMode] [TRANSPORT] Returning to quester for retry..."); + await NavigateToPosition(questerStartPos); + await Task.Delay(2000); + log.Information("[ChauffeurMode] [TRANSPORT] Signaling mount ready for retry..."); + crossProcessIPC.SendChauffeurMountReady(questerName, questerWorld); + DateTime waitStart = DateTime.Now; + TimeSpan waitMax = TimeSpan.FromSeconds(30L); + bool mounted = false; + while (DateTime.Now - waitStart < waitMax) + { + TaskCompletionSource checkTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + bool result = condition[ConditionFlag.RidingPillion]; + checkTask.SetResult(result); + } + catch + { + checkTask.SetResult(result: false); + } + }); + if (await checkTask.Task) + { + log.Information("[ChauffeurMode] [TRANSPORT] ✓ Quester mounted for retry!"); + mounted = true; + break; + } + await Task.Delay(1000); + } + if (!mounted) + { + log.Error("[ChauffeurMode] [TRANSPORT] Quester failed to mount after retry - aborting"); + return; + } + continue; + } + log.Information("[ChauffeurMode] [TRANSPORT] ✓ Quester confirmed on mount - continuing to destination"); + DateTime arrivalStart = DateTime.Now; + TimeSpan arrivalTimeout = TimeSpan.FromMinutes(5L); + while (DateTime.Now - arrivalStart < arrivalTimeout) + { + if (!isTransportingQuester) + { + log.Information("[ChauffeurMode] [TRANSPORT] Transport cancelled"); + TaskCompletionSource cancelTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + commandManager.ProcessCommand("/vnav stop"); + cancelTask.SetResult(result: true); + } + catch + { + cancelTask.SetResult(result: false); + } + }); + await cancelTask.Task; + return; + } + TaskCompletionSource posTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + Vector3? result = clientState.LocalPlayer?.Position; + posTask.SetResult(result); + } + catch + { + posTask.SetResult(null); + } + }); + Vector3? currentPos = await posTask.Task; + if (currentPos.HasValue) + { + float distance = Vector3.Distance(currentPos.Value, targetPos); + if (lastDistance > 0f && Math.Abs(distance - lastDistance) < 1f) + { + stuckCounter++; + if (stuckCounter >= 5) + { + log.Warning($"[ChauffeurMode] [TRANSPORT] Stuck for 5 seconds at distance {distance:F2}"); + log.Information("[ChauffeurMode] [TRANSPORT] Moving 5 yalms backwards to unstuck"); + await framework.RunOnFrameworkThread(delegate + { + commandManager.ProcessCommand("/vnav stop"); + }); + await Task.Delay(500); + Vector3 direction = Vector3.Normalize(currentPos.Value - targetPos); + Vector3 backwardsPos = currentPos.Value + direction * 5f; + log.Information($"[ChauffeurMode] [TRANSPORT] Moving to backwards position: ({backwardsPos.X:F2}, {backwardsPos.Y:F2}, {backwardsPos.Z:F2})"); + await framework.RunOnFrameworkThread(delegate + { + commandManager.ProcessCommand($"/vnav {backwardsPos.X} {backwardsPos.Y} {backwardsPos.Z}"); + }); + await Task.Delay(3000); + await framework.RunOnFrameworkThread(delegate + { + commandManager.ProcessCommand("/vnav stop"); + }); + log.Information("[ChauffeurMode] [TRANSPORT] Unstuck complete, considering arrived"); + return; + } + } + else + { + stuckCounter = 0; + } + lastDistance = distance; + if (distance <= arrivalThreshold) + { + log.Information($"[ChauffeurMode] [TRANSPORT] Arrived at destination (distance: {distance:F2} yalms, threshold: {arrivalThreshold:F1})"); + TaskCompletionSource arrivedTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + commandManager.ProcessCommand("/vnav stop"); + arrivedTask.SetResult(result: true); + } + catch + { + arrivedTask.SetResult(result: false); + } + }); + await arrivedTask.Task; + return; + } + } + await Task.Delay(1000); + } + log.Warning("[ChauffeurMode] [TRANSPORT] Arrival timeout - but quester was on mount, so considering it success"); + return; + } + log.Error($"[ChauffeurMode] [TRANSPORT] Failed after {5} attempts - giving up"); + ResetChauffeurState(); + } + + private void OnChauffeurReadyForPickup(string helperName) + { + if (config.ChauffeurModeEnabled && config.IsQuester && isWaitingForHelper) + { + log.Information("[ChauffeurMode] ========================================"); + log.Information("[ChauffeurMode] === HELPER READY ==="); + log.Information("[ChauffeurMode] ========================================"); + log.Information("[ChauffeurMode] Helper " + helperName + " is ready for pickup"); + log.Information("[ChauffeurMode] [QUESTER] Waiting for mount ready signal..."); + } + } + + private unsafe void OnChauffeurMountReady(string questerName, ushort questerWorld) + { + if (!config.ChauffeurModeEnabled || !config.IsQuester || !isWaitingForHelper) + { + return; + } + if (hasExecutedRidePillion) + { + log.Debug("[ChauffeurMode] [QUESTER] RidePillion already executed this session, ignoring"); + return; + } + IPlayerCharacter localPlayer = clientState.LocalPlayer; + if (localPlayer == null) + { + return; + } + string myName = localPlayer.Name.ToString(); + ushort myWorld = (ushort)localPlayer.HomeWorld.RowId; + if (myName != questerName || myWorld != questerWorld) + { + log.Debug($"[ChauffeurMode] [QUESTER] Mount ready signal is for {questerName}@{questerWorld}, not for me ({myName}@{myWorld}) - ignoring"); + return; + } + log.Information("[ChauffeurMode] ========================================"); + log.Information("[ChauffeurMode] === MOUNT READY FOR RIDEPILLION ==="); + log.Information("[ChauffeurMode] ========================================"); + log.Information($"[ChauffeurMode] [QUESTER] This signal is for ME: {myName}@{myWorld}"); + hasExecutedRidePillion = true; + Task.Run(async delegate + { + _ = 11; + try + { + TaskCompletionSource isMountedTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + bool flag = IsMounted(); + log.Information($"[ChauffeurMode] [QUESTER] Currently mounted: {flag}"); + isMountedTask.SetResult(flag); + } + catch (Exception ex2) + { + log.Error("[ChauffeurMode] [QUESTER] Error checking mount: " + ex2.Message); + isMountedTask.SetResult(result: false); + } + }); + if (await isMountedTask.Task) + { + log.Information("[ChauffeurMode] [QUESTER] Dismounting before RidePillion (Condition 4 active)"); + for (int i = 0; i < 3; i++) + { + log.Information($"[ChauffeurMode] [QUESTER] Dismount attempt {i + 1}/3 using ActionManager"); + await framework.RunOnFrameworkThread(delegate + { + ActionManager* ptr = ActionManager.Instance(); + if (ptr != null) + { + ptr->UseAction(ActionType.Mount, 0u, 3758096384uL, 0u, ActionManager.UseActionMode.None, 0u, null); + log.Information("[ChauffeurMode] [QUESTER] Dismount action executed via ActionManager"); + } + else + { + log.Error("[ChauffeurMode] [QUESTER] ActionManager is null!"); + } + }); + await Task.Delay(2000); + TaskCompletionSource checkTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + bool flag = condition[ConditionFlag.Mounted]; + log.Information($"[ChauffeurMode] [QUESTER] After dismount attempt {i + 1} - Still mounted: {flag}"); + checkTask.SetResult(flag); + }); + if (!(await checkTask.Task)) + { + log.Information("[ChauffeurMode] [QUESTER] Successfully dismounted!"); + break; + } + } + } + else + { + log.Information("[ChauffeurMode] [QUESTER] Not mounted, no dismount needed"); + } + log.Information("[ChauffeurMode] [QUESTER] Finding Helper in party using IPartyList"); + TaskCompletionSource helperNameTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + IPlayerCharacter localPlayer2 = clientState.LocalPlayer; + if (localPlayer2 == null) + { + log.Error("[ChauffeurMode] [QUESTER] Local player is null"); + helperNameTask.SetResult(null); + } + else + { + string text = localPlayer2.Name.ToString(); + log.Information("[ChauffeurMode] [QUESTER] My name: " + text); + if (partyList != null && partyList.Length > 1) + { + log.Information($"[ChauffeurMode] [QUESTER] Party size: {partyList.Length}"); + for (int j = 0; j < partyList.Length; j++) + { + IPartyMember partyMember = partyList[j]; + if (partyMember != null) + { + string text2 = partyMember.Name.ToString(); + log.Information($"[ChauffeurMode] [QUESTER] Party member [{j}]: {text2}"); + if (text2 != text) + { + log.Information("[ChauffeurMode] [QUESTER] Found Helper in party: " + text2); + helperNameTask.SetResult(text2); + return; + } + } + } + } + log.Warning("[ChauffeurMode] [QUESTER] No Helper found in party (only found self)"); + helperNameTask.SetResult(null); + } + } + catch (Exception ex2) + { + log.Error("[ChauffeurMode] [QUESTER] Error finding Helper: " + ex2.Message); + helperNameTask.SetResult(null); + } + }); + string helperName = await helperNameTask.Task; + if (string.IsNullOrEmpty(helperName)) + { + log.Error("[ChauffeurMode] [QUESTER] Cannot execute RidePillion - Helper not found in party"); + } + else + { + log.Information("[ChauffeurMode] [QUESTER] Targeting Helper: " + helperName); + await framework.RunOnFrameworkThread(delegate + { + TargetSystem* ptr = TargetSystem.Instance(); + if (ptr == null) + { + log.Error("[ChauffeurMode] [QUESTER] TargetSystem is null!"); + } + else + { + if (partyList != null) + { + for (int j = 0; j < partyList.Length; j++) + { + IPartyMember partyMember = partyList[j]; + if (partyMember != null && partyMember.Name.ToString() == helperName) + { + IGameObject gameObject = partyMember.GameObject; + if (gameObject != null) + { + ptr->Target = (GameObject*)gameObject.Address; + log.Information("[ChauffeurMode] [QUESTER] Targeted Helper via TargetSystem: " + helperName); + return; + } + } + } + } + log.Warning("[ChauffeurMode] [QUESTER] Could not find Helper GameObject to target"); + } + }); + await Task.Delay(1000); + log.Information("[ChauffeurMode] [QUESTER] ========================================"); + log.Information("[ChauffeurMode] [QUESTER] === EXECUTING RIDEPILLION ==="); + log.Information("[ChauffeurMode] [QUESTER] ========================================"); + log.Information("[ChauffeurMode] [QUESTER] Helper name: " + helperName); + log.Information($"[ChauffeurMode] [QUESTER] Party size: {partyList.Length}"); + for (int i2 = 0; i2 < 3; i2++) + { + log.Information($"[ChauffeurMode] [QUESTER] --- RidePillion attempt {i2 + 1}/3 ---"); + await framework.RunOnFrameworkThread(delegate + { + log.Information("[ChauffeurMode] [QUESTER] Searching for Helper in party..."); + if (partyList != null) + { + for (int j = 0; j < partyList.Length; j++) + { + IPartyMember partyMember = partyList[j]; + if (partyMember != null) + { + string text = partyMember.Name.ToString(); + log.Information($"[ChauffeurMode] [QUESTER] Party member {j}: {text}"); + if (text == helperName) + { + log.Information("[ChauffeurMode] [QUESTER] Found Helper: " + helperName); + IGameObject gameObject = partyMember.GameObject; + if (gameObject != null) + { + log.Information($"[ChauffeurMode] [QUESTER] Helper GameObject address: 0x{gameObject.Address:X}"); + log.Information($"[ChauffeurMode] [QUESTER] Helper ObjectKind: {gameObject.ObjectKind}"); + log.Information($"[ChauffeurMode] [QUESTER] Helper Position: ({gameObject.Position.X:F2}, {gameObject.Position.Y:F2}, {gameObject.Position.Z:F2})"); + BattleChara* address = (BattleChara*)gameObject.Address; + log.Information($"[ChauffeurMode] [QUESTER] BattleChara pointer: 0x{address:X}"); + log.Information("[ChauffeurMode] [QUESTER] Calling MemoryHelper.ExecuteRidePillion(battleChara, 10)..."); + bool value = memoryHelper.ExecuteRidePillion(address); + log.Information($"[ChauffeurMode] [QUESTER] RidePillion Memory call result: {value}"); + return; + } + log.Warning("[ChauffeurMode] [QUESTER] Helper GameObject is NULL!"); + } + } + } + } + log.Warning("[ChauffeurMode] [QUESTER] Could not find Helper in party to execute RidePillion"); + }); + await Task.Delay(2000); + TaskCompletionSource checkTask2 = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + bool flag = condition[ConditionFlag.RidingPillion]; + log.Information($"[ChauffeurMode] [QUESTER] After RidePillion attempt {i2 + 1} - Mounted as passenger: {flag}"); + checkTask2.SetResult(flag); + }); + if (await checkTask2.Task) + { + log.Information("[ChauffeurMode] [QUESTER] Successfully mounted as passenger!"); + break; + } + } + TaskCompletionSource mountedTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + bool flag = condition[ConditionFlag.RidingPillion]; + log.Information($"[ChauffeurMode] [QUESTER] Mounted as passenger (Condition 10): {flag}"); + mountedTask.SetResult(flag); + }); + if (await mountedTask.Task) + { + log.Information("[ChauffeurMode] [QUESTER] Successfully mounted as passenger!"); + log.Information("[ChauffeurMode] [QUESTER] Sending mounted signal to Helper"); + TaskCompletionSource signalTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + crossProcessIPC.SendChauffeurPassengerMounted(); + log.Information("[ChauffeurMode] [QUESTER] Passenger mounted signal sent"); + signalTask.SetResult(result: true); + } + catch (Exception ex2) + { + log.Error("[ChauffeurMode] [QUESTER] Signal error: " + ex2.Message); + signalTask.SetResult(result: false); + } + }); + await signalTask.Task; + } + else + { + log.Warning("[ChauffeurMode] [QUESTER] RidePillion may have failed - not detected as passenger"); + } + log.Information("[ChauffeurMode] [QUESTER] Waiting for transport to complete..."); + log.Information("[ChauffeurMode] [QUESTER] Movement Monitor is stopped during transport"); + } + } + catch (Exception ex) + { + log.Error("[ChauffeurMode] [QUESTER] Error during mount ready: " + ex.Message); + } + }); + } + + private void OnChauffeurPassengerMounted() + { + log.Debug("[ChauffeurMode] OnChauffeurPassengerMounted event fired (handled inline)"); + } + + private void OnHelperStatusUpdate(string helperName, ushort helperWorld, string status) + { + if (!config.IsQuester) + { + return; + } + ExcelSheet worldSheet = dataManager.GetExcelSheet(); + string worldName = "Unknown"; + if (worldSheet != null) + { + foreach (World world in worldSheet) + { + if (world.RowId == helperWorld) + { + worldName = world.Name.ExtractText(); + break; + } + } + } + string helperKey = helperName + "@" + worldName; + helperStatuses[helperKey] = status; + log.Debug("[ChauffeurMode] [QUESTER] Helper status updated: " + helperKey + " = " + status); + } + + private void BroadcastHelperStatusPeriodically() + { + if (isDisposed) + { + log.Debug("[ChauffeurMode] [HELPER] Periodic broadcast stopped (service disposed)"); + return; + } + if (!config.IsHighLevelHelper) + { + if (!isDisposed) + { + framework.RunOnTick(delegate + { + BroadcastHelperStatusPeriodically(); + }, TimeSpan.FromSeconds(10L)); + } + return; + } + IPlayerCharacter localPlayer = clientState.LocalPlayer; + if (localPlayer != null) + { + try + { + string helperName = localPlayer.Name.ToString(); + ushort helperWorld = (ushort)localPlayer.HomeWorld.RowId; + string status = config.CurrentHelperStatus switch + { + HelperStatus.Available => "Available", + HelperStatus.Transporting => "Transporting", + HelperStatus.InDungeon => "InDungeon", + _ => "Available", + }; + crossProcessIPC.BroadcastHelperStatus(helperName, helperWorld, status); + log.Debug("[ChauffeurMode] [HELPER] Periodic status broadcast: " + status); + } + catch (ObjectDisposedException) + { + log.Debug("[ChauffeurMode] [HELPER] Periodic broadcast stopped (IPC disposed)"); + return; + } + catch (Exception ex2) + { + log.Error("[ChauffeurMode] [HELPER] Error in periodic broadcast: " + ex2.Message); + } + } + if (!isDisposed) + { + framework.RunOnTick(delegate + { + BroadcastHelperStatusPeriodically(); + }, TimeSpan.FromSeconds(10L)); + } + } + + private void OnChauffeurArrived(string questerName, ushort questerWorld) + { + if (!config.ChauffeurModeEnabled || !config.IsQuester || !isWaitingForHelper) + { + return; + } + IPlayerCharacter localPlayer = clientState.LocalPlayer; + if (localPlayer == null) + { + return; + } + string myName = localPlayer.Name.ToString(); + ushort myWorld = (ushort)localPlayer.HomeWorld.RowId; + if (myName != questerName || myWorld != questerWorld) + { + log.Debug($"[ChauffeurMode] [QUESTER] Arrived signal is for {questerName}@{questerWorld}, not for me ({myName}@{myWorld}) - ignoring"); + return; + } + log.Information("[ChauffeurMode] ========================================"); + log.Information("[ChauffeurMode] === ARRIVED AT DESTINATION ==="); + log.Information("[ChauffeurMode] ========================================"); + log.Information($"[ChauffeurMode] [QUESTER] This signal is for ME: {myName}@{myWorld}"); + Task.Run(async delegate + { + _ = 6; + try + { + TaskCompletionSource isMountedTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + bool flag = IsMounted(); + log.Information($"[ChauffeurMode] [QUESTER] Currently mounted: {flag}"); + isMountedTask.SetResult(flag); + } + catch (Exception ex2) + { + log.Error("[ChauffeurMode] [QUESTER] Error checking mount: " + ex2.Message); + isMountedTask.SetResult(result: false); + } + }); + bool num = await isMountedTask.Task; + log.Information("[ChauffeurMode] [QUESTER] ========================================"); + log.Information("[ChauffeurMode] [QUESTER] === ARRIVED AT DESTINATION ==="); + log.Information("[ChauffeurMode] [QUESTER] ========================================"); + if (num) + { + log.Information("[ChauffeurMode] [QUESTER] Dismounting from RidePillion (Condition 10 active)"); + TaskCompletionSource dismountTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + try + { + commandManager.ProcessCommand("/ridepillion"); + log.Information("[ChauffeurMode] [QUESTER] /ridepillion command sent to dismount"); + dismountTask.SetResult(result: true); + } + catch (Exception ex2) + { + log.Error("[ChauffeurMode] [QUESTER] Command error: " + ex2.Message); + dismountTask.SetResult(result: false); + } + }); + await dismountTask.Task; + log.Information("[ChauffeurMode] [QUESTER] Waiting 3 seconds for dismount..."); + await Task.Delay(3000); + TaskCompletionSource verifyTask = new TaskCompletionSource(); + framework.RunOnFrameworkThread(delegate + { + bool flag = condition[ConditionFlag.Mounted] || condition[ConditionFlag.RidingPillion]; + log.Information($"[ChauffeurMode] [QUESTER] After dismount - Still mounted: {flag}"); + verifyTask.SetResult(!flag); + }); + await verifyTask.Task; + } + else + { + log.Information("[ChauffeurMode] [QUESTER] Not mounted as passenger, skipping dismount"); + } + log.Information("[ChauffeurMode] [QUESTER] Leaving party"); + await framework.RunOnFrameworkThread(delegate + { + memoryHelper.SendChatMessage("/leave"); + log.Information("[ChauffeurMode] [QUESTER] /leave command sent via UIModule"); + }); + await Task.Delay(1000); + log.Information("[ChauffeurMode] [QUESTER] ========================================"); + log.Information("[ChauffeurMode] [QUESTER] === RESUMING QUESTIONABLE ==="); + log.Information("[ChauffeurMode] [QUESTER] ========================================"); + await framework.RunOnFrameworkThread(delegate + { + commandManager.ProcessCommand("/qst start"); + log.Information("[ChauffeurMode] [QUESTER] /qst start command sent"); + }); + isWaitingForHelper = false; + hasExecutedRidePillion = false; + targetPosition = null; + targetZoneId = 0u; + log.Information("[ChauffeurMode] Chauffeur transport complete - FLAGS RESET"); + } + catch (Exception ex) + { + log.Error("[ChauffeurMode] [QUESTER] Error during arrival: " + ex.Message); + } + }); + } + + private void OnTerritoryChanged(ushort territoryId) + { + if (!config.ChauffeurModeEnabled) + { + return; + } + if (isWaitingForHelper && targetZoneId != 0 && targetZoneId != territoryId) + { + log.Information($"[ChauffeurMode] Zone changed ({targetZoneId} -> {territoryId}) while waiting for helper, resetting state"); + ResetChauffeurState(); + } + if (config.IsQuester && !((DateTime.Now - lastZoneUpdate).TotalSeconds < 5.0)) + { + lastZoneUpdate = DateTime.Now; + IPlayerCharacter localPlayer = clientState.LocalPlayer; + if (localPlayer != null) + { + string zoneName = GetZoneName(territoryId); + log.Information($"[ChauffeurMode] Zone changed: {zoneName} ({territoryId})"); + log.Information("[ChauffeurMode] Territory Load State: Zone change detected, starting 8-second broadcast delay"); + lastZoneChangeTime = DateTime.Now; + log.Information("[ChauffeurMode] [QUESTER] Sending zone update to helper"); + crossProcessIPC.SendChauffeurZoneUpdate(localPlayer.Name.ToString(), (ushort)localPlayer.HomeWorld.RowId, territoryId, zoneName); + } + } + } + + private void OnChauffeurZoneUpdate(string questerName, ushort questerWorld, uint zoneId, string zoneName) + { + if (!config.ChauffeurModeEnabled || !config.IsHighLevelHelper) + { + return; + } + log.Debug($"[ChauffeurMode] Zone update received: {questerName}@{questerWorld} -> {zoneName} ({zoneId})"); + log.Debug("[ChauffeurMode] Auto-follow disabled, waiting for explicit summon"); + if (isTransportingQuester) + { + log.Information("[ChauffeurMode] Quester moved to different zone (" + zoneName + "), cancelling transport"); + ResetChauffeurState(); + framework.RunOnFrameworkThread(delegate + { + commandManager.ProcessCommand("/vnav stop"); + log.Information("[ChauffeurMode] Navigation stopped"); + }); + } + } + + private string GetZoneName(uint territoryId) + { + try + { + ExcelSheet territorySheet = dataManager.GetExcelSheet(); + if (territorySheet == null) + { + return $"Zone {territoryId}"; + } + TerritoryType? territory = territorySheet.GetRowOrDefault(territoryId); + if (!territory.HasValue) + { + return $"Zone {territoryId}"; + } + return territory.Value.PlaceName.ValueNullable?.Name.ToString() ?? $"Zone {territoryId}"; + } + catch + { + return $"Zone {territoryId}"; + } + } + + private void ResetHelperTransportState() + { + log.Warning("[ChauffeurMode] [HELPER] Resetting transport state due to workflow abort"); + isTransportingQuester = false; + if (config.IsHighLevelHelper && !string.IsNullOrEmpty(config.AssignedQuester)) + { + log.Information("[ChauffeurMode] [HELPER] Clearing assigned quester: " + config.AssignedQuester); + config.AssignedQuester = ""; + config.CurrentHelperStatus = HelperStatus.Available; + config.Save(); + IPlayerCharacter localPlayer = clientState.LocalPlayer; + if (localPlayer != null) + { + string helperName = localPlayer.Name.ToString(); + ushort helperWorld = (ushort)localPlayer.HomeWorld.RowId; + crossProcessIPC.BroadcastHelperStatus(helperName, helperWorld, "Available"); + } + } + } + + public void ResetChauffeurState() + { + log.Warning("[ChauffeurMode] ========================================"); + log.Warning("[ChauffeurMode] === RESETTING CHAUFFEUR STATE ==="); + log.Warning("[ChauffeurMode] ========================================"); + log.Warning($"[ChauffeurMode] IsWaitingForHelper: {isWaitingForHelper}, IsTransportingQuester: {isTransportingQuester}"); + if (helperWorkflowCts != null) + { + log.Information("[ChauffeurMode] Cancelling running helper workflow"); + helperWorkflowCts.Cancel(); + helperWorkflowCts.Dispose(); + helperWorkflowCts = null; + } + isWaitingForHelper = false; + isTransportingQuester = false; + hasExecutedRidePillion = false; + targetPosition = null; + targetZoneId = 0u; + questerName = null; + StopNavigation(); + if (config.IsHighLevelHelper) + { + if (!string.IsNullOrEmpty(config.AssignedQuester)) + { + log.Information("[ChauffeurMode] [HELPER] Clearing assigned quester: " + config.AssignedQuester); + config.AssignedQuester = ""; + } + config.CurrentHelperStatus = HelperStatus.Available; + config.Save(); + log.Information("[ChauffeurMode] [HELPER] Status: Available"); + IPlayerCharacter localPlayer = clientState.LocalPlayer; + if (localPlayer != null) + { + string helperName = localPlayer.Name.ToString(); + ushort helperWorld = (ushort)localPlayer.HomeWorld.RowId; + crossProcessIPC.BroadcastHelperStatus(helperName, helperWorld, "Available"); + } + } + } + + public void CheckHelperFollowing() + { + if (!config.EnableHelperFollowing) + { + if (isFollowingQuester) + { + StopFollowingQuester(); + } + } + else + { + if (!config.IsHighLevelHelper) + { + return; + } + if (string.IsNullOrEmpty(config.AssignedQuesterForFollowing)) + { + if (isFollowingQuester) + { + log.Warning("[HelperFollowing] Stopped - no assigned quester configured!"); + StopFollowingQuester(); + } + return; + } + if (condition[ConditionFlag.BoundByDuty] || condition[ConditionFlag.BoundByDuty56] || condition[ConditionFlag.BoundByDuty95]) + { + log.Debug("[HelperFollowing] Skipping - in duty/dungeon"); + if (isFollowingQuester) + { + log.Information("[HelperFollowing] Stopping - entered duty/dungeon"); + StopFollowingQuester(); + } + return; + } + if (isTransportingQuester) + { + log.Debug("[HelperFollowing] Skipping - currently transporting quester"); + if (isFollowingQuester) + { + log.Information("[HelperFollowing] Stopping - Chauffeur Mode active"); + StopFollowingQuester(); + } + return; + } + ushort currentZone = clientState.TerritoryType; + if (restrictedZones.Contains(currentZone)) + { + log.Debug($"[HelperFollowing] Skipping - in restricted zone {currentZone}"); + if (isFollowingQuester) + { + log.Information($"[HelperFollowing] Stopping - entered restricted zone {currentZone}"); + StopFollowingQuester(); + } + return; + } + if (BLACKLISTED_ZONES.Contains(currentZone)) + { + log.Debug($"[HelperFollowing] Skipping - in blacklisted zone {currentZone}"); + if (isFollowingQuester) + { + log.Information($"[HelperFollowing] Stopping - entered blacklisted zone {currentZone}"); + StopFollowingQuester(); + } + return; + } + DateTime now = DateTime.Now; + if ((now - lastFollowCheck).TotalSeconds < (double)config.HelperFollowCheckInterval) + { + return; + } + lastFollowCheck = now; + IPlayerCharacter localPlayer = clientState.LocalPlayer; + if (localPlayer == null) + { + return; + } + if (!lastQuesterPosition.HasValue || lastQuesterZone == 0) + { + if (isFollowingQuester) + { + log.Information("[HelperFollowing] Stopped - no quester position data"); + StopFollowingQuester(); + } + return; + } + if (restrictedZones.Contains(lastQuesterZone)) + { + log.Debug($"[HelperFollowing] Skipping - Quester is in restricted zone {lastQuesterZone}"); + if (isFollowingQuester) + { + log.Information($"[HelperFollowing] Stopping - Quester entered restricted zone {lastQuesterZone}"); + StopFollowingQuester(); + } + return; + } + if (BLACKLISTED_ZONES.Contains(lastQuesterZone)) + { + log.Debug($"[HelperFollowing] Skipping - Quester is in blacklisted zone {lastQuesterZone}"); + if (isFollowingQuester) + { + log.Information($"[HelperFollowing] Stopping - Quester entered blacklisted zone {lastQuesterZone}"); + StopFollowingQuester(); + } + return; + } + uint questerZone = lastQuesterZone; + if (questerZone != currentZone) + { + if (!IsMountingAllowed(questerZone)) + { + log.Debug($"[HelperFollowing] Skipping - Quester's zone {questerZone} does not allow mounting/flying"); + if (isFollowingQuester) + { + log.Information($"[HelperFollowing] Stopping - Quester entered non-flying zone {questerZone}"); + StopFollowingQuester(); + } + return; + } + if (HasFlyingInZone(questerZone)) + { + log.Debug($"[HelperFollowing] Quester can fly in zone {questerZone} - no need to follow"); + if (isFollowingQuester) + { + log.Information("[HelperFollowing] Stopping - Quester can fly in their zone"); + StopFollowingQuester(); + } + return; + } + log.Information($"[HelperFollowing] Quester in different zone ({questerZone} vs {currentZone}) - teleporting"); + Task.Run(async delegate + { + try + { + if (await TeleportToZone(questerZone)) + { + log.Information($"[HelperFollowing] Successfully teleported to zone {questerZone}"); + } + else + { + log.Warning($"[HelperFollowing] Failed to teleport to zone {questerZone}"); + } + } + catch (Exception ex) + { + log.Error("[HelperFollowing] Error teleporting to zone: " + ex.Message); + } + }); + return; + } + Vector3 questerPos = lastQuesterPosition.Value; + float distance = Vector3.Distance(localPlayer.Position, questerPos); + log.Debug($"[HelperFollowing] Distance to quester: {distance:F1} yalms (threshold: {config.HelperFollowDistance})"); + if (distance > config.HelperFollowDistance) + { + log.Information($"[HelperFollowing] Distance {distance:F1} > {config.HelperFollowDistance} - navigating to quester"); + if (!condition[ConditionFlag.Mounted] && config.ChauffeurMountId != 0) + { + log.Information("[HelperFollowing] Not mounted - summoning Chauffeur mount"); + framework.RunOnFrameworkThread(delegate + { + SummonMountDirect(config.ChauffeurMountId); + }); + return; + } + NavigateToQuester(questerPos); + if (!isFollowingQuester) + { + isFollowingQuester = true; + log.Information("[HelperFollowing] Started following " + followingQuesterName); + } + } + else if (isFollowingQuester) + { + log.Debug($"[HelperFollowing] Within range ({distance:F1} yalms) - stopping navigation"); + StopNavigation(); + } + lastQuesterPosition = questerPos; + lastQuesterZone = questerZone; + } + } + + private void BroadcastQuesterPosition() + { + if (!config.IsQuester || string.IsNullOrEmpty(config.AssignedHelperForFollowing)) + { + return; + } + if (condition[ConditionFlag.BoundByDuty] || condition[ConditionFlag.BoundByDuty56] || condition[ConditionFlag.BoundByDuty95]) + { + log.Debug("[HelperFollowing] Skipping broadcast - in duty/dungeon"); + return; + } + if (lastZoneChangeTime.HasValue) + { + double timeSinceZoneChange = (DateTime.Now - lastZoneChangeTime.Value).TotalSeconds; + if (timeSinceZoneChange < 8.0) + { + log.Debug($"[ChauffeurMode] [HelperFollowing] Territory Load State: Waiting for zone load (elapsed: {timeSinceZoneChange:F1}s / 8.0s)"); + return; + } + log.Information($"[ChauffeurMode] [HelperFollowing] Territory Load State: Zone load complete ({timeSinceZoneChange:F1}s) - resuming position broadcasts"); + lastZoneChangeTime = null; + } + IPlayerCharacter localPlayer = clientState.LocalPlayer; + if (localPlayer != null) + { + string questerName = localPlayer.Name.ToString(); + ushort questerWorld = (ushort)localPlayer.HomeWorld.RowId; + ushort currentZone = clientState.TerritoryType; + Vector3 position = localPlayer.Position; + crossProcessIPC.BroadcastQuesterPosition(questerName, questerWorld, currentZone, position); + } + } + + private void OnQuesterPositionUpdate(string questerName, ushort questerWorld, uint zoneId, Vector3 position) + { + if (!config.IsHighLevelHelper) + { + return; + } + string worldName = (dataManager.GetExcelSheet()?.GetRowOrDefault(questerWorld))?.Name.ToString() ?? questerWorld.ToString(); + string receivedQuesterKey = questerName + "@" + worldName; + discoveredQuesters[receivedQuesterKey] = DateTime.Now; + if (config.EnableHelperFollowing) + { + if (string.IsNullOrEmpty(config.AssignedQuesterForFollowing)) + { + log.Debug("[HelperFollowing] No assigned quester - ignoring position update"); + return; + } + if (receivedQuesterKey != config.AssignedQuesterForFollowing) + { + log.Debug("[HelperFollowing] Ignoring position from " + receivedQuesterKey + " - assigned quester is " + config.AssignedQuesterForFollowing); + return; + } + lastQuesterPosition = position; + lastQuesterZone = zoneId; + followingQuesterName = receivedQuesterKey; + log.Debug($"[HelperFollowing] Updated position for {followingQuesterName}: zone {zoneId}, pos ({position.X:F1},{position.Y:F1},{position.Z:F1})"); + } + } + + private IPartyMember? FindQuesterInParty() + { + if (partyList == null || partyList.Length == 0) + { + return null; + } + IPlayerCharacter localPlayer = clientState.LocalPlayer; + if (localPlayer == null) + { + return null; + } + string helperName = localPlayer.Name.ToString(); + uint helperWorld = localPlayer.HomeWorld.RowId; + for (int i = 0; i < partyList.Length; i++) + { + IPartyMember member = partyList[i]; + if (member == null) + { + continue; + } + string memberName = member.Name.ToString(); + uint memberWorld = member.World.RowId; + if (!(memberName == helperName) || memberWorld != helperWorld) + { + if (string.IsNullOrEmpty(config.AssignedQuester)) + { + log.Debug("[HelperFollowing] No assigned quester - following first party member: " + memberName); + return member; + } + string assignedQuester = config.AssignedQuester; + string memberKey = $"{memberName}@{memberWorld}"; + if (assignedQuester == memberKey) + { + log.Debug("[HelperFollowing] Found assigned quester: " + memberName); + return member; + } + } + } + return null; + } + + private void NavigateToQuester(Vector3 position) + { + try + { + string command = $"/vnav flyto {position.X.ToString(CultureInfo.InvariantCulture)} {position.Y.ToString(CultureInfo.InvariantCulture)} {position.Z.ToString(CultureInfo.InvariantCulture)}"; + framework.RunOnFrameworkThread(delegate + { + commandManager.ProcessCommand(command); + log.Debug("[HelperFollowing] Sent: " + command); + }); + } + catch (Exception ex) + { + log.Error("[HelperFollowing] Error navigating to quester: " + ex.Message); + } + } + + private void StopNavigation() + { + try + { + framework.RunOnFrameworkThread(delegate + { + commandManager.ProcessCommand("/vnav stop"); + log.Debug("[HelperFollowing] Stopped navigation"); + }); + } + catch (Exception ex) + { + log.Error("[HelperFollowing] Error stopping navigation: " + ex.Message); + } + } + + private void StopFollowingQuester() + { + if (isFollowingQuester) + { + log.Information("[HelperFollowing] Stopped following " + followingQuesterName); + StopNavigation(); + isFollowingQuester = false; + followingQuesterName = null; + lastQuesterPosition = null; + } + } + + public void Dispose() + { + isDisposed = true; + if (isFollowingQuester) + { + StopFollowingQuester(); + } + if (crossProcessIPC != null) + { + crossProcessIPC.OnChauffeurSummonRequest -= OnChauffeurSummonRequest; + crossProcessIPC.OnChauffeurReadyForPickup -= OnChauffeurReadyForPickup; + crossProcessIPC.OnChauffeurArrived -= OnChauffeurArrived; + crossProcessIPC.OnChauffeurZoneUpdate -= OnChauffeurZoneUpdate; + crossProcessIPC.OnChauffeurMountReady -= OnChauffeurMountReady; + crossProcessIPC.OnHelperStatusUpdate -= OnHelperStatusUpdate; + crossProcessIPC.OnQuesterPositionUpdate -= OnQuesterPositionUpdate; + } + if (clientState != null) + { + clientState.TerritoryChanged -= OnTerritoryChanged; + } + vnavmeshIPC?.Dispose(); + if (framework != null) + { + framework.Update -= OnFrameworkUpdate; + } + log.Information("[ChauffeurMode] Service disposed"); + } +} diff --git a/QuestionableCompanion/QSTCompanion.csproj b/QuestionableCompanion/QSTCompanion.csproj new file mode 100644 index 0000000..1ab981d --- /dev/null +++ b/QuestionableCompanion/QSTCompanion.csproj @@ -0,0 +1,37 @@ + + + QSTCompanion + False + netcoreapp9.0 + x64 + + + 12.0 + True + + + + + + ..\..\..\..\..\ffxiv\alyssile-xivl\addon\Hooks\dev\Dalamud.dll + + + ..\..\..\..\..\ffxiv\alyssile-xivl\addon\Hooks\dev\Newtonsoft.Json.dll + + + ..\..\..\..\..\ffxiv\alyssile-xivl\addon\Hooks\dev\Lumina.dll + + + ..\..\..\..\..\ffxiv\alyssile-xivl\addon\Hooks\dev\Lumina.Excel.dll + + + ..\..\..\..\..\ffxiv\alyssile-xivl\addon\Hooks\dev\FFXIVClientStructs.dll + + + ..\..\..\..\..\ffxiv\alyssile-xivl\addon\Hooks\dev\Dalamud.Bindings.ImGui.dll + + + ..\..\..\..\..\ffxiv\alyssile-xivl\addon\Hooks\dev\InteropGenerator.Runtime.dll + + + \ No newline at end of file diff --git a/QuestionableCompanion/QuestionableCompanion.Data/ExpansionProgress.cs b/QuestionableCompanion/QuestionableCompanion.Data/ExpansionProgress.cs new file mode 100644 index 0000000..63aa157 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Data/ExpansionProgress.cs @@ -0,0 +1,18 @@ +namespace QuestionableCompanion.Data; + +public class ExpansionProgress +{ + public MSQExpansionData.Expansion Expansion { get; set; } + + public int CompletedCount { get; set; } + + public int ExpectedCount { get; set; } + + public float Percentage { get; set; } + + public bool IsComplete { get; set; } + + public string ExpansionName => MSQExpansionData.GetExpansionName(Expansion); + + public string ExpansionShortName => MSQExpansionData.GetExpansionShortName(Expansion); +} diff --git a/QuestionableCompanion/QuestionableCompanion.Data/MSQExpansionData.cs b/QuestionableCompanion/QuestionableCompanion.Data/MSQExpansionData.cs new file mode 100644 index 0000000..a89dcaf --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Data/MSQExpansionData.cs @@ -0,0 +1,479 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; + +namespace QuestionableCompanion.Data; + +public static class MSQExpansionData +{ + public enum Expansion + { + ARealmReborn, + Heavensward, + Stormblood, + Shadowbringers, + Endwalker, + Dawntrail + } + + private static readonly Dictionary> ExpansionQuests = new Dictionary> + { + { + Expansion.ARealmReborn, + new HashSet() + }, + { + Expansion.Heavensward, + new HashSet() + }, + { + Expansion.Stormblood, + new HashSet() + }, + { + Expansion.Shadowbringers, + new HashSet() + }, + { + Expansion.Endwalker, + new HashSet() + }, + { + Expansion.Dawntrail, + new HashSet() + } + }; + + private static readonly Dictionary ExpectedQuestCounts = new Dictionary + { + { + Expansion.ARealmReborn, + 200 + }, + { + Expansion.Heavensward, + 100 + }, + { + Expansion.Stormblood, + 100 + }, + { + Expansion.Shadowbringers, + 100 + }, + { + Expansion.Endwalker, + 100 + }, + { + Expansion.Dawntrail, + 100 + } + }; + + private static readonly Dictionary ExpansionNames = new Dictionary + { + { + Expansion.ARealmReborn, + "A Realm Reborn" + }, + { + Expansion.Heavensward, + "Heavensward" + }, + { + Expansion.Stormblood, + "Stormblood" + }, + { + Expansion.Shadowbringers, + "Shadowbringers" + }, + { + Expansion.Endwalker, + "Endwalker" + }, + { + Expansion.Dawntrail, + "Dawntrail" + } + }; + + private static readonly Dictionary ExpansionShortNames = new Dictionary + { + { + Expansion.ARealmReborn, + "ARR" + }, + { + Expansion.Heavensward, + "HW" + }, + { + Expansion.Stormblood, + "SB" + }, + { + Expansion.Shadowbringers, + "ShB" + }, + { + Expansion.Endwalker, + "EW" + }, + { + Expansion.Dawntrail, + "DT" + } + }; + + public static void RegisterQuest(uint questId, Expansion expansion) + { + if (ExpansionQuests.TryGetValue(expansion, out HashSet quests)) + { + quests.Add(questId); + } + } + + public static void ClearQuests() + { + foreach (HashSet value in ExpansionQuests.Values) + { + value.Clear(); + } + } + + public static Expansion GetExpansionForQuest(uint questId) + { + foreach (var (expansion2, hashSet2) in ExpansionQuests) + { + if (hashSet2.Contains(questId)) + { + return expansion2; + } + } + return Expansion.ARealmReborn; + } + + public static IReadOnlySet GetQuestsForExpansion(Expansion expansion) + { + if (!ExpansionQuests.TryGetValue(expansion, out HashSet quests)) + { + return new HashSet(); + } + return quests; + } + + public static int GetExpectedQuestCount(Expansion expansion) + { + if (!ExpectedQuestCounts.TryGetValue(expansion, out var count)) + { + return 0; + } + return count; + } + + public static string GetExpansionName(Expansion expansion) + { + if (!ExpansionNames.TryGetValue(expansion, out string name)) + { + return "Unknown"; + } + return name; + } + + public static string GetExpansionShortName(Expansion expansion) + { + if (!ExpansionShortNames.TryGetValue(expansion, out string name)) + { + return "???"; + } + return name; + } + + public static IEnumerable GetAllExpansions() + { + return from e in Enum.GetValues() + orderby (int)e + select e; + } + + public static int GetCompletedQuestCountForExpansion(IEnumerable completedQuestIds, Expansion expansion) + { + IReadOnlySet expansionQuests = GetQuestsForExpansion(expansion); + return completedQuestIds.Count((uint qId) => expansionQuests.Contains(qId)); + } + + public unsafe static (Expansion expansion, string debugInfo) GetCurrentExpansionFromGameWithDebug() + { + StringBuilder debug = new StringBuilder(); + debug.AppendLine("=== AGENT SCENARIO TREE DEBUG ==="); + try + { + AgentScenarioTree* agentScenarioTree = AgentScenarioTree.Instance(); + StringBuilder stringBuilder = debug; + StringBuilder stringBuilder2 = stringBuilder; + StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(30, 1, stringBuilder); + handler.AppendLiteral("AgentScenarioTree.Instance(): "); + handler.AppendFormatted((agentScenarioTree != null) ? "OK" : "NULL"); + stringBuilder2.AppendLine(ref handler); + if (agentScenarioTree == null) + { + debug.AppendLine("ERROR: AgentScenarioTree is NULL!"); + return (expansion: Expansion.ARealmReborn, debugInfo: debug.ToString()); + } + stringBuilder = debug; + StringBuilder stringBuilder3 = stringBuilder; + handler = new StringBuilder.AppendInterpolatedStringHandler(25, 1, stringBuilder); + handler.AppendLiteral("AgentScenarioTree->Data: "); + handler.AppendFormatted((agentScenarioTree->Data != null) ? "OK" : "NULL"); + stringBuilder3.AppendLine(ref handler); + if (agentScenarioTree->Data == null) + { + debug.AppendLine("ERROR: AgentScenarioTree->Data is NULL!"); + return (expansion: Expansion.ARealmReborn, debugInfo: debug.ToString()); + } + ushort currentQuest = agentScenarioTree->Data->CurrentScenarioQuest; + ushort completedQuest = agentScenarioTree->Data->CompleteScenarioQuest; + stringBuilder = debug; + StringBuilder stringBuilder4 = stringBuilder; + handler = new StringBuilder.AppendInterpolatedStringHandler(33, 2, stringBuilder); + handler.AppendLiteral("CurrentScenarioQuest (raw): "); + handler.AppendFormatted(currentQuest); + handler.AppendLiteral(" (0x"); + handler.AppendFormatted(currentQuest, "X4"); + handler.AppendLiteral(")"); + stringBuilder4.AppendLine(ref handler); + stringBuilder = debug; + StringBuilder stringBuilder5 = stringBuilder; + handler = new StringBuilder.AppendInterpolatedStringHandler(34, 2, stringBuilder); + handler.AppendLiteral("CompleteScenarioQuest (raw): "); + handler.AppendFormatted(completedQuest); + handler.AppendLiteral(" (0x"); + handler.AppendFormatted(completedQuest, "X4"); + handler.AppendLiteral(")"); + stringBuilder5.AppendLine(ref handler); + ushort questToCheck = ((currentQuest != 0) ? currentQuest : completedQuest); + stringBuilder = debug; + StringBuilder stringBuilder6 = stringBuilder; + handler = new StringBuilder.AppendInterpolatedStringHandler(25, 2, stringBuilder); + handler.AppendLiteral("Quest to check: "); + handler.AppendFormatted(questToCheck); + handler.AppendLiteral(" (using "); + handler.AppendFormatted((currentQuest != 0) ? "Current" : "Completed"); + handler.AppendLiteral(")"); + stringBuilder6.AppendLine(ref handler); + if (questToCheck == 0) + { + debug.AppendLine("WARNING: Both CurrentScenarioQuest and CompleteScenarioQuest are 0!"); + return (expansion: Expansion.ARealmReborn, debugInfo: debug.ToString()); + } + uint questId = (uint)(questToCheck | 0x10000); + stringBuilder = debug; + StringBuilder stringBuilder7 = stringBuilder; + handler = new StringBuilder.AppendInterpolatedStringHandler(25, 2, stringBuilder); + handler.AppendLiteral("Converted Quest ID: "); + handler.AppendFormatted(questId); + handler.AppendLiteral(" (0x"); + handler.AppendFormatted(questId, "X8"); + handler.AppendLiteral(")"); + stringBuilder7.AppendLine(ref handler); + Expansion expansion = GetExpansionForQuest(questId); + stringBuilder = debug; + StringBuilder stringBuilder8 = stringBuilder; + handler = new StringBuilder.AppendInterpolatedStringHandler(22, 2, stringBuilder); + handler.AppendLiteral("Expansion for Quest "); + handler.AppendFormatted(questId); + handler.AppendLiteral(": "); + handler.AppendFormatted(GetExpansionName(expansion)); + stringBuilder8.AppendLine(ref handler); + IReadOnlySet expansionQuests = GetQuestsForExpansion(expansion); + bool isRegistered = expansionQuests.Contains(questId); + stringBuilder = debug; + StringBuilder stringBuilder9 = stringBuilder; + handler = new StringBuilder.AppendInterpolatedStringHandler(23, 3, stringBuilder); + handler.AppendLiteral("Quest "); + handler.AppendFormatted(questId); + handler.AppendLiteral(" registered in "); + handler.AppendFormatted(expansion); + handler.AppendLiteral(": "); + handler.AppendFormatted(isRegistered); + stringBuilder9.AppendLine(ref handler); + if (!isRegistered) + { + stringBuilder = debug; + StringBuilder stringBuilder10 = stringBuilder; + handler = new StringBuilder.AppendInterpolatedStringHandler(48, 1, stringBuilder); + handler.AppendLiteral("WARNING: Quest "); + handler.AppendFormatted(questId); + handler.AppendLiteral(" is NOT in our registered quests!"); + stringBuilder10.AppendLine(ref handler); + stringBuilder = debug; + StringBuilder stringBuilder11 = stringBuilder; + handler = new StringBuilder.AppendInterpolatedStringHandler(30, 2, stringBuilder); + handler.AppendLiteral("Total registered quests for "); + handler.AppendFormatted(expansion); + handler.AppendLiteral(": "); + handler.AppendFormatted(expansionQuests.Count); + stringBuilder11.AppendLine(ref handler); + foreach (Expansion exp in GetAllExpansions()) + { + if (GetQuestsForExpansion(exp).Contains(questId)) + { + stringBuilder = debug; + StringBuilder stringBuilder12 = stringBuilder; + handler = new StringBuilder.AppendInterpolatedStringHandler(10, 1, stringBuilder); + handler.AppendLiteral("FOUND in "); + handler.AppendFormatted(exp); + handler.AppendLiteral("!"); + stringBuilder12.AppendLine(ref handler); + expansion = exp; + break; + } + } + } + stringBuilder = debug; + StringBuilder stringBuilder13 = stringBuilder; + handler = new StringBuilder.AppendInterpolatedStringHandler(25, 1, stringBuilder); + handler.AppendLiteral(">>> FINAL EXPANSION: "); + handler.AppendFormatted(GetExpansionName(expansion)); + handler.AppendLiteral(" <<<"); + stringBuilder13.AppendLine(ref handler); + return (expansion: expansion, debugInfo: debug.ToString()); + } + catch (Exception ex) + { + StringBuilder stringBuilder = debug; + StringBuilder stringBuilder14 = stringBuilder; + StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(11, 1, stringBuilder); + handler.AppendLiteral("EXCEPTION: "); + handler.AppendFormatted(ex.Message); + stringBuilder14.AppendLine(ref handler); + stringBuilder = debug; + StringBuilder stringBuilder15 = stringBuilder; + handler = new StringBuilder.AppendInterpolatedStringHandler(7, 1, stringBuilder); + handler.AppendLiteral("Stack: "); + handler.AppendFormatted(ex.StackTrace); + stringBuilder15.AppendLine(ref handler); + return (expansion: Expansion.ARealmReborn, debugInfo: debug.ToString()); + } + } + + public static Expansion GetCurrentExpansionFromGame() + { + return GetCurrentExpansionFromGameWithDebug().expansion; + } + + public static Expansion GetCurrentExpansion(IEnumerable completedQuestIds) + { + List questList = completedQuestIds.ToList(); + if (questList.Count == 0) + { + return Expansion.ARealmReborn; + } + foreach (Expansion expansion in GetAllExpansions().Reverse().ToList()) + { + IReadOnlySet expansionQuests = GetQuestsForExpansion(expansion); + if (questList.Where((uint qId) => expansionQuests.Contains(qId)).ToList().Count > 0) + { + return expansion; + } + } + return Expansion.ARealmReborn; + } + + public static string GetExpansionDetectionDebugInfo(IEnumerable completedQuestIds) + { + List questList = completedQuestIds.ToList(); + StringBuilder result = new StringBuilder(); + result.AppendLine("=== EXPANSION DETECTION DEBUG ==="); + StringBuilder stringBuilder = result; + StringBuilder stringBuilder2 = stringBuilder; + StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(24, 1, stringBuilder); + handler.AppendLiteral("Total completed quests: "); + handler.AppendFormatted(questList.Count); + stringBuilder2.AppendLine(ref handler); + result.AppendLine(""); + result.AppendLine("Checking expansions from highest to lowest:"); + result.AppendLine(""); + foreach (Expansion expansion in GetAllExpansions().Reverse()) + { + IReadOnlySet expansionQuests = GetQuestsForExpansion(expansion); + List completedInExpansion = questList.Where((uint qId) => expansionQuests.Contains(qId)).ToList(); + float percentage = ((expansionQuests.Count > 0) ? ((float)completedInExpansion.Count / (float)expansionQuests.Count * 100f) : 0f); + stringBuilder = result; + StringBuilder stringBuilder3 = stringBuilder; + handler = new StringBuilder.AppendInterpolatedStringHandler(4, 2, stringBuilder); + handler.AppendFormatted(GetExpansionName(expansion)); + handler.AppendLiteral(" ("); + handler.AppendFormatted(GetExpansionShortName(expansion)); + handler.AppendLiteral("):"); + stringBuilder3.AppendLine(ref handler); + stringBuilder = result; + StringBuilder stringBuilder4 = stringBuilder; + handler = new StringBuilder.AppendInterpolatedStringHandler(28, 1, stringBuilder); + handler.AppendLiteral(" - Total MSQ in expansion: "); + handler.AppendFormatted(expansionQuests.Count); + stringBuilder4.AppendLine(ref handler); + stringBuilder = result; + StringBuilder stringBuilder5 = stringBuilder; + handler = new StringBuilder.AppendInterpolatedStringHandler(32, 2, stringBuilder); + handler.AppendLiteral(" - Completed by character: "); + handler.AppendFormatted(completedInExpansion.Count); + handler.AppendLiteral(" ("); + handler.AppendFormatted(percentage, "F1"); + handler.AppendLiteral("%)"); + stringBuilder5.AppendLine(ref handler); + if (completedInExpansion.Count > 0) + { + string samples = string.Join(", ", completedInExpansion.OrderByDescending((uint x) => x).Take(5)); + stringBuilder = result; + StringBuilder stringBuilder6 = stringBuilder; + handler = new StringBuilder.AppendInterpolatedStringHandler(22, 1, stringBuilder); + handler.AppendLiteral(" - Sample Quest IDs: "); + handler.AppendFormatted(samples); + stringBuilder6.AppendLine(ref handler); + result.AppendLine(" >>> HAS COMPLETED QUESTS - WOULD SELECT THIS EXPANSION <<<"); + } + else + { + result.AppendLine(" - No quests completed in this expansion"); + } + result.AppendLine(""); + } + Expansion currentExpansion = GetCurrentExpansion(questList); + result.AppendLine("==========================================="); + stringBuilder = result; + StringBuilder stringBuilder7 = stringBuilder; + handler = new StringBuilder.AppendInterpolatedStringHandler(34, 1, stringBuilder); + handler.AppendLiteral(">>> FINAL DETECTED EXPANSION: "); + handler.AppendFormatted(GetExpansionName(currentExpansion)); + handler.AppendLiteral(" <<<"); + stringBuilder7.AppendLine(ref handler); + result.AppendLine("==========================================="); + return result.ToString(); + } + + public static ExpansionProgress GetExpansionProgress(IEnumerable completedQuestIds, Expansion expansion) + { + int completed = GetCompletedQuestCountForExpansion(completedQuestIds, expansion); + int expected = GetExpectedQuestCount(expansion); + return new ExpansionProgress + { + Expansion = expansion, + CompletedCount = completed, + ExpectedCount = expected, + Percentage = ((expected > 0) ? ((float)completed / (float)expected * 100f) : 0f), + IsComplete = (completed >= expected) + }; + } + + public static List GetAllExpansionProgress(IEnumerable completedQuestIds) + { + return (from exp in GetAllExpansions() + select GetExpansionProgress(completedQuestIds, exp)).ToList(); + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Helpers/ImGuiDragDrop.cs b/QuestionableCompanion/QuestionableCompanion.Helpers/ImGuiDragDrop.cs new file mode 100644 index 0000000..b5c29a9 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Helpers/ImGuiDragDrop.cs @@ -0,0 +1,22 @@ +using System; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Dalamud.Bindings.ImGui; + +namespace QuestionableCompanion.Helpers; + +public static class ImGuiDragDrop +{ + public static void SetDragDropPayload(string type, T data, ImGuiCond cond = ImGuiCond.None) where T : struct + { + ReadOnlySpan span = MemoryMarshal.AsBytes(MemoryMarshal.CreateReadOnlySpan(in data, 1)); + ImGui.SetDragDropPayload(type, span, cond); + } + + public unsafe static bool AcceptDragDropPayload(string type, out T payload, ImGuiDragDropFlags flags = ImGuiDragDropFlags.None) where T : struct + { + ImGuiPayload* pload = ImGui.AcceptDragDropPayload(type, flags); + payload = ((pload != null) ? Unsafe.Read(pload->Data) : default(T)); + return pload != null; + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyCharacterStatus.cs b/QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyCharacterStatus.cs new file mode 100644 index 0000000..46bca3f --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyCharacterStatus.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace QuestionableCompanion.Models; + +public class AlliedSocietyCharacterStatus +{ + public required string CharacterId { get; set; } + + public AlliedSocietyRotationStatus Status { get; set; } + + public DateTime? LastCompletionDate { get; set; } + + public List ImportedQuestIds { get; set; } = new List(); +} diff --git a/QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyConfiguration.cs b/QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyConfiguration.cs new file mode 100644 index 0000000..4004e4e --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyConfiguration.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace QuestionableCompanion.Models; + +public class AlliedSocietyConfiguration +{ + public List Priorities { get; set; } = new List(); + + public AlliedSocietyQuestMode QuestMode { get; set; } + + public void InitializeDefaults() + { + Priorities.Clear(); + for (byte i = 1; i <= 20; i++) + { + Priorities.Add(new AlliedSocietyPriority + { + SocietyId = i, + Enabled = true, + Order = i - 1 + }); + } + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyPriority.cs b/QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyPriority.cs new file mode 100644 index 0000000..b46d9f6 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyPriority.cs @@ -0,0 +1,10 @@ +namespace QuestionableCompanion.Models; + +public class AlliedSocietyPriority +{ + public required byte SocietyId { get; set; } + + public bool Enabled { get; set; } + + public int Order { get; set; } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyProgress.cs b/QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyProgress.cs new file mode 100644 index 0000000..187dcd6 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyProgress.cs @@ -0,0 +1,12 @@ +namespace QuestionableCompanion.Models; + +public class AlliedSocietyProgress +{ + public required string CharacterId { get; set; } + + public required byte SocietyId { get; set; } + + public int CurrentRank { get; set; } + + public bool IsMaxRank { get; set; } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyQuestMode.cs b/QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyQuestMode.cs new file mode 100644 index 0000000..9dbca7a --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyQuestMode.cs @@ -0,0 +1,7 @@ +namespace QuestionableCompanion.Models; + +public enum AlliedSocietyQuestMode +{ + OnlyThreePerSociety, + AllAvailableQuests +} diff --git a/QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyRotationPhase.cs b/QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyRotationPhase.cs new file mode 100644 index 0000000..f75c8c0 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyRotationPhase.cs @@ -0,0 +1,13 @@ +namespace QuestionableCompanion.Models; + +public enum AlliedSocietyRotationPhase +{ + Idle, + StartingRotation, + ImportingQuests, + WaitingForQuestAccept, + MonitoringQuests, + CheckingCompletion, + WaitingForCharacterSwitch, + Completed +} diff --git a/QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyRotationStatus.cs b/QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyRotationStatus.cs new file mode 100644 index 0000000..3c4f622 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Models/AlliedSocietyRotationStatus.cs @@ -0,0 +1,8 @@ +namespace QuestionableCompanion.Models; + +public enum AlliedSocietyRotationStatus +{ + Ready, + InProgress, + Complete +} diff --git a/QuestionableCompanion/QuestionableCompanion.Models/CharacterProgressInfo.cs b/QuestionableCompanion/QuestionableCompanion.Models/CharacterProgressInfo.cs new file mode 100644 index 0000000..3cb90a0 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Models/CharacterProgressInfo.cs @@ -0,0 +1,25 @@ +using System; + +namespace QuestionableCompanion.Models; + +[Serializable] +public class CharacterProgressInfo +{ + public string World { get; set; } = "Unknown"; + + public uint LastQuestId { get; set; } + + public string LastQuestName { get; set; } = "—"; + + public int CompletedQuestCount { get; set; } + + public DateTime LastUpdatedUtc { get; set; } = DateTime.MinValue; + + public uint LastCompletedMSQId { get; set; } + + public string LastCompletedMSQName { get; set; } = "—"; + + public int CompletedMSQCount { get; set; } + + public float MSQCompletionPercentage { get; set; } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Models/ExecutionState.cs b/QuestionableCompanion/QuestionableCompanion.Models/ExecutionState.cs new file mode 100644 index 0000000..38febf1 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Models/ExecutionState.cs @@ -0,0 +1,23 @@ +using System; + +namespace QuestionableCompanion.Models; + +[Serializable] +public class ExecutionState +{ + public string ActiveProfile { get; set; } = string.Empty; + + public string CurrentCharacter { get; set; } = string.Empty; + + public uint CurrentQuestId { get; set; } + + public string CurrentQuestName { get; set; } = string.Empty; + + public string CurrentSequence { get; set; } = string.Empty; + + public ExecutionStatus Status { get; set; } + + public int Progress { get; set; } + + public DateTime LastUpdate { get; set; } = DateTime.Now; +} diff --git a/QuestionableCompanion/QuestionableCompanion.Models/ExecutionStatus.cs b/QuestionableCompanion/QuestionableCompanion.Models/ExecutionStatus.cs new file mode 100644 index 0000000..68bb2f5 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Models/ExecutionStatus.cs @@ -0,0 +1,11 @@ +namespace QuestionableCompanion.Models; + +public enum ExecutionStatus +{ + Idle, + Waiting, + Queued, + Running, + Complete, + Failed +} diff --git a/QuestionableCompanion/QuestionableCompanion.Models/LogEntry.cs b/QuestionableCompanion/QuestionableCompanion.Models/LogEntry.cs new file mode 100644 index 0000000..ec80f8a --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Models/LogEntry.cs @@ -0,0 +1,30 @@ +using System; + +namespace QuestionableCompanion.Models; + +[Serializable] +public class LogEntry +{ + public DateTime Timestamp { get; set; } = DateTime.Now; + + public LogLevel Level { get; set; } + + public string Message { get; set; } = string.Empty; + + public string FormattedTimestamp => Timestamp.ToString("HH:mm:ss"); + + public string FormattedMessage => $"[{FormattedTimestamp}] {GetLevelIcon()} {Message}"; + + private string GetLevelIcon() + { + return Level switch + { + LogLevel.Info => "→", + LogLevel.Success => "✓", + LogLevel.Warning => "⏳", + LogLevel.Error => "✗", + LogLevel.Debug => "▶", + _ => "•", + }; + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Models/LogLevel.cs b/QuestionableCompanion/QuestionableCompanion.Models/LogLevel.cs new file mode 100644 index 0000000..0cf3470 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Models/LogLevel.cs @@ -0,0 +1,10 @@ +namespace QuestionableCompanion.Models; + +public enum LogLevel +{ + Info, + Success, + Warning, + Error, + Debug +} diff --git a/QuestionableCompanion/QuestionableCompanion.Models/QuestConfig.cs b/QuestionableCompanion/QuestionableCompanion.Models/QuestConfig.cs new file mode 100644 index 0000000..0956ba5 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Models/QuestConfig.cs @@ -0,0 +1,19 @@ +using System; + +namespace QuestionableCompanion.Models; + +[Serializable] +public class QuestConfig +{ + public uint QuestId { get; set; } + + public string QuestName { get; set; } = string.Empty; + + public TriggerType TriggerType { get; set; } = TriggerType.OnComplete; + + public SequenceConfig SequenceAfterQuest { get; set; } = new SequenceConfig(); + + public string NextCharacter { get; set; } = "auto_next"; + + public string AssignedCharacter { get; set; } = string.Empty; +} diff --git a/QuestionableCompanion/QuestionableCompanion.Models/QuestProfile.cs b/QuestionableCompanion/QuestionableCompanion.Models/QuestProfile.cs new file mode 100644 index 0000000..fac0d0c --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Models/QuestProfile.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; + +namespace QuestionableCompanion.Models; + +[Serializable] +public class QuestProfile +{ + public string Name { get; set; } = "New Profile"; + + public List Characters { get; set; } = new List(); + + public List Quests { get; set; } = new List(); + + public bool IsActive { get; set; } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Models/RotationPhase.cs b/QuestionableCompanion/QuestionableCompanion.Models/RotationPhase.cs new file mode 100644 index 0000000..ba6e1da --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Models/RotationPhase.cs @@ -0,0 +1,25 @@ +namespace QuestionableCompanion.Models; + +public enum RotationPhase +{ + Idle, + InitializingFirstCharacter, + WaitingForCharacterLogin, + ScanningQuests, + CheckingQuestCompletion, + DCTraveling, + WaitingForQuestStart, + Questing, + InCombat, + InDungeon, + HandlingSubmarines, + SyncingCharacterData, + WaitingForChauffeur, + TravellingWithChauffeur, + QuestActive, + WaitingForNextCharacterSwitch, + WaitingBeforeCharacterSwitch, + WaitingForHomeworldReturn, + Completed, + Error +} diff --git a/QuestionableCompanion/QuestionableCompanion.Models/RotationState.cs b/QuestionableCompanion/QuestionableCompanion.Models/RotationState.cs new file mode 100644 index 0000000..d5d385c --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Models/RotationState.cs @@ -0,0 +1,249 @@ +using System; +using System.Collections.Generic; + +namespace QuestionableCompanion.Models; + +public class RotationState +{ + private readonly object _lock = new object(); + + private uint _currentStopQuestId; + + private List _selectedCharacters = new List(); + + private string _currentCharacter = ""; + + private string _nextCharacter = ""; + + private List _completedCharacters = new List(); + + private List _remainingCharacters = new List(); + + private RotationPhase _phase; + + private DateTime _phaseStartTime = DateTime.Now; + + private string _errorMessage = ""; + + private DateTime? _rotationStartTime; + + private bool _hasQuestBeenAccepted; + + private uint? _lastKnownQuestState; + + public uint CurrentStopQuestId + { + get + { + lock (_lock) + { + return _currentStopQuestId; + } + } + set + { + lock (_lock) + { + _currentStopQuestId = value; + } + } + } + + public List SelectedCharacters + { + get + { + lock (_lock) + { + return new List(_selectedCharacters); + } + } + set + { + lock (_lock) + { + _selectedCharacters = new List(value); + } + } + } + + public string CurrentCharacter + { + get + { + lock (_lock) + { + return _currentCharacter; + } + } + set + { + lock (_lock) + { + _currentCharacter = value; + } + } + } + + public string NextCharacter + { + get + { + lock (_lock) + { + return _nextCharacter; + } + } + set + { + lock (_lock) + { + _nextCharacter = value; + } + } + } + + public List CompletedCharacters + { + get + { + lock (_lock) + { + return new List(_completedCharacters); + } + } + set + { + lock (_lock) + { + _completedCharacters = new List(value); + } + } + } + + public List RemainingCharacters + { + get + { + lock (_lock) + { + return new List(_remainingCharacters); + } + } + set + { + lock (_lock) + { + _remainingCharacters = new List(value); + } + } + } + + public RotationPhase Phase + { + get + { + lock (_lock) + { + return _phase; + } + } + set + { + lock (_lock) + { + _phase = value; + } + } + } + + public DateTime PhaseStartTime + { + get + { + lock (_lock) + { + return _phaseStartTime; + } + } + set + { + lock (_lock) + { + _phaseStartTime = value; + } + } + } + + public string ErrorMessage + { + get + { + lock (_lock) + { + return _errorMessage; + } + } + set + { + lock (_lock) + { + _errorMessage = value; + } + } + } + + public DateTime? RotationStartTime + { + get + { + lock (_lock) + { + return _rotationStartTime; + } + } + set + { + lock (_lock) + { + _rotationStartTime = value; + } + } + } + + public bool HasQuestBeenAccepted + { + get + { + lock (_lock) + { + return _hasQuestBeenAccepted; + } + } + set + { + lock (_lock) + { + _hasQuestBeenAccepted = value; + } + } + } + + public uint? LastKnownQuestState + { + get + { + lock (_lock) + { + return _lastKnownQuestState; + } + } + set + { + lock (_lock) + { + _lastKnownQuestState = value; + } + } + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Models/SequenceConfig.cs b/QuestionableCompanion/QuestionableCompanion.Models/SequenceConfig.cs new file mode 100644 index 0000000..cc8f595 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Models/SequenceConfig.cs @@ -0,0 +1,13 @@ +using System; + +namespace QuestionableCompanion.Models; + +[Serializable] +public class SequenceConfig +{ + public SequenceType Type { get; set; } + + public string Value { get; set; } = string.Empty; + + public bool WaitForCompletion { get; set; } = true; +} diff --git a/QuestionableCompanion/QuestionableCompanion.Models/SequenceType.cs b/QuestionableCompanion/QuestionableCompanion.Models/SequenceType.cs new file mode 100644 index 0000000..9c16db1 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Models/SequenceType.cs @@ -0,0 +1,7 @@ +namespace QuestionableCompanion.Models; + +public enum SequenceType +{ + QuestionableProfile, + InternalAction +} diff --git a/QuestionableCompanion/QuestionableCompanion.Models/StopPoint.cs b/QuestionableCompanion/QuestionableCompanion.Models/StopPoint.cs new file mode 100644 index 0000000..6d85d84 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Models/StopPoint.cs @@ -0,0 +1,30 @@ +using System; +using System.Text.Json.Serialization; + +namespace QuestionableCompanion.Models; + +[Serializable] +public class StopPoint +{ + public uint QuestId { get; set; } + + [JsonInclude] + [JsonPropertyName("Sequence")] + public byte? Sequence { get; set; } + + public bool IsActive { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.Now; + + public string DisplayName + { + get + { + if (Sequence.HasValue) + { + return $"Quest {QuestId} (Seq {Sequence.Value})"; + } + return $"Quest {QuestId}"; + } + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Models/TriggerType.cs b/QuestionableCompanion/QuestionableCompanion.Models/TriggerType.cs new file mode 100644 index 0000000..beced55 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Models/TriggerType.cs @@ -0,0 +1,7 @@ +namespace QuestionableCompanion.Models; + +public enum TriggerType +{ + OnAccept, + OnComplete +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/ARPostProcessEventQuestService.cs b/QuestionableCompanion/QuestionableCompanion.Services/ARPostProcessEventQuestService.cs new file mode 100644 index 0000000..c0f2f28 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/ARPostProcessEventQuestService.cs @@ -0,0 +1,454 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Dalamud.Plugin; +using Dalamud.Plugin.Ipc; +using Dalamud.Plugin.Services; +using Newtonsoft.Json.Linq; + +namespace QuestionableCompanion.Services; + +public class ARPostProcessEventQuestService : IDisposable +{ + private readonly IDalamudPluginInterface pluginInterface; + + private readonly QuestionableIPC questionableIPC; + + private readonly EventQuestResolver eventQuestResolver; + + private readonly Configuration configuration; + + private readonly IPluginLog log; + + private readonly IFramework framework; + + private readonly ICommandManager commandManager; + + private readonly LifestreamIPC lifestreamIPC; + + private ICallGateSubscriber? characterAdditionalTaskSubscriber; + + private ICallGateSubscriber? characterPostProcessSubscriber; + + private Action? characterAdditionalTaskHandler; + + private Action? characterPostProcessHandler; + + private bool isProcessingEventQuests; + + private DateTime postProcessStartTime; + + private List currentQuestHierarchy = new List(); + + private string currentPluginName = string.Empty; + + private string lastTerritoryWaitDetected = string.Empty; + + private DateTime lastTerritoryTeleportTime = DateTime.MinValue; + + private const string PLUGIN_NAME = "QuestionableCompanion"; + + private const string AR_CHARACTER_ADDITIONAL_TASK = "AutoRetainer.OnCharacterAdditionalTask"; + + private const string AR_CHARACTER_POST_PROCESS_EVENT = "AutoRetainer.OnCharacterReadyForPostprocess"; + + private const string AR_FINISH_CHARACTER_POST_PROCESS = "AutoRetainer.FinishCharacterPostprocessRequest"; + + private const string AR_REQUEST_CHARACTER_POST_PROCESS = "AutoRetainer.RequestCharacterPostprocess"; + + public ARPostProcessEventQuestService(IDalamudPluginInterface pluginInterface, QuestionableIPC questionableIPC, EventQuestResolver eventQuestResolver, Configuration configuration, IPluginLog log, IFramework framework, ICommandManager commandManager, LifestreamIPC lifestreamIPC) + { + this.pluginInterface = pluginInterface; + this.questionableIPC = questionableIPC; + this.eventQuestResolver = eventQuestResolver; + this.configuration = configuration; + this.log = log; + this.framework = framework; + this.commandManager = commandManager; + this.lifestreamIPC = lifestreamIPC; + InitializeIPC(); + } + + private void InitializeIPC() + { + try + { + characterAdditionalTaskSubscriber = pluginInterface.GetIpcSubscriber("AutoRetainer.OnCharacterAdditionalTask"); + if (characterAdditionalTaskSubscriber == null) + { + return; + } + characterAdditionalTaskHandler = delegate + { + try + { + RegisterWithAutoRetainer(); + } + catch + { + } + }; + characterAdditionalTaskSubscriber.Subscribe(characterAdditionalTaskHandler); + characterPostProcessSubscriber = pluginInterface.GetIpcSubscriber("AutoRetainer.OnCharacterReadyForPostprocess"); + if (characterPostProcessSubscriber == null) + { + return; + } + characterPostProcessHandler = delegate(string pluginName) + { + try + { + OnARCharacterPostProcessStarted(pluginName); + } + catch + { + } + }; + characterPostProcessSubscriber.Subscribe(characterPostProcessHandler); + } + catch + { + } + } + + private void RegisterWithAutoRetainer() + { + try + { + pluginInterface.GetIpcSubscriber("AutoRetainer.RequestCharacterPostprocess").InvokeAction("QuestionableCompanion"); + } + catch + { + } + } + + private void OnARCharacterPostProcessStarted(string pluginName) + { + try + { + if (pluginName != "QuestionableCompanion") + { + return; + } + if (!configuration.RunEventQuestsOnARPostProcess) + { + FinishPostProcess(); + return; + } + currentPluginName = pluginName; + postProcessStartTime = DateTime.Now; + framework.RunOnFrameworkThread(async delegate + { + try + { + await ProcessEventQuestsAsync(); + } + catch + { + FinishPostProcess(); + } + }); + } + catch + { + try + { + FinishPostProcess(); + } + catch + { + } + } + } + + private async Task ProcessEventQuestsAsync() + { + if (isProcessingEventQuests) + { + return; + } + isProcessingEventQuests = true; + bool shouldFinishPostProcess = false; + try + { + _ = 1; + try + { + List detectedEventQuests = DetectActiveEventQuests(); + if (detectedEventQuests.Count == 0) + { + shouldFinishPostProcess = true; + return; + } + currentQuestHierarchy = new List(detectedEventQuests); + await ImportEventQuestsForPostProcess(detectedEventQuests); + await WaitForEventQuestsCompletion(detectedEventQuests); + shouldFinishPostProcess = true; + } + catch + { + shouldFinishPostProcess = true; + } + } + finally + { + if (shouldFinishPostProcess) + { + await ClearPriorityQuests(); + FinishPostProcess(); + } + isProcessingEventQuests = false; + } + } + + private List DetectActiveEventQuests() + { + try + { + return questionableIPC.GetCurrentlyActiveEventQuests() ?? new List(); + } + catch + { + return new List(); + } + } + + private async Task ImportEventQuestsForPostProcess(List detectedEventQuests) + { + List allQuestsToImport = new List(); + try + { + foreach (string questId in detectedEventQuests) + { + foreach (string quest in await GetQuestHierarchy(questId)) + { + if (!allQuestsToImport.Contains(quest)) + { + allQuestsToImport.Add(quest); + } + } + } + if (!questionableIPC.IsAvailable) + { + return; + } + try + { + questionableIPC.ClearQuestPriority(); + await Task.Delay(500); + } + catch + { + } + foreach (string questId2 in allQuestsToImport) + { + try + { + questionableIPC.AddQuestPriority(questId2); + await Task.Delay(100); + } + catch + { + } + } + await Task.Delay(500); + try + { + await framework.RunOnFrameworkThread(delegate + { + commandManager.ProcessCommand("/qst start"); + }); + } + catch + { + } + } + catch + { + throw; + } + } + + private async Task> GetQuestHierarchy(string questId) + { + List hierarchy = new List(); + HashSet visited = new HashSet(); + await CollectPrerequisitesRecursive(questId, hierarchy, visited); + return hierarchy; + } + + private async Task CollectPrerequisitesRecursive(string questId, List hierarchy, HashSet visited) + { + if (visited.Contains(questId)) + { + return; + } + visited.Add(questId); + try + { + List prerequisites = eventQuestResolver.ResolveEventQuestDependencies(questId); + if (prerequisites.Count > 0) + { + foreach (string prereq in prerequisites) + { + await CollectPrerequisitesRecursive(prereq, hierarchy, visited); + } + } + hierarchy.Add(questId); + } + catch + { + hierarchy.Add(questId); + } + await Task.CompletedTask; + } + + private async Task WaitForEventQuestsCompletion(List originalEventQuests) + { + TimeSpan maxWaitTime = TimeSpan.FromMinutes(configuration.EventQuestPostProcessTimeoutMinutes); + DateTime startTime = DateTime.Now; + TimeSpan checkInterval = TimeSpan.FromSeconds(2L); + while (DateTime.Now - startTime < maxWaitTime) + { + try + { + CheckForTerritoryWait(); + if (!questionableIPC.IsAvailable) + { + await Task.Delay(checkInterval); + continue; + } + bool isRunning = questionableIPC.IsRunning(); + List currentEventQuests = DetectActiveEventQuests(); + if (originalEventQuests.Where((string q) => currentEventQuests.Contains(q)).ToList().Count == 0) + { + if (!isRunning) + { + break; + } + try + { + await framework.RunOnFrameworkThread(delegate + { + commandManager.ProcessCommand("/qst stop"); + }); + await Task.Delay(500); + break; + } + catch + { + break; + } + } + } + catch + { + } + await Task.Delay(checkInterval); + } + } + + private void CheckForTerritoryWait() + { + if (!questionableIPC.IsRunning()) + { + return; + } + object task = questionableIPC.GetCurrentTask(); + if (task == null) + { + return; + } + try + { + if (!(task is JObject jObject)) + { + return; + } + JToken taskNameToken = jObject["TaskName"]; + if (taskNameToken == null) + { + return; + } + string taskName = taskNameToken.ToString(); + if (string.IsNullOrEmpty(taskName)) + { + return; + } + Match waitTerritoryMatch = new Regex("Wait\\(territory:\\s*(.+?)\\s*\\((\\d+)\\)\\)").Match(taskName); + if (!waitTerritoryMatch.Success) + { + return; + } + string territoryName = waitTerritoryMatch.Groups[1].Value.Trim(); + uint territoryId = uint.Parse(waitTerritoryMatch.Groups[2].Value); + string territoryKey = $"{territoryName}_{territoryId}"; + double timeSinceLastTeleport = (DateTime.Now - lastTerritoryTeleportTime).TotalSeconds; + if (lastTerritoryWaitDetected == territoryKey && timeSinceLastTeleport < 60.0) + { + return; + } + lastTerritoryWaitDetected = territoryKey; + lastTerritoryTeleportTime = DateTime.Now; + framework.RunOnFrameworkThread(delegate + { + try + { + commandManager.ProcessCommand("/li " + territoryName); + } + catch + { + } + }); + } + catch + { + } + } + + private async Task ClearPriorityQuests() + { + try + { + if (questionableIPC.IsAvailable) + { + questionableIPC.ClearQuestPriority(); + await Task.Delay(500); + } + } + catch + { + } + } + + private void FinishPostProcess() + { + try + { + pluginInterface.GetIpcSubscriber("AutoRetainer.FinishCharacterPostprocessRequest").InvokeAction(); + } + catch + { + } + } + + public void Dispose() + { + try + { + if (characterAdditionalTaskHandler != null && characterAdditionalTaskSubscriber != null) + { + characterAdditionalTaskSubscriber.Unsubscribe(characterAdditionalTaskHandler); + } + if (characterPostProcessHandler != null && characterPostProcessSubscriber != null) + { + characterPostProcessSubscriber.Unsubscribe(characterPostProcessHandler); + } + } + catch + { + } + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/AlliedSocietyDatabase.cs b/QuestionableCompanion/QuestionableCompanion.Services/AlliedSocietyDatabase.cs new file mode 100644 index 0000000..a14ef5f --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/AlliedSocietyDatabase.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Dalamud.Plugin.Services; +using QuestionableCompanion.Models; + +namespace QuestionableCompanion.Services; + +public class AlliedSocietyDatabase +{ + private readonly Configuration configuration; + + private readonly IPluginLog log; + + public AlliedSocietyDatabase(Configuration configuration, IPluginLog log) + { + this.configuration = configuration; + this.log = log; + if (configuration.AlliedSociety.RotationConfig.Priorities.Count == 0) + { + configuration.AlliedSociety.RotationConfig.InitializeDefaults(); + SaveToConfig(); + } + } + + public void SaveToConfig() + { + configuration.Save(); + } + + public void UpdateCharacterProgress(string characterId, byte societyId, int rank, bool isMaxRank) + { + if (!configuration.AlliedSociety.CharacterProgress.ContainsKey(characterId)) + { + configuration.AlliedSociety.CharacterProgress[characterId] = new List(); + } + List progressList = configuration.AlliedSociety.CharacterProgress[characterId]; + AlliedSocietyProgress existing = progressList.FirstOrDefault((AlliedSocietyProgress p) => p.SocietyId == societyId); + if (existing != null) + { + existing.CurrentRank = rank; + existing.IsMaxRank = isMaxRank; + } + else + { + progressList.Add(new AlliedSocietyProgress + { + CharacterId = characterId, + SocietyId = societyId, + CurrentRank = rank, + IsMaxRank = isMaxRank + }); + } + SaveToConfig(); + } + + public AlliedSocietyProgress? GetProgress(string characterId, byte societyId) + { + if (configuration.AlliedSociety.CharacterProgress.TryGetValue(characterId, out List list)) + { + return list.FirstOrDefault((AlliedSocietyProgress p) => p.SocietyId == societyId); + } + return null; + } + + public AlliedSocietyCharacterStatus GetCharacterStatus(string characterId) + { + if (!configuration.AlliedSociety.CharacterStatuses.ContainsKey(characterId)) + { + configuration.AlliedSociety.CharacterStatuses[characterId] = new AlliedSocietyCharacterStatus + { + CharacterId = characterId, + Status = AlliedSocietyRotationStatus.Ready + }; + SaveToConfig(); + } + return configuration.AlliedSociety.CharacterStatuses[characterId]; + } + + public void UpdateCharacterStatus(string characterId, AlliedSocietyRotationStatus status) + { + GetCharacterStatus(characterId).Status = status; + SaveToConfig(); + } + + public void SetCharacterComplete(string characterId, DateTime completionDate) + { + AlliedSocietyCharacterStatus characterStatus = GetCharacterStatus(characterId); + characterStatus.Status = AlliedSocietyRotationStatus.Complete; + characterStatus.LastCompletionDate = completionDate; + characterStatus.ImportedQuestIds.Clear(); + SaveToConfig(); + } + + public void CheckAndResetExpired(DateTime nextResetDate) + { + List charactersToReset = GetCharactersNeedingReset(nextResetDate); + foreach (string charId in charactersToReset) + { + log.Information("[AlliedSociety] Resetting status for character " + charId); + AlliedSocietyCharacterStatus characterStatus = GetCharacterStatus(charId); + characterStatus.Status = AlliedSocietyRotationStatus.Ready; + characterStatus.ImportedQuestIds.Clear(); + } + if (charactersToReset.Count > 0) + { + SaveToConfig(); + } + } + + public List GetCharactersNeedingReset(DateTime nextResetDate) + { + List result = new List(); + DateTime lastResetDate = nextResetDate.AddDays(-1.0); + foreach (KeyValuePair kvp in configuration.AlliedSociety.CharacterStatuses) + { + AlliedSocietyCharacterStatus status = kvp.Value; + if (status.Status == AlliedSocietyRotationStatus.Ready) + { + continue; + } + if (status.LastCompletionDate.HasValue) + { + if (status.LastCompletionDate.Value < lastResetDate) + { + result.Add(kvp.Key); + } + } + else + { + result.Add(kvp.Key); + } + } + return result; + } + + public void ClearAllStatuses() + { + foreach (KeyValuePair kvp in configuration.AlliedSociety.CharacterStatuses) + { + kvp.Value.Status = AlliedSocietyRotationStatus.Ready; + kvp.Value.ImportedQuestIds.Clear(); + } + SaveToConfig(); + } + + public void AddImportedQuest(string characterId, string questId) + { + AlliedSocietyCharacterStatus status = GetCharacterStatus(characterId); + if (!status.ImportedQuestIds.Contains(questId)) + { + status.ImportedQuestIds.Add(questId); + SaveToConfig(); + } + } + + public void ClearImportedQuests(string characterId) + { + GetCharacterStatus(characterId).ImportedQuestIds.Clear(); + SaveToConfig(); + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/AlliedSocietyQuestSelector.cs b/QuestionableCompanion/QuestionableCompanion.Services/AlliedSocietyQuestSelector.cs new file mode 100644 index 0000000..b0b084b --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/AlliedSocietyQuestSelector.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +using System.Linq; +using Dalamud.Plugin.Services; +using QuestionableCompanion.Models; + +namespace QuestionableCompanion.Services; + +public class AlliedSocietyQuestSelector +{ + private readonly QuestionableIPC questionableIpc; + + private readonly IPluginLog log; + + public AlliedSocietyQuestSelector(QuestionableIPC questionableIpc, IPluginLog log) + { + this.questionableIpc = questionableIpc; + this.log = log; + } + + public List SelectQuestsForCharacter(string characterId, int remainingAllowances, List priorities, AlliedSocietyQuestMode mode) + { + List selectedQuests = new List(); + int currentAllowances = remainingAllowances; + List list = (from p in priorities + where p.Enabled + orderby p.Order + select p).ToList(); + log.Debug($"[AlliedSociety] Selecting quests for {characterId}. Allowances: {remainingAllowances}, Mode: {mode}"); + foreach (AlliedSocietyPriority priority in list) + { + if (currentAllowances <= 0) + { + log.Debug("[AlliedSociety] No allowances left, stopping selection"); + break; + } + byte societyId = priority.SocietyId; + List optimalQuests = questionableIpc.GetAlliedSocietyOptimalQuests(societyId); + if (optimalQuests.Count == 0) + { + continue; + } + List readyQuests = new List(); + foreach (string questId in optimalQuests) + { + if (questionableIpc.IsReadyToAcceptQuest(questId)) + { + readyQuests.Add(questId); + } + } + if (readyQuests.Count == 0) + { + continue; + } + if (mode == AlliedSocietyQuestMode.OnlyThreePerSociety) + { + List questsToTake = readyQuests; + if (questsToTake.Count > 3) + { + questsToTake = questsToTake.Skip(questsToTake.Count - 3).Take(3).ToList(); + } + foreach (string questId2 in questsToTake) + { + if (currentAllowances > 0) + { + selectedQuests.Add(questId2); + currentAllowances--; + continue; + } + break; + } + continue; + } + foreach (string questId3 in readyQuests) + { + if (currentAllowances > 0) + { + selectedQuests.Add(questId3); + currentAllowances--; + continue; + } + break; + } + } + log.Information($"[AlliedSociety] Selected {selectedQuests.Count} quests for {characterId}"); + return selectedQuests; + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/AlliedSocietyRotationService.cs b/QuestionableCompanion/QuestionableCompanion.Services/AlliedSocietyRotationService.cs new file mode 100644 index 0000000..29c7a69 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/AlliedSocietyRotationService.cs @@ -0,0 +1,496 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using Dalamud.Plugin.Services; +using Newtonsoft.Json.Linq; +using QuestionableCompanion.Models; + +namespace QuestionableCompanion.Services; + +public class AlliedSocietyRotationService : IDisposable +{ + private readonly QuestionableIPC questionableIpc; + + private readonly AlliedSocietyDatabase database; + + private readonly AlliedSocietyQuestSelector questSelector; + + private readonly AutoRetainerIPC autoRetainerIpc; + + private readonly Configuration configuration; + + private readonly IPluginLog log; + + private readonly IFramework framework; + + private readonly ICommandManager commandManager; + + private readonly ICondition condition; + + private readonly IClientState clientState; + + private bool isRotationActive; + + private AlliedSocietyRotationPhase currentPhase; + + private List rotationCharacters = new List(); + + private int currentCharacterIndex = -1; + + private string currentCharacterId = string.Empty; + + private DateTime phaseStartTime = DateTime.MinValue; + + private int consecutiveNoQuestsCount; + + private DateTime lastTerritoryTeleportTime = DateTime.MinValue; + + private string lastTerritoryName = string.Empty; + + private DateTime lastUpdate = DateTime.MinValue; + + private const double UpdateIntervalMs = 500.0; + + private DateTime characterSwitchStartTime = DateTime.MinValue; + + private const double CharacterSwitchRetrySeconds = 20.0; + + public bool IsRotationActive => isRotationActive; + + public string CurrentCharacterId => currentCharacterId; + + public AlliedSocietyRotationPhase CurrentPhase => currentPhase; + + public AlliedSocietyRotationService(QuestionableIPC questionableIpc, AlliedSocietyDatabase database, AlliedSocietyQuestSelector questSelector, AutoRetainerIPC autoRetainerIpc, Configuration configuration, IPluginLog log, IFramework framework, ICommandManager commandManager, ICondition condition, IClientState clientState) + { + this.questionableIpc = questionableIpc; + this.database = database; + this.questSelector = questSelector; + this.autoRetainerIpc = autoRetainerIpc; + this.configuration = configuration; + this.log = log; + this.framework = framework; + this.commandManager = commandManager; + this.condition = condition; + this.clientState = clientState; + framework.Update += OnFrameworkUpdate; + } + + public void Dispose() + { + framework.Update -= OnFrameworkUpdate; + } + + public void StartRotation(List characters) + { + if (isRotationActive) + { + log.Warning("[AlliedSociety] Rotation already active"); + return; + } + if (characters.Count == 0) + { + log.Warning("[AlliedSociety] No characters selected for rotation"); + return; + } + log.Information($"[AlliedSociety] Starting rotation with {characters.Count} characters"); + rotationCharacters = new List(characters); + isRotationActive = true; + currentCharacterIndex = -1; + AdvanceToNextCharacter(); + } + + public void StopRotation() + { + if (!isRotationActive) + { + return; + } + log.Information("[AlliedSociety] Stopping rotation"); + isRotationActive = false; + currentPhase = AlliedSocietyRotationPhase.Idle; + currentCharacterId = string.Empty; + try + { + commandManager.ProcessCommand("/qst stop"); + } + catch + { + } + } + + private void OnFrameworkUpdate(IFramework framework) + { + if (!isRotationActive || (DateTime.Now - lastUpdate).TotalMilliseconds < 500.0) + { + return; + } + lastUpdate = DateTime.Now; + try + { + switch (currentPhase) + { + case AlliedSocietyRotationPhase.StartingRotation: + HandleStartingRotation(); + break; + case AlliedSocietyRotationPhase.ImportingQuests: + HandleImportingQuests(); + break; + case AlliedSocietyRotationPhase.WaitingForQuestAccept: + HandleWaitingForQuestAccept(); + break; + case AlliedSocietyRotationPhase.MonitoringQuests: + HandleMonitoringQuests(); + break; + case AlliedSocietyRotationPhase.CheckingCompletion: + HandleCheckingCompletion(); + break; + case AlliedSocietyRotationPhase.WaitingForCharacterSwitch: + HandleWaitingForCharacterSwitch(); + break; + } + } + catch (Exception ex) + { + log.Error("[AlliedSociety] Error in rotation loop: " + ex.Message); + StopRotation(); + } + } + + private void HandleStartingRotation() + { + if (clientState.LocalContentId == 0L) + { + return; + } + string currentLoggedInChar = autoRetainerIpc.GetCurrentCharacter(); + if (string.IsNullOrEmpty(currentLoggedInChar) || currentLoggedInChar != currentCharacterId) + { + double waitTime = (DateTime.Now - characterSwitchStartTime).TotalSeconds; + if (!(waitTime > 20.0)) + { + return; + } + log.Warning($"[AlliedSociety] Character switch timeout ({waitTime:F1}s). Retrying /ar relog for {currentCharacterId}..."); + framework.RunOnFrameworkThread(delegate + { + try + { + commandManager.ProcessCommand("/ays relog " + currentCharacterId); + log.Information("[AlliedSociety] Retry relog command sent for " + currentCharacterId); + } + catch (Exception ex) + { + log.Error("[AlliedSociety] Failed to send retry relog: " + ex.Message); + } + }); + characterSwitchStartTime = DateTime.Now; + } + else + { + questionableIpc.ForceCheckAvailability(); + if (questionableIpc.IsAvailable) + { + log.Information("[AlliedSociety] ✓ Character logged in (" + currentLoggedInChar + "), Questionable ready"); + SetPhase(AlliedSocietyRotationPhase.ImportingQuests); + } + } + } + + private void HandleImportingQuests() + { + int allowances = questionableIpc.GetAlliedSocietyRemainingAllowances(); + log.Information($"[AlliedSociety] Remaining allowances: {allowances}"); + if (allowances <= 0) + { + log.Information("[AlliedSociety] No allowances left. Checking completion..."); + SetPhase(AlliedSocietyRotationPhase.CheckingCompletion); + return; + } + List quests = questSelector.SelectQuestsForCharacter(currentCharacterId, allowances, configuration.AlliedSociety.RotationConfig.Priorities, configuration.AlliedSociety.RotationConfig.QuestMode); + if (quests.Count == 0) + { + consecutiveNoQuestsCount++; + log.Warning($"[AlliedSociety] No quests selected (attempt {consecutiveNoQuestsCount}/3). Checking completion..."); + if (consecutiveNoQuestsCount >= 3) + { + log.Error("[AlliedSociety] Failed to select quests 3 times consecutively. No quests available for this character."); + log.Information("[AlliedSociety] Marking character as complete and moving to next..."); + consecutiveNoQuestsCount = 0; + database.SetCharacterComplete(currentCharacterId, DateTime.Now); + AdvanceToNextCharacter(); + } + else + { + SetPhase(AlliedSocietyRotationPhase.CheckingCompletion); + } + return; + } + consecutiveNoQuestsCount = 0; + log.Information($"[AlliedSociety] Importing {quests.Count} quests to Questionable..."); + foreach (string questId in quests) + { + log.Debug("[AlliedSociety] Adding quest " + questId + " to priority"); + questionableIpc.AddQuestPriority(questId); + database.AddImportedQuest(currentCharacterId, questId); + } + log.Information("✓ All quests imported to Questionable"); + log.Information("[AlliedSociety] Sending /qst start command..."); + framework.RunOnFrameworkThread(delegate + { + try + { + commandManager.ProcessCommand("/qst start"); + log.Information("[AlliedSociety] ✓ /qst start command sent successfully"); + } + catch (Exception ex) + { + log.Error("[AlliedSociety] ✗ Failed to send /qst start: " + ex.Message); + } + }); + log.Information("[AlliedSociety] Transitioning to WaitingForQuestAccept phase"); + SetPhase(AlliedSocietyRotationPhase.WaitingForQuestAccept); + } + + private void HandleWaitingForQuestAccept() + { + try + { + object task = questionableIpc.GetCurrentTask(); + if (task != null && task is JObject jObject) + { + JToken taskNameToken = jObject["TaskName"]; + if (taskNameToken != null) + { + string taskName = taskNameToken.ToString(); + if (!string.IsNullOrEmpty(taskName) && taskName.Contains("Wait(territory:")) + { + log.Information("[AlliedSociety] [WaitAccept] Territory wait detected: " + taskName); + Match territoryMatch = Regex.Match(taskName, "Wait\\(territory:\\s*(.+?)\\s*\\((\\d+)\\)\\)"); + if (territoryMatch.Success) + { + string territoryName = territoryMatch.Groups[1].Value.Trim(); + TimeSpan timeSinceLastTeleport = DateTime.Now - lastTerritoryTeleportTime; + if (territoryName != lastTerritoryName || timeSinceLastTeleport.TotalSeconds > 10.0) + { + log.Information("[AlliedSociety] [WaitAccept] ▶ Sending Lifestream teleport: /li " + territoryName); + framework.RunOnFrameworkThread(delegate + { + try + { + commandManager.ProcessCommand("/li " + territoryName); + log.Information("[AlliedSociety] [WaitAccept] ✓ Lifestream command sent successfully"); + } + catch (Exception ex2) + { + log.Error("[AlliedSociety] [WaitAccept] ✗ Failed to send Lifestream command: " + ex2.Message); + } + }); + lastTerritoryTeleportTime = DateTime.Now; + lastTerritoryName = territoryName; + } + else + { + log.Debug("[AlliedSociety] [WaitAccept] Territory teleport debounced for: " + territoryName); + } + } + } + } + } + } + catch (Exception ex) + { + log.Debug("[AlliedSociety] [WaitAccept] Error checking task for teleport: " + ex.Message); + } + AlliedSocietyCharacterStatus characterStatus = database.GetCharacterStatus(currentCharacterId); + bool allAccepted = true; + int acceptedCount = 0; + int totalCount = characterStatus.ImportedQuestIds.Count; + foreach (string questId in characterStatus.ImportedQuestIds) + { + if (questionableIpc.IsReadyToAcceptQuest(questId)) + { + allAccepted = false; + } + else + { + acceptedCount++; + } + } + if (allAccepted) + { + log.Information($"[AlliedSociety] ✓ All {totalCount} quests accepted. Monitoring progress..."); + SetPhase(AlliedSocietyRotationPhase.MonitoringQuests); + } + } + + private void HandleMonitoringQuests() + { + string currentQuestId = questionableIpc.GetCurrentQuestId(); + AlliedSocietyCharacterStatus status = database.GetCharacterStatus(currentCharacterId); + log.Debug("[AlliedSociety] Monitoring - Current Quest: " + (currentQuestId ?? "null")); + if (currentQuestId != null && status.ImportedQuestIds.Contains(currentQuestId)) + { + log.Debug("[AlliedSociety] Working on imported quest: " + currentQuestId); + try + { + object task = questionableIpc.GetCurrentTask(); + log.Debug("[AlliedSociety] Task object type: " + (task?.GetType().Name ?? "null")); + if (task != null && task is JObject jObject) + { + log.Debug("[AlliedSociety] Task JObject: " + jObject.ToString()); + JToken taskNameToken = jObject["TaskName"]; + if (taskNameToken != null) + { + string taskName = taskNameToken.ToString(); + log.Information("[AlliedSociety] Current Task Name: '" + taskName + "'"); + if (!string.IsNullOrEmpty(taskName) && taskName.Contains("Wait(territory:")) + { + log.Information("[AlliedSociety] ✓ TERRITORY WAIT DETECTED! Raw task: " + taskName); + Match territoryMatch = Regex.Match(taskName, "Wait\\(territory:\\s*(.+?)\\s*\\((\\d+)\\)\\)"); + if (territoryMatch.Success) + { + string territoryName = territoryMatch.Groups[1].Value.Trim(); + log.Information("[AlliedSociety] ✓ Territory name parsed: '" + territoryName + "'"); + TimeSpan timeSinceLastTeleport = DateTime.Now - lastTerritoryTeleportTime; + if (territoryName != lastTerritoryName || timeSinceLastTeleport.TotalSeconds > 10.0) + { + log.Information("[AlliedSociety] ▶ Territory wait detected: " + territoryName); + log.Information("[AlliedSociety] ▶ Sending Lifestream teleport command: /li " + territoryName); + framework.RunOnFrameworkThread(delegate + { + try + { + commandManager.ProcessCommand("/li " + territoryName); + log.Information("[AlliedSociety] ✓ Lifestream command sent successfully"); + } + catch (Exception ex2) + { + log.Error("[AlliedSociety] ✗ Failed to send Lifestream command: " + ex2.Message); + } + }); + lastTerritoryTeleportTime = DateTime.Now; + lastTerritoryName = territoryName; + } + else + { + log.Information($"[AlliedSociety] ⏸ Territory teleport debounced for: {territoryName} (last: {timeSinceLastTeleport.TotalSeconds:F1}s ago)"); + } + } + else + { + log.Warning("[AlliedSociety] ✗ Territory wait detected but regex didn't match! Raw: " + taskName); + } + } + else if (!string.IsNullOrEmpty(taskName) && taskName.ToLower().Contains("wait")) + { + log.Debug("[AlliedSociety] Wait task (not territory): " + taskName); + } + } + else + { + log.Debug("[AlliedSociety] Task has no TaskName property"); + } + } + else if (task == null) + { + log.Debug("[AlliedSociety] GetCurrentTask returned null"); + } + else + { + log.Warning("[AlliedSociety] Task is not JObject, type: " + task.GetType().Name); + } + return; + } + catch (Exception ex) + { + log.Error("[AlliedSociety] Error checking task for teleport: " + ex.Message + "\n" + ex.StackTrace); + return; + } + } + if (currentQuestId == null || !status.ImportedQuestIds.Contains(currentQuestId)) + { + log.Information("[AlliedSociety] No longer working on imported quests. Checking completion..."); + SetPhase(AlliedSocietyRotationPhase.CheckingCompletion); + } + } + + private void HandleCheckingCompletion() + { + int allowances = questionableIpc.GetAlliedSocietyRemainingAllowances(); + log.Information($"[AlliedSociety] Checking completion. Allowances: {allowances}"); + if (allowances == 0) + { + string currentQuestId = questionableIpc.GetCurrentQuestId(); + AlliedSocietyCharacterStatus status = database.GetCharacterStatus(currentCharacterId); + if (currentQuestId != null && status.ImportedQuestIds.Contains(currentQuestId)) + { + log.Information("[AlliedSociety] Still working on final quest " + currentQuestId + ". Waiting..."); + return; + } + log.Information("[AlliedSociety] Character " + currentCharacterId + " completed all allowances."); + try + { + commandManager.ProcessCommand("/qst stop"); + log.Information("[AlliedSociety] Sent /qst stop command after quest completion"); + } + catch (Exception ex) + { + log.Error("[AlliedSociety] Failed to send /qst stop: " + ex.Message); + } + questionableIpc.ClearQuestPriority(); + database.SetCharacterComplete(currentCharacterId, DateTime.Now); + SetPhase(AlliedSocietyRotationPhase.WaitingForCharacterSwitch); + } + else + { + log.Information("[AlliedSociety] Allowances remaining. Trying to import more quests..."); + questionableIpc.ClearQuestPriority(); + database.ClearImportedQuests(currentCharacterId); + SetPhase(AlliedSocietyRotationPhase.ImportingQuests); + } + } + + private void HandleWaitingForCharacterSwitch() + { + if (!((DateTime.Now - phaseStartTime).TotalSeconds < 2.0)) + { + AdvanceToNextCharacter(); + } + } + + private void AdvanceToNextCharacter() + { + currentCharacterIndex++; + if (currentCharacterIndex >= rotationCharacters.Count) + { + log.Information("[AlliedSociety] Rotation completed for all characters."); + StopRotation(); + return; + } + string nextChar = rotationCharacters[currentCharacterIndex]; + if (database.GetCharacterStatus(nextChar).Status == AlliedSocietyRotationStatus.Complete) + { + log.Information("[AlliedSociety] Skipping " + nextChar + " (Already Complete)"); + AdvanceToNextCharacter(); + return; + } + log.Information("[AlliedSociety] Switching to " + nextChar); + currentCharacterId = nextChar; + characterSwitchStartTime = DateTime.Now; + if (autoRetainerIpc.SwitchCharacter(nextChar)) + { + SetPhase(AlliedSocietyRotationPhase.StartingRotation); + return; + } + log.Error("[AlliedSociety] Failed to switch to " + nextChar); + StopRotation(); + } + + private void SetPhase(AlliedSocietyRotationPhase phase) + { + log.Information($"[AlliedSociety] Phase: {currentPhase} → {phase}"); + currentPhase = phase; + phaseStartTime = DateTime.Now; + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/AutoRetainerIPC.cs b/QuestionableCompanion/QuestionableCompanion.Services/AutoRetainerIPC.cs new file mode 100644 index 0000000..f0c71aa --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/AutoRetainerIPC.cs @@ -0,0 +1,513 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Dalamud.Plugin; +using Dalamud.Plugin.Ipc; +using Dalamud.Plugin.Services; +using Newtonsoft.Json.Linq; + +namespace QuestionableCompanion.Services; + +public class AutoRetainerIPC : IDisposable +{ + private readonly IDalamudPluginInterface pluginInterface; + + private readonly IPluginLog log; + + private readonly IClientState clientState; + + private readonly ICommandManager commandManager; + + private readonly IFramework framework; + + private ICallGateSubscriber>? getRegisteredCIDsSubscriber; + + private ICallGateSubscriber? getOfflineCharacterDataSubscriber; + + private ICallGateProvider? relogProvider; + + private ICallGateSubscriber? getMultiModeEnabledSubscriber; + + private ICallGateProvider? setMultiModeEnabledProvider; + + private Dictionary characterCache = new Dictionary(); + + private HashSet unknownCIDs = new HashSet(); + + private bool subscribersInitialized; + + private DateTime lastAvailabilityCheck = DateTime.MinValue; + + private const int AvailabilityCheckCooldownSeconds = 5; + + public bool IsAvailable { get; private set; } + + public AutoRetainerIPC(IDalamudPluginInterface pluginInterface, IPluginLog log, IClientState clientState, ICommandManager commandManager, IFramework framework) + { + this.pluginInterface = pluginInterface; + this.log = log; + this.clientState = clientState; + this.commandManager = commandManager; + this.framework = framework; + InitializeIPC(); + } + + public void ClearCache() + { + characterCache.Clear(); + unknownCIDs.Clear(); + log.Information("[AutoRetainerIPC] Cache cleared"); + } + + private void InitializeIPC() + { + try + { + getRegisteredCIDsSubscriber = null; + getOfflineCharacterDataSubscriber = null; + relogProvider = null; + getMultiModeEnabledSubscriber = null; + setMultiModeEnabledProvider = null; + IsAvailable = false; + getRegisteredCIDsSubscriber = pluginInterface.GetIpcSubscriber>("AutoRetainer.GetRegisteredCIDs"); + getOfflineCharacterDataSubscriber = pluginInterface.GetIpcSubscriber("AutoRetainer.GetOfflineCharacterData"); + try + { + relogProvider = pluginInterface.GetIpcProvider("AutoRetainer.Relog"); + log.Debug("[AutoRetainerIPC] Relog IPC provider initialized"); + } + catch (Exception ex) + { + log.Debug("[AutoRetainerIPC] Failed to initialize Relog provider: " + ex.Message); + } + try + { + getMultiModeEnabledSubscriber = pluginInterface.GetIpcSubscriber("AutoRetainer.GetMultiModeEnabled"); + setMultiModeEnabledProvider = pluginInterface.GetIpcProvider("AutoRetainer.SetMultiModeEnabled"); + log.Debug("[AutoRetainerIPC] Multi-Mode IPC initialized"); + } + catch (Exception ex2) + { + log.Debug("[AutoRetainerIPC] Failed to initialize Multi-Mode IPC: " + ex2.Message); + } + subscribersInitialized = true; + log.Debug("[AutoRetainerIPC] IPC subscribers initialized (lazy-loading enabled)"); + } + catch (Exception ex3) + { + IsAvailable = false; + subscribersInitialized = false; + log.Error("[AutoRetainerIPC] Failed to initialize subscribers: " + ex3.Message); + } + } + + private bool TryEnsureAvailable() + { + if (IsAvailable) + { + return true; + } + if (!subscribersInitialized) + { + return false; + } + DateTime now = DateTime.Now; + if ((now - lastAvailabilityCheck).TotalSeconds < 5.0) + { + return false; + } + lastAvailabilityCheck = now; + try + { + if (getRegisteredCIDsSubscriber == null) + { + return false; + } + List testCids = getRegisteredCIDsSubscriber.InvokeFunc(); + if (!IsAvailable) + { + IsAvailable = true; + log.Information($"[AutoRetainerIPC] ✅ AutoRetainer is now available ({testCids?.Count ?? 0} characters)"); + } + return true; + } + catch (Exception ex) + { + log.Debug("[AutoRetainerIPC] AutoRetainer not yet available: " + ex.Message); + IsAvailable = false; + return false; + } + } + + public bool TryReinitialize() + { + log.Information("[AutoRetainerIPC] Manual IPC reinitialization requested"); + lastAvailabilityCheck = DateTime.MinValue; + bool num = TryEnsureAvailable(); + if (num) + { + log.Information("[AutoRetainerIPC] IPC reinitialization successful"); + return num; + } + log.Warning("[AutoRetainerIPC] IPC still unavailable after reinitialization attempt"); + return num; + } + + public List GetRegisteredCharacters() + { + log.Debug("[AutoRetainerIPC] GetRegisteredCharacters called"); + TryEnsureAvailable(); + if (!IsAvailable || getRegisteredCIDsSubscriber == null) + { + log.Warning("[AutoRetainerIPC] Cannot get characters - IPC not available"); + log.Warning($"[AutoRetainerIPC] IsAvailable: {IsAvailable}, Subscriber: {getRegisteredCIDsSubscriber != null}"); + return new List(); + } + try + { + List cids = getRegisteredCIDsSubscriber.InvokeFunc(); + if (cids == null || cids.Count == 0) + { + log.Warning("[AutoRetainerIPC] No CIDs returned from AutoRetainer"); + return new List(); + } + List characters = new List(); + foreach (ulong cid in cids) + { + string charName = GetCharacterNameFromCID(cid); + if (!string.IsNullOrEmpty(charName)) + { + characters.Add(charName); + continue; + } + log.Debug($"[AutoRetainerIPC] Could not resolve name for CID: {cid}"); + } + if (characters.Count == 0) + { + log.Warning("[AutoRetainerIPC] No character names could be resolved from CIDs"); + } + return characters; + } + catch (Exception ex) + { + log.Error("[AutoRetainerIPC] GetRegisteredCharacters failed: " + ex.Message); + log.Error("[AutoRetainerIPC] Stack trace: " + ex.StackTrace); + return new List(); + } + } + + private string GetCharacterNameFromCID(ulong cid) + { + if (characterCache.TryGetValue(cid, out string cachedName)) + { + if (cachedName.Contains("@")) + { + return cachedName; + } + log.Debug($"[AutoRetainerIPC] Removing invalid cache entry for CID {cid}: '{cachedName}'"); + characterCache.Remove(cid); + } + if (unknownCIDs.Contains(cid)) + { + return $"Unknown (CID: {cid})"; + } + if (getOfflineCharacterDataSubscriber == null) + { + log.Debug("[AutoRetainerIPC] OfflineCharacterData subscriber is null"); + return string.Empty; + } + try + { + object data = getOfflineCharacterDataSubscriber.InvokeFunc(cid); + if (data == null) + { + if (!unknownCIDs.Contains(cid)) + { + log.Warning($"[AutoRetainerIPC] No data returned for CID {cid}"); + unknownCIDs.Add(cid); + } + return $"Unknown (CID: {cid})"; + } + string resolvedName = null; + FieldInfo nameField = data.GetType().GetField("Name"); + FieldInfo worldField = data.GetType().GetField("World"); + if (nameField != null && worldField != null) + { + string name = nameField.GetValue(data)?.ToString(); + string world = worldField.GetValue(data)?.ToString(); + log.Warning($"[AutoRetainerIPC] Field values for CID {cid} - Name: '{name}', World: '{world}'"); + if (!string.IsNullOrEmpty(name) && name != "Unknown") + { + if (string.IsNullOrEmpty(world) && clientState.IsLoggedIn && clientState.LocalPlayer != null && clientState.LocalPlayer.Name.ToString() == name) + { + world = clientState.LocalPlayer.HomeWorld.Value.Name.ToString(); + log.Information("[AutoRetainerIPC] Resolved world from ClientState for " + name + ": " + world); + } + if (!string.IsNullOrEmpty(world)) + { + resolvedName = name + "@" + world; + characterCache[cid] = resolvedName; + return resolvedName; + } + log.Warning($"[AutoRetainerIPC] World is empty for CID {cid}, cannot create full name"); + } + else + { + log.Warning($"[AutoRetainerIPC] Name is empty/invalid for CID {cid}"); + } + } + PropertyInfo nameProperty = data.GetType().GetProperty("Name"); + PropertyInfo worldProperty = data.GetType().GetProperty("World"); + if (nameProperty != null && worldProperty != null) + { + string name2 = nameProperty.GetValue(data)?.ToString(); + string world2 = worldProperty.GetValue(data)?.ToString(); + if (!string.IsNullOrEmpty(name2) && !string.IsNullOrEmpty(world2) && name2 != "Unknown") + { + resolvedName = name2 + "@" + world2; + characterCache[cid] = resolvedName; + log.Debug($"[AutoRetainerIPC] CID {cid} resolved to {resolvedName} (via properties)"); + return resolvedName; + } + } + if (data is JToken jToken) + { + resolvedName = ParseJTokenCharacterData(jToken, cid); + if (!string.IsNullOrEmpty(resolvedName)) + { + characterCache[cid] = resolvedName; + log.Debug($"[AutoRetainerIPC] CID {cid} resolved to {resolvedName} (via JSON)"); + return resolvedName; + } + } + if (!unknownCIDs.Contains(cid)) + { + log.Warning($"[AutoRetainerIPC] Could not resolve name for CID {cid}"); + LogDataStructure(data, cid); + unknownCIDs.Add(cid); + } + return $"Unknown (CID: {cid})"; + } + catch (Exception ex) + { + if (!unknownCIDs.Contains(cid)) + { + log.Warning($"[AutoRetainerIPC] Exception resolving CID {cid}: {ex.Message}"); + log.Debug("[AutoRetainerIPC] Stack trace: " + ex.StackTrace); + unknownCIDs.Add(cid); + } + return $"Unknown (CID: {cid})"; + } + } + + private string? ParseJTokenCharacterData(JToken jToken, ulong cid) + { + try + { + JToken nameToken = jToken.SelectToken("Name") ?? jToken.SelectToken("name") ?? jToken.SelectToken("Value.Name"); + JToken worldToken = jToken.SelectToken("World") ?? jToken.SelectToken("world") ?? jToken.SelectToken("Value.World"); + if (nameToken != null && worldToken != null) + { + string name = nameToken.Value(); + string world = worldToken.Value(); + if (!string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(world)) + { + return name + "@" + world; + } + if (!string.IsNullOrEmpty(name)) + { + log.Warning($"[AutoRetainerIPC] JSON has Name but World is empty for CID {cid}"); + } + } + string[] array = new string[4] { "NameWithWorld", "nameWithWorld", "[\"NameWithWorld\"]", "Value.NameWithWorld" }; + foreach (string path in array) + { + if (string.IsNullOrEmpty(path)) + { + continue; + } + JToken token = jToken.SelectToken(path); + if (token != null && token.Type == JTokenType.String) + { + string value = token.Value(); + if (!string.IsNullOrEmpty(value) && value.Contains("@")) + { + log.Information("[AutoRetainerIPC] Found name via JSON path '" + path + "': " + value); + return value; + } + } + } + } + catch (Exception ex) + { + log.Warning($"[AutoRetainerIPC] Error parsing JToken for CID {cid}: {ex.Message}"); + } + return null; + } + + private void LogDataStructure(object data, ulong cid) + { + try + { + if (data is JToken jToken) + { + log.Debug($"[AutoRetainerIPC] JSON structure for CID {cid}:"); + log.Debug(jToken.ToString()); + return; + } + PropertyInfo[] properties = data.GetType().GetProperties(); + log.Debug($"[AutoRetainerIPC] Object structure for CID {cid}:"); + foreach (PropertyInfo prop in properties.Take(10)) + { + try + { + object value = prop.GetValue(data); + log.Debug($" {prop.Name} = {value ?? "(null)"}"); + } + catch + { + log.Debug(" " + prop.Name + " = (error reading value)"); + } + } + } + catch + { + } + } + + public string? GetCurrentCharacter() + { + try + { + string result = null; + framework.RunOnFrameworkThread(delegate + { + try + { + if (!clientState.IsLoggedIn) + { + result = null; + } + else if (clientState.LocalPlayer == null) + { + result = null; + } + else + { + string text = clientState.LocalPlayer.Name.ToString(); + string text2 = clientState.LocalPlayer.HomeWorld.Value.Name.ToString(); + if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(text2)) + { + result = null; + } + else + { + result = text + "@" + text2; + } + } + } + catch (Exception ex2) + { + log.Debug("[AutoRetainerIPC] GetCurrentCharacter inner failed: " + ex2.Message); + result = null; + } + }).Wait(); + return result; + } + catch (Exception ex) + { + log.Debug("[AutoRetainerIPC] GetCurrentCharacter failed: " + ex.Message); + return null; + } + } + + public bool SwitchCharacter(string characterNameWithWorld) + { + if (string.IsNullOrEmpty(characterNameWithWorld)) + { + log.Warning("[AutoRetainerIPC] Character name is null or empty"); + return false; + } + TryEnsureAvailable(); + if (!IsAvailable) + { + log.Warning("[AutoRetainerIPC] AutoRetainer not available"); + return false; + } + try + { + log.Information("[AutoRetainerIPC] Requesting relog to: " + characterNameWithWorld); + string command = "/ays relog " + characterNameWithWorld; + bool success = false; + framework.RunOnFrameworkThread(delegate + { + try + { + commandManager.ProcessCommand(command); + success = true; + log.Information("[AutoRetainerIPC] Relog command executed: " + command); + } + catch (Exception ex2) + { + log.Error("[AutoRetainerIPC] Failed to execute relog command: " + ex2.Message); + success = false; + } + }).Wait(); + return success; + } + catch (Exception ex) + { + log.Error("[AutoRetainerIPC] Failed to switch character: " + ex.Message); + return false; + } + } + + public bool GetMultiModeEnabled() + { + TryEnsureAvailable(); + if (!IsAvailable || getMultiModeEnabledSubscriber == null) + { + log.Debug("[AutoRetainerIPC] Multi-Mode IPC not available"); + return false; + } + try + { + return getMultiModeEnabledSubscriber.InvokeFunc(); + } + catch (Exception ex) + { + log.Error("[AutoRetainerIPC] GetMultiModeEnabled failed: " + ex.Message); + return false; + } + } + + public bool SetMultiModeEnabled(bool enabled) + { + TryEnsureAvailable(); + if (!IsAvailable || setMultiModeEnabledProvider == null) + { + log.Warning("[AutoRetainerIPC] Multi-Mode IPC not available"); + return false; + } + try + { + setMultiModeEnabledProvider.SendMessage(enabled); + log.Information($"[AutoRetainerIPC] Multi-Mode set to: {enabled}"); + return true; + } + catch (Exception ex) + { + log.Error("[AutoRetainerIPC] SetMultiModeEnabled failed: " + ex.Message); + return false; + } + } + + public void Dispose() + { + IsAvailable = false; + characterCache.Clear(); + unknownCIDs.Clear(); + log.Information("[AutoRetainerIPC] Service disposed"); + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/CharacterSafeWaitService.cs b/QuestionableCompanion/QuestionableCompanion.Services/CharacterSafeWaitService.cs new file mode 100644 index 0000000..10c9d51 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/CharacterSafeWaitService.cs @@ -0,0 +1,233 @@ +using System; +using System.Diagnostics; +using System.Numerics; +using System.Threading; +using System.Threading.Tasks; +using Dalamud.Game.ClientState.Conditions; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using FFXIVClientStructs.FFXIV.Client.Game.Control; +using FFXIVClientStructs.FFXIV.Common.Math; + +namespace QuestionableCompanion.Services; + +public class CharacterSafeWaitService +{ + private readonly IClientState clientState; + + private readonly IPluginLog log; + + private readonly IFramework framework; + + private readonly ICondition condition; + + private readonly IGameGui gameGui; + + public CharacterSafeWaitService(IClientState clientState, IPluginLog log, IFramework framework, ICondition condition, IGameGui gameGui) + { + this.clientState = clientState; + this.log = log; + this.framework = framework; + this.condition = condition; + this.gameGui = gameGui; + } + + public unsafe Task WaitForCharacterFullyLoadedAsync(int timeoutSeconds = 5, int checkIntervalMs = 200) + { + return Task.Run(delegate + { + Stopwatch stopwatch = Stopwatch.StartNew(); + log.Information("[CharLoad] Waiting for character to fully load..."); + TimeSpan timeSpan = TimeSpan.FromSeconds(timeoutSeconds); + while (stopwatch.Elapsed < timeSpan) + { + try + { + if (Control.GetLocalPlayer() == null || !clientState.IsLoggedIn) + { + Thread.Sleep(checkIntervalMs); + } + else + { + if (!condition[ConditionFlag.BetweenAreas] && !condition[ConditionFlag.BetweenAreas51] && !condition[ConditionFlag.OccupiedInCutSceneEvent]) + { + stopwatch.Stop(); + log.Information($"[CharLoad] Character fully loaded in {stopwatch.ElapsedMilliseconds}ms!"); + return true; + } + Thread.Sleep(checkIntervalMs); + } + } + catch (Exception ex) + { + log.Error("[CharLoad] Error during load check: " + ex.Message); + Thread.Sleep(checkIntervalMs); + } + } + stopwatch.Stop(); + log.Warning($"[CharLoad] Character load timeout after {stopwatch.ElapsedMilliseconds}ms"); + return false; + }); + } + + public bool WaitForCharacterFullyLoaded(int timeoutSeconds = 5) + { + return WaitForCharacterFullyLoadedAsync(timeoutSeconds).GetAwaiter().GetResult(); + } + + public async Task PerformSafeWaitAsync() + { + Stopwatch sw = Stopwatch.StartNew(); + log.Information("[SafeWait] Starting character stabilization..."); + try + { + if (!(await WaitForCharacterFullyLoadedAsync(120))) + { + log.Warning("[SafeWait] Character not fully loaded, proceeding anyway"); + } + await Task.Delay(250); + await WaitForMovementEndAsync(); + await Task.Delay(250); + await WaitForActionsCompleteAsync(); + await Task.Delay(500); + sw.Stop(); + log.Information($"[SafeWait] Character stabilization complete in {sw.ElapsedMilliseconds}ms"); + } + catch (Exception ex) + { + sw.Stop(); + log.Error($"[SafeWait] Error during safe wait after {sw.ElapsedMilliseconds}ms: {ex.Message}"); + } + } + + public void PerformSafeWait() + { + PerformSafeWaitAsync().GetAwaiter().GetResult(); + } + + private async Task WaitForMovementEndAsync(int maxWaitMs = 5000, int checkIntervalMs = 100) + { + Stopwatch sw = Stopwatch.StartNew(); + while (sw.ElapsedMilliseconds < maxWaitMs) + { + try + { + if (!IsPlayerMoving()) + { + log.Debug($"[SafeWait] Movement ended after {sw.ElapsedMilliseconds}ms"); + return; + } + await Task.Delay(checkIntervalMs); + } + catch (Exception ex) + { + log.Error("[SafeWait] Error checking movement: " + ex.Message); + await Task.Delay(checkIntervalMs); + } + } + log.Warning($"[SafeWait] Movement wait timeout after {sw.ElapsedMilliseconds}ms"); + } + + private void WaitForMovementEnd() + { + WaitForMovementEndAsync().GetAwaiter().GetResult(); + } + + private async Task WaitForActionsCompleteAsync(int maxWaitMs = 10000, int checkIntervalMs = 100) + { + Stopwatch sw = Stopwatch.StartNew(); + while (sw.ElapsedMilliseconds < maxWaitMs) + { + try + { + if (!IsPlayerInAction()) + { + log.Debug($"[SafeWait] Actions completed after {sw.ElapsedMilliseconds}ms"); + return; + } + await Task.Delay(checkIntervalMs); + } + catch (Exception ex) + { + log.Error("[SafeWait] Error checking actions: " + ex.Message); + await Task.Delay(checkIntervalMs); + } + } + log.Warning($"[SafeWait] Action wait timeout after {sw.ElapsedMilliseconds}ms"); + } + + private void WaitForActionsComplete() + { + WaitForActionsCompleteAsync().GetAwaiter().GetResult(); + } + + private unsafe bool IsPlayerMoving() + { + try + { + BattleChara* player = Control.GetLocalPlayer(); + if (player == null) + { + return false; + } + FFXIVClientStructs.FFXIV.Common.Math.Vector3 currentPos = player->Character.GameObject.Position; + Thread.Sleep(50); + player = Control.GetLocalPlayer(); + if (player == null) + { + return false; + } + FFXIVClientStructs.FFXIV.Common.Math.Vector3 newPos = player->Character.GameObject.Position; + return System.Numerics.Vector3.Distance(currentPos, newPos) > 0.01f; + } + catch + { + return false; + } + } + + private unsafe bool IsPlayerInAction() + { + try + { + BattleChara* player = Control.GetLocalPlayer(); + if (player == null) + { + return false; + } + if (player->Character.IsCasting) + { + log.Debug("[SafeWait] Player is casting"); + return true; + } + return false; + } + catch + { + return false; + } + } + + public async Task PerformQuickSafeWaitAsync() + { + Stopwatch sw = Stopwatch.StartNew(); + log.Debug("[SafeWait] Performing quick safe wait..."); + try + { + await WaitForMovementEndAsync(3000); + await Task.Delay(1000); + sw.Stop(); + log.Debug($"[SafeWait] Quick safe wait complete in {sw.ElapsedMilliseconds}ms"); + } + catch (Exception ex) + { + sw.Stop(); + log.Error("[SafeWait] Error during quick safe wait: " + ex.Message); + } + } + + public void PerformQuickSafeWait() + { + PerformQuickSafeWaitAsync().GetAwaiter().GetResult(); + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/CombatDutyDetectionService.cs b/QuestionableCompanion/QuestionableCompanion.Services/CombatDutyDetectionService.cs new file mode 100644 index 0000000..87ba6c9 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/CombatDutyDetectionService.cs @@ -0,0 +1,368 @@ +using System; +using Dalamud.Game.ClientState.Conditions; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Plugin.Services; + +namespace QuestionableCompanion.Services; + +public class CombatDutyDetectionService : IDisposable +{ + private readonly ICondition condition; + + private readonly IPluginLog log; + + private readonly IClientState clientState; + + private readonly ICommandManager commandManager; + + private readonly IFramework framework; + + private readonly Configuration config; + + private bool wasInCombat; + + private bool wasInDuty; + + private DateTime dutyExitTime = DateTime.MinValue; + + private DateTime dutyEntryTime = DateTime.MinValue; + + private DateTime lastStateChange = DateTime.MinValue; + + private bool combatCommandsActive; + + private bool hasCombatCommandsForDuty; + + private bool isInAutoDutyDungeon; + + private uint currentQuestId; + + private bool isRotationActive; + + public bool JustEnteredDuty { get; private set; } + + public bool JustExitedDuty { get; private set; } + + public DateTime DutyExitTime => dutyExitTime; + + public bool IsInCombat { get; private set; } + + public bool IsInDuty { get; private set; } + + public bool IsInDutyQueue { get; private set; } + + public bool ShouldPauseAutomation + { + get + { + if (!IsInCombat && !IsInDuty) + { + return IsInDutyQueue; + } + return true; + } + } + + public void AcknowledgeDutyEntry() + { + JustEnteredDuty = false; + } + + public void AcknowledgeDutyExit() + { + JustExitedDuty = false; + } + + public CombatDutyDetectionService(ICondition condition, IPluginLog log, IClientState clientState, ICommandManager commandManager, IFramework framework, Configuration config) + { + this.condition = condition; + this.log = log; + this.clientState = clientState; + this.commandManager = commandManager; + this.framework = framework; + this.config = config; + log.Information("[CombatDuty] Service initialized"); + } + + public void SetRotationActive(bool active) + { + isRotationActive = active; + } + + public void SetAutoDutyDungeon(bool isAutoDuty) + { + isInAutoDutyDungeon = isAutoDuty; + } + + public void SetCurrentQuestId(uint questId) + { + currentQuestId = questId; + } + + public void Update() + { + if (clientState.LocalPlayer == null || !clientState.IsLoggedIn) + { + return; + } + if (isRotationActive) + { + bool inCombat = condition[ConditionFlag.InCombat]; + if (inCombat != wasInCombat) + { + IsInCombat = inCombat; + wasInCombat = inCombat; + lastStateChange = DateTime.Now; + if (inCombat) + { + log.Information("[CombatDuty] Combat started - pausing automation"); + if (currentQuestId == 811) + { + log.Information("[CombatDuty] Quest 811 - combat commands DISABLED (RSR off)"); + return; + } + } + else + { + log.Information("[CombatDuty] Combat ended - resuming automation"); + if (combatCommandsActive && !IsInDuty) + { + log.Information("[CombatDuty] Not in duty - disabling combat commands"); + DisableCombatCommands(); + } + else if (combatCommandsActive && IsInDuty) + { + log.Information("[CombatDuty] In duty - keeping combat commands active"); + } + } + } + } + if (isRotationActive && config.EnableCombatHandling && IsInCombat && !combatCommandsActive && currentQuestId != 811) + { + IPlayerCharacter player = clientState.LocalPlayer; + if (player != null) + { + float hpPercent = (float)player.CurrentHp / (float)player.MaxHp * 100f; + if (hpPercent <= (float)config.CombatHPThreshold) + { + log.Warning($"[CombatDuty] HP at {hpPercent:F1}% (threshold: {config.CombatHPThreshold}%) - enabling combat commands"); + EnableCombatCommands(); + } + } + } + bool inDuty = condition[ConditionFlag.BoundByDuty] || condition[ConditionFlag.BoundByDuty56] || condition[ConditionFlag.BoundByDuty95]; + if (inDuty != wasInDuty) + { + IsInDuty = inDuty; + wasInDuty = inDuty; + lastStateChange = DateTime.Now; + if (inDuty) + { + log.Information("[CombatDuty] Duty started - pausing automation"); + JustEnteredDuty = true; + JustExitedDuty = false; + dutyEntryTime = DateTime.Now; + hasCombatCommandsForDuty = false; + } + else + { + log.Information("[CombatDuty] Duty completed - resuming automation"); + JustEnteredDuty = false; + JustExitedDuty = true; + dutyExitTime = DateTime.Now; + dutyEntryTime = DateTime.MinValue; + hasCombatCommandsForDuty = false; + if (combatCommandsActive) + { + log.Information("[CombatDuty] Duty ended - disabling combat commands"); + DisableCombatCommands(); + } + } + } + if (isRotationActive && IsInDuty && !isInAutoDutyDungeon && !hasCombatCommandsForDuty && dutyEntryTime != DateTime.MinValue) + { + if (currentQuestId == 811) + { + log.Information("[CombatDuty] Quest 811 - skipping combat commands (RSR disabled)"); + hasCombatCommandsForDuty = true; + return; + } + if (currentQuestId == 4591) + { + log.Information("[CombatDuty] Quest 4591 (Steps of Faith) - skipping combat commands (handler does it)"); + hasCombatCommandsForDuty = true; + return; + } + if ((DateTime.Now - dutyEntryTime).TotalSeconds >= 8.0) + { + log.Information("[CombatDuty] 8 seconds in Solo Duty - enabling combat commands"); + EnableCombatCommands(); + hasCombatCommandsForDuty = true; + } + } + bool inQueue = condition[ConditionFlag.WaitingForDuty] || condition[ConditionFlag.WaitingForDutyFinder]; + if (inQueue != IsInDutyQueue) + { + IsInDutyQueue = inQueue; + lastStateChange = DateTime.Now; + if (inQueue) + { + log.Information("[CombatDuty] Duty queue active - pausing automation"); + } + else + { + log.Information("[CombatDuty] Duty queue ended - resuming automation"); + } + } + } + + public TimeSpan TimeSinceLastStateChange() + { + if (lastStateChange == DateTime.MinValue) + { + return TimeSpan.Zero; + } + return DateTime.Now - lastStateChange; + } + + private void EnableCombatCommands() + { + if (combatCommandsActive) + { + return; + } + try + { + log.Information("[CombatDuty] ========================================"); + log.Information("[CombatDuty] === ENABLING COMBAT AUTOMATION ==="); + log.Information("[CombatDuty] ========================================"); + framework.RunOnTick(delegate + { + try + { + commandManager.ProcessCommand("/rsr auto"); + log.Information("[CombatDuty] /rsr auto sent"); + } + catch (Exception ex2) + { + log.Error("[CombatDuty] Failed to send /rsr auto: " + ex2.Message); + } + }, TimeSpan.Zero); + framework.RunOnTick(delegate + { + try + { + commandManager.ProcessCommand("/vbmai on"); + log.Information("[CombatDuty] /vbmai on sent"); + } + catch (Exception ex2) + { + log.Error("[CombatDuty] Failed to send /vbmai on: " + ex2.Message); + } + }, TimeSpan.FromMilliseconds(100L, 0L)); + framework.RunOnTick(delegate + { + try + { + commandManager.ProcessCommand("/bmrai on"); + log.Information("[CombatDuty] /bmrai on sent"); + } + catch (Exception ex2) + { + log.Error("[CombatDuty] Failed to send /bmrai on: " + ex2.Message); + } + }, TimeSpan.FromMilliseconds(200L, 0L)); + combatCommandsActive = true; + log.Information("[CombatDuty] Combat automation enabled"); + } + catch (Exception ex) + { + log.Error("[CombatDuty] Error enabling combat commands: " + ex.Message); + } + } + + private void DisableCombatCommands() + { + if (!combatCommandsActive) + { + return; + } + try + { + log.Information("[CombatDuty] ========================================"); + log.Information("[CombatDuty] === DISABLING COMBAT AUTOMATION ==="); + log.Information("[CombatDuty] ========================================"); + framework.RunOnTick(delegate + { + try + { + commandManager.ProcessCommand("/rsr off"); + log.Information("[CombatDuty] /rsr off sent"); + } + catch (Exception ex2) + { + log.Error("[CombatDuty] Failed to send /rsr off: " + ex2.Message); + } + }, TimeSpan.Zero); + framework.RunOnTick(delegate + { + try + { + commandManager.ProcessCommand("/vbmai off"); + log.Information("[CombatDuty] /vbmai off sent"); + } + catch (Exception ex2) + { + log.Error("[CombatDuty] Failed to send /vbmai off: " + ex2.Message); + } + }, TimeSpan.FromMilliseconds(100L, 0L)); + framework.RunOnTick(delegate + { + try + { + commandManager.ProcessCommand("/bmrai off"); + log.Information("[CombatDuty] /bmrai off sent"); + } + catch (Exception ex2) + { + log.Error("[CombatDuty] Failed to send /bmrai off: " + ex2.Message); + } + }, TimeSpan.FromMilliseconds(200L, 0L)); + combatCommandsActive = false; + log.Information("[CombatDuty] Combat automation disabled"); + } + catch (Exception ex) + { + log.Error("[CombatDuty] Error disabling combat commands: " + ex.Message); + } + } + + public void ClearDutyExitFlag() + { + JustExitedDuty = false; + } + + public void Reset() + { + IsInCombat = false; + IsInDuty = false; + IsInDutyQueue = false; + wasInCombat = false; + wasInDuty = false; + combatCommandsActive = false; + hasCombatCommandsForDuty = false; + JustEnteredDuty = false; + JustExitedDuty = false; + dutyExitTime = DateTime.MinValue; + dutyEntryTime = DateTime.MinValue; + log.Information("[CombatDuty] State reset"); + } + + public void Dispose() + { + if (combatCommandsActive) + { + DisableCombatCommands(); + } + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/CrossProcessIPC.cs b/QuestionableCompanion/QuestionableCompanion.Services/CrossProcessIPC.cs new file mode 100644 index 0000000..147dae2 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/CrossProcessIPC.cs @@ -0,0 +1,514 @@ +using System; +using System.Globalization; +using System.IO.MemoryMappedFiles; +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Plugin.Services; + +namespace QuestionableCompanion.Services; + +public class CrossProcessIPC : IDisposable +{ + private readonly IPluginLog log; + + private readonly IFramework framework; + + private readonly Configuration configuration; + + private MemoryMappedFile? mmf; + + private Thread? listenerThread; + + private bool isRunning; + + private const string MMF_NAME = "QSTCompanion_IPC"; + + private const int MMF_SIZE = 4096; + + private const int POLLING_INTERVAL_MS = 10; + + public event Action? OnHelperAvailable; + + public event Action? OnHelperRequested; + + public event Action? OnHelperDismissed; + + public event Action? OnChatMessageReceived; + + public event Action? OnCommandReceived; + + public event Action? OnHelperInParty; + + public event Action? OnHelperInDuty; + + public event Action? OnHelperReady; + + public event Action? OnRequestHelperAnnouncements; + + public event Action? OnChauffeurSummonRequest; + + public event Action? OnChauffeurReadyForPickup; + + public event Action? OnChauffeurArrived; + + public event Action? OnChauffeurZoneUpdate; + + public event Action? OnChauffeurMountReady; + + public event Action? OnChauffeurPassengerMounted; + + public event Action? OnHelperStatusUpdate; + + public event Action? OnQuesterPositionUpdate; + + public CrossProcessIPC(IPluginLog log, IFramework framework, Configuration configuration) + { + this.log = log; + this.framework = framework; + this.configuration = configuration; + InitializeIPC(); + } + + private void InitializeIPC() + { + try + { + mmf = MemoryMappedFile.CreateOrOpen("QSTCompanion_IPC", 4096L, MemoryMappedFileAccess.ReadWrite); + isRunning = true; + listenerThread = new Thread(ListenerLoop) + { + IsBackground = true, + Name = "QSTCompanion IPC Listener" + }; + listenerThread.Start(); + log.Information("[CrossProcessIPC] Initialized with Memory-Mapped File"); + if (configuration.IsHighLevelHelper) + { + framework.RunOnFrameworkThread(delegate + { + AnnounceHelper(); + }); + } + } + catch (Exception ex) + { + log.Error("[CrossProcessIPC] Failed to initialize: " + ex.Message); + } + } + + private void ListenerLoop() + { + string lastMessage = ""; + while (isRunning) + { + try + { + if (mmf == null) + { + break; + } + using (MemoryMappedViewAccessor accessor = mmf.CreateViewAccessor(0L, 4096L, MemoryMappedFileAccess.Read)) + { + byte[] buffer = new byte[4096]; + accessor.ReadArray(0L, buffer, 0, 4096); + string message = Encoding.UTF8.GetString(buffer).TrimEnd('\0'); + if (!string.IsNullOrEmpty(message) && message != lastMessage) + { + lastMessage = message; + ProcessMessage(message); + } + } + Thread.Sleep(10); + } + catch (Exception ex) + { + log.Error("[CrossProcessIPC] Listener error: " + ex.Message); + Thread.Sleep(1000); + } + } + } + + private void ProcessMessage(string message) + { + try + { + string[] parts = message.Split('|'); + if (parts.Length < 2) + { + return; + } + string command = parts[0]; + _003C_003Ec__DisplayClass63_0 CS_0024_003C_003E8__locals0; + framework.RunOnFrameworkThread(delegate + { + try + { + string text = command; + if (text != null) + { + switch (text.Length) + { + case 16: + switch (text[0]) + { + case 'H': + if (text == "HELPER_AVAILABLE" && parts.Length >= 3) + { + string text3 = parts[1]; + if (ushort.TryParse(parts[2], out var result8)) + { + log.Information($"[CrossProcessIPC] Helper available: {text3}@{result8}"); + this.OnHelperAvailable?.Invoke(text3, result8); + } + } + break; + case 'C': + if (text == "CHAUFFEUR_SUMMON" && parts.Length >= 11) + { + ushort questerWorld = ushort.Parse(parts[2]); + uint zoneId = uint.Parse(parts[3]); + Vector3 targetPos = new Vector3(float.Parse(parts[4]), float.Parse(parts[5]), float.Parse(parts[6])); + Vector3 questerPos = new Vector3(float.Parse(parts[7]), float.Parse(parts[8]), float.Parse(parts[9])); + bool isAttuneAetheryte = bool.Parse(parts[10]); + framework.RunOnFrameworkThread(delegate + { + this.OnChauffeurSummonRequest?.Invoke((string)(object)CS_0024_003C_003E8__locals0, questerWorld, zoneId, targetPos, questerPos, isAttuneAetheryte); + }); + } + break; + case 'Q': + if (text == "QUESTER_POSITION" && parts.Length >= 7) + { + string arg3 = parts[1]; + if (ushort.TryParse(parts[2], out var result3) && uint.TryParse(parts[3], out var result4) && float.TryParse(parts[4], NumberStyles.Float, CultureInfo.InvariantCulture, out var result5) && float.TryParse(parts[5], NumberStyles.Float, CultureInfo.InvariantCulture, out var result6) && float.TryParse(parts[6], NumberStyles.Float, CultureInfo.InvariantCulture, out var result7)) + { + Vector3 arg4 = new Vector3(result5, result6, result7); + this.OnQuesterPositionUpdate?.Invoke(arg3, result3, result4, arg4); + } + } + break; + } + break; + case 14: + switch (text[7]) + { + case 'R': + if (text == "HELPER_REQUEST" && parts.Length >= 3) + { + string text12 = parts[1]; + if (ushort.TryParse(parts[2], out var result14)) + { + log.Information($"[CrossProcessIPC] Helper request: {text12}@{result14}"); + this.OnHelperRequested?.Invoke(text12, result14); + } + } + break; + case 'D': + if (text == "HELPER_DISMISS") + { + log.Information("[CrossProcessIPC] Helper dismiss"); + this.OnHelperDismissed?.Invoke(); + } + break; + case 'I': + if (text == "HELPER_IN_DUTY" && parts.Length >= 3) + { + string text11 = parts[1]; + if (ushort.TryParse(parts[2], out var result13)) + { + log.Information($"[CrossProcessIPC] Helper in duty: {text11}@{result13}"); + this.OnHelperInDuty?.Invoke(text11, result13); + } + } + break; + } + break; + case 15: + switch (text[0]) + { + case 'H': + if (text == "HELPER_IN_PARTY" && parts.Length >= 3) + { + string text9 = parts[1]; + if (ushort.TryParse(parts[2], out var result12)) + { + log.Information($"[CrossProcessIPC] Helper in party: {text9}@{result12}"); + this.OnHelperInParty?.Invoke(text9, result12); + } + } + break; + case 'C': + if (text == "CHAUFFEUR_READY" && parts.Length >= 2) + { + string text8 = parts[1]; + log.Information("[CrossProcessIPC] Chauffeur ready: " + text8); + this.OnChauffeurReadyForPickup?.Invoke(text8); + } + break; + } + break; + case 21: + switch (text[10]) + { + case 'Z': + if (text == "CHAUFFEUR_ZONE_UPDATE" && parts.Length >= 5) + { + string text5 = parts[1]; + if (ushort.TryParse(parts[2], out var result10) && uint.TryParse(parts[3], out var result11)) + { + string text6 = parts[4]; + log.Information($"[CrossProcessIPC] Zone update: {text5}@{result10} -> {text6} ({result11})"); + this.OnChauffeurZoneUpdate?.Invoke(text5, result10, result11, text6); + } + } + break; + case 'M': + if (text == "CHAUFFEUR_MOUNT_READY" && parts.Length >= 3) + { + string text4 = parts[1]; + if (ushort.TryParse(parts[2], out var result9)) + { + log.Information($"[CrossProcessIPC] Chauffeur mount ready for: {text4}@{result9}"); + IPluginLog pluginLog = log; + DefaultInterpolatedStringHandler defaultInterpolatedStringHandler = new DefaultInterpolatedStringHandler(53, 1); + defaultInterpolatedStringHandler.AppendLiteral("[CrossProcessIPC] OnChauffeurMountReady subscribers: "); + Action? action = this.OnChauffeurMountReady; + defaultInterpolatedStringHandler.AppendFormatted((action != null) ? action.GetInvocationList().Length : 0); + pluginLog.Information(defaultInterpolatedStringHandler.ToStringAndClear()); + this.OnChauffeurMountReady?.Invoke(text4, result9); + } + } + break; + } + break; + case 4: + if (text == "CHAT" && parts.Length >= 2) + { + string text7 = parts[1]; + log.Information("[CrossProcessIPC] Chat: " + text7); + this.OnChatMessageReceived?.Invoke(text7); + } + break; + case 7: + if (text == "COMMAND" && parts.Length >= 2) + { + string text10 = parts[1]; + log.Information("[CrossProcessIPC] Command: " + text10); + this.OnCommandReceived?.Invoke(text10); + } + break; + case 12: + if (text == "HELPER_READY" && parts.Length >= 3) + { + string text13 = parts[1]; + if (ushort.TryParse(parts[2], out var result15)) + { + log.Information($"[CrossProcessIPC] Helper ready: {text13}@{result15}"); + this.OnHelperReady?.Invoke(text13, result15); + } + } + break; + case 28: + if (text == "REQUEST_HELPER_ANNOUNCEMENTS") + { + log.Information("[CrossProcessIPC] Request for helper announcements received"); + this.OnRequestHelperAnnouncements?.Invoke(); + } + break; + case 17: + if (text == "CHAUFFEUR_ARRIVED" && parts.Length >= 3) + { + string text2 = parts[1]; + if (ushort.TryParse(parts[2], out var result2)) + { + log.Information($"[CrossProcessIPC] Chauffeur arrived for: {text2}@{result2}"); + this.OnChauffeurArrived?.Invoke(text2, result2); + } + } + break; + case 27: + if (text == "CHAUFFEUR_PASSENGER_MOUNTED") + { + log.Information("[CrossProcessIPC] Chauffeur passenger mounted signal received"); + IPluginLog pluginLog2 = log; + DefaultInterpolatedStringHandler defaultInterpolatedStringHandler2 = new DefaultInterpolatedStringHandler(59, 1); + defaultInterpolatedStringHandler2.AppendLiteral("[CrossProcessIPC] OnChauffeurPassengerMounted subscribers: "); + Action? action2 = this.OnChauffeurPassengerMounted; + defaultInterpolatedStringHandler2.AppendFormatted((action2 != null) ? action2.GetInvocationList().Length : 0); + pluginLog2.Information(defaultInterpolatedStringHandler2.ToStringAndClear()); + this.OnChauffeurPassengerMounted?.Invoke(); + } + break; + case 13: + if (text == "HELPER_STATUS" && parts.Length >= 4) + { + string arg = parts[1]; + if (ushort.TryParse(parts[2], out var result)) + { + string arg2 = parts[3]; + this.OnHelperStatusUpdate?.Invoke(arg, result, arg2); + } + } + break; + } + } + } + catch (Exception ex2) + { + log.Error("[CrossProcessIPC] Error in event handler: " + ex2.Message); + } + }); + } + catch (Exception ex) + { + log.Error("[CrossProcessIPC] Error processing message: " + ex.Message); + } + } + + private void SendMessage(string message) + { + try + { + if (mmf == null) + { + return; + } + using MemoryMappedViewAccessor accessor = mmf.CreateViewAccessor(0L, 4096L, MemoryMappedFileAccess.Write); + byte[] buffer = Encoding.UTF8.GetBytes(message); + if (buffer.Length > 4095) + { + log.Warning($"[CrossProcessIPC] Message too large: {buffer.Length} bytes"); + } + else + { + byte[] clearBuffer = new byte[4096]; + accessor.WriteArray(0L, clearBuffer, 0, 4096); + accessor.WriteArray(0L, buffer, 0, buffer.Length); + } + } + catch (Exception ex) + { + log.Error("[CrossProcessIPC] Failed to send message: " + ex.Message); + } + } + + public void AnnounceHelper() + { + if (configuration.IsHighLevelHelper) + { + IPlayerCharacter localPlayer = Plugin.ClientState?.LocalPlayer; + if (localPlayer != null) + { + string name = localPlayer.Name.ToString(); + ushort worldId = (ushort)localPlayer.HomeWorld.RowId; + SendMessage($"HELPER_AVAILABLE|{name}|{worldId}"); + log.Information($"[CrossProcessIPC] Announced as helper: {name}@{worldId}"); + } + } + } + + public void RequestHelper(string characterName, ushort worldId) + { + SendMessage($"HELPER_REQUEST|{characterName}|{worldId}"); + log.Information($"[CrossProcessIPC] Requested helper: {characterName}@{worldId}"); + } + + public void DismissHelper() + { + SendMessage("HELPER_DISMISS"); + log.Information("[CrossProcessIPC] Dismissed helper"); + } + + public void SendChatMessage(string message) + { + SendMessage("CHAT|" + message); + log.Information("[CrossProcessIPC] Chat: " + message); + } + + public void SendCommand(string command) + { + SendMessage("COMMAND|" + command); + log.Information("[CrossProcessIPC] Command: " + command); + } + + public void NotifyHelperInParty(string name, ushort worldId) + { + SendMessage($"HELPER_IN_PARTY|{name}|{worldId}"); + log.Information($"[CrossProcessIPC] Notified: Helper in party {name}@{worldId}"); + } + + public void NotifyHelperInDuty(string name, ushort worldId) + { + SendMessage($"HELPER_IN_DUTY|{name}|{worldId}"); + log.Information($"[CrossProcessIPC] Notified: Helper in duty {name}@{worldId}"); + } + + public void NotifyHelperReady(string name, ushort worldId) + { + SendMessage($"HELPER_READY|{name}|{worldId}"); + log.Information($"[CrossProcessIPC] Notified: Helper ready {name}@{worldId}"); + } + + public void RequestHelperAnnouncements() + { + SendMessage("REQUEST_HELPER_ANNOUNCEMENTS"); + log.Information("[CrossProcessIPC] Requesting helper announcements from all clients"); + } + + public void SendChauffeurSummonRequest(string questerName, ushort questerWorld, uint zoneId, Vector3 targetPos, Vector3 questerPos, bool isAttuneAetheryte) + { + SendMessage($"CHAUFFEUR_SUMMON|{questerName}|{questerWorld}|{zoneId}|{targetPos.X}|{targetPos.Y}|{targetPos.Z}|{questerPos.X}|{questerPos.Y}|{questerPos.Z}|{isAttuneAetheryte}"); + log.Information($"[CrossProcessIPC] Chauffeur summon: {questerName}@{questerWorld} zone {zoneId} quester@({questerPos.X:F2},{questerPos.Y:F2},{questerPos.Z:F2}) AttuneAetheryte={isAttuneAetheryte}"); + } + + public void SendChauffeurMountReady(string questerName, ushort questerWorld) + { + SendMessage($"CHAUFFEUR_MOUNT_READY|{questerName}|{questerWorld}"); + log.Information($"[CrossProcessIPC] Chauffeur mount ready for RidePillion: {questerName}@{questerWorld}"); + } + + public void SendChauffeurPassengerMounted() + { + SendMessage("CHAUFFEUR_PASSENGER_MOUNTED"); + log.Debug("[CrossProcessIPC] Sent: CHAUFFEUR_PASSENGER_MOUNTED"); + } + + public void SendChauffeurReadyForPickup(string helperName) + { + SendMessage("CHAUFFEUR_READY|" + helperName); + log.Information("[CrossProcessIPC] Chauffeur ready: " + helperName); + } + + public void SendChauffeurArrived(string questerName, ushort questerWorld) + { + SendMessage($"CHAUFFEUR_ARRIVED|{questerName}|{questerWorld}"); + log.Information($"[CrossProcessIPC] Chauffeur arrived for: {questerName}@{questerWorld}"); + } + + public void SendChauffeurZoneUpdate(string characterName, ushort worldId, uint zoneId, string zoneName) + { + SendMessage($"CHAUFFEUR_ZONE_UPDATE|{characterName}|{worldId}|{zoneId}|{zoneName}"); + log.Information($"[CrossProcessIPC] Zone update: {characterName}@{worldId} -> {zoneName} ({zoneId})"); + } + + public void BroadcastHelperStatus(string helperName, ushort helperWorld, string status) + { + SendMessage($"HELPER_STATUS|{helperName}|{helperWorld}|{status}"); + } + + public void BroadcastQuesterPosition(string questerName, ushort questerWorld, uint zoneId, Vector3 position) + { + SendMessage($"QUESTER_POSITION|{questerName}|{questerWorld}|{zoneId}|{position.X.ToString(CultureInfo.InvariantCulture)}|{position.Y.ToString(CultureInfo.InvariantCulture)}|{position.Z.ToString(CultureInfo.InvariantCulture)}"); + } + + public void Dispose() + { + isRunning = false; + listenerThread?.Join(1000); + mmf?.Dispose(); + log.Information("[CrossProcessIPC] Disposed"); + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/CurrentTask.cs b/QuestionableCompanion/QuestionableCompanion.Services/CurrentTask.cs new file mode 100644 index 0000000..b244653 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/CurrentTask.cs @@ -0,0 +1,14 @@ +using System.Numerics; + +namespace QuestionableCompanion.Services; + +public class CurrentTask +{ + public required string Type { get; init; } + + public ushort TerritoryId { get; init; } + + public string? InteractionType { get; init; } + + public Vector3? Position { get; init; } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/DCTravelService.cs b/QuestionableCompanion/QuestionableCompanion.Services/DCTravelService.cs new file mode 100644 index 0000000..06c09c4 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/DCTravelService.cs @@ -0,0 +1,374 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Dalamud.Plugin.Services; + +namespace QuestionableCompanion.Services; + +public class DCTravelService : IDisposable +{ + private readonly IPluginLog log; + + private readonly Configuration config; + + private readonly IClientState clientState; + + private readonly LifestreamIPC lifestreamIPC; + + private readonly QuestionableIPC questionableIPC; + + private readonly CharacterSafeWaitService safeWaitService; + + private readonly ICommandManager commandManager; + + private readonly IFramework framework; + + private bool dcTravelCompleted; + + private bool dcTravelInProgress; + + public DCTravelService(IPluginLog log, Configuration config, LifestreamIPC lifestreamIPC, QuestionableIPC questionableIPC, CharacterSafeWaitService safeWaitService, IClientState clientState, ICommandManager commandManager, IFramework framework) + { + this.log = log; + this.config = config; + this.lifestreamIPC = lifestreamIPC; + this.questionableIPC = questionableIPC; + this.safeWaitService = safeWaitService; + this.clientState = clientState; + this.commandManager = commandManager; + this.framework = framework; + } + + public bool ShouldPerformDCTravel() + { + log.Information("[DCTravel] ========================================"); + log.Information("[DCTravel] === DC TRAVEL CHECK START ==="); + log.Information("[DCTravel] ========================================"); + log.Information($"[DCTravel] Config.EnableDCTravel: {config.EnableDCTravel}"); + log.Information("[DCTravel] Config.DCTravelWorld: '" + config.DCTravelWorld + "'"); + log.Information($"[DCTravel] State.dcTravelCompleted: {dcTravelCompleted}"); + 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) + { + log.Warning("[DCTravel] SKIP: DC Travel is DISABLED in config"); + return false; + } + if (string.IsNullOrEmpty(config.DCTravelWorld)) + { + log.Warning("[DCTravel] SKIP: No target world configured"); + return false; + } + if (clientState.LocalPlayer == null) + { + log.Error("[DCTravel] SKIP: LocalPlayer is NULL"); + return false; + } + string currentWorld = clientState.LocalPlayer.CurrentWorld.Value.Name.ToString(); + log.Information("[DCTravel] Current World: '" + currentWorld + "'"); + log.Information("[DCTravel] Target World: '" + config.DCTravelWorld + "'"); + if (currentWorld.Equals(config.DCTravelWorld, StringComparison.OrdinalIgnoreCase)) + { + log.Warning("[DCTravel] SKIP: Already on target world '" + config.DCTravelWorld + "'"); + return false; + } + log.Information("[DCTravel] ========================================"); + log.Information("[DCTravel] DC TRAVEL WILL BE PERFORMED!"); + log.Information("[DCTravel] ========================================"); + return true; + } + + public async Task PerformDCTravel() + { + if (dcTravelInProgress) + { + log.Warning("[DCTravel] DC Travel already in progress"); + return false; + } + if (string.IsNullOrEmpty(config.DCTravelWorld)) + { + log.Error("[DCTravel] No target world configured"); + return false; + } + log.Information("[DCTravel] ========================================"); + log.Information("[DCTravel] === CHECKING LIFESTREAM AVAILABILITY ==="); + log.Information("[DCTravel] ========================================"); + log.Information($"[DCTravel] lifestreamIPC.IsAvailable (cached): {lifestreamIPC.IsAvailable}"); + bool isAvailable = lifestreamIPC.ForceCheckAvailability(); + log.Information($"[DCTravel] lifestreamIPC.ForceCheckAvailability() result: {isAvailable}"); + if (!isAvailable) + { + log.Error("[DCTravel] ========================================"); + log.Error("[DCTravel] ======= LIFESTREAM NOT AVAILABLE! ======"); + log.Error("[DCTravel] ========================================"); + log.Error("[DCTravel] Possible reasons:"); + log.Error("[DCTravel] 1. Lifestream plugin is not installed"); + log.Error("[DCTravel] 2. Lifestream plugin is not enabled"); + log.Error("[DCTravel] 3. Lifestream plugin failed to load"); + log.Error("[DCTravel] 4. IPC communication error"); + log.Error("[DCTravel] ========================================"); + return false; + } + log.Information("[DCTravel] Lifestream is available!"); + dcTravelInProgress = true; + try + { + log.Information("[DCTravel] === INITIATING DATA CENTER TRAVEL ==="); + log.Information("[DCTravel] Target World: " + config.DCTravelWorld); + log.Information("[DCTravel] ========================================"); + log.Information("[DCTravel] === STOPPING QUESTIONABLE ==="); + log.Information("[DCTravel] ========================================"); + log.Information($"[DCTravel] Questionable IsRunning: {questionableIPC.IsRunning()}"); + try + { + framework.RunOnFrameworkThread(delegate + { + commandManager.ProcessCommand("/qst stop"); + Thread.Sleep(1000); + }); + log.Information("[DCTravel] /qst stop command sent on Framework Thread"); + log.Information($"[DCTravel] Questionable IsRunning after stop: {questionableIPC.IsRunning()}"); + } + catch (Exception ex) + { + log.Error("[DCTravel] Error stopping Questionable: " + ex.Message); + } + log.Information("[DCTravel] Initiating travel to " + config.DCTravelWorld + "..."); + log.Information("[DCTravel] Checking Lifestream status before travel..."); + log.Information($"[DCTravel] Lifestream.IsAvailable: {lifestreamIPC.IsAvailable}"); + log.Information($"[DCTravel] Lifestream.IsBusy(): {lifestreamIPC.IsBusy()}"); + if (lifestreamIPC.IsBusy()) + { + log.Error("[DCTravel] Lifestream is BUSY! Cannot start travel!"); + framework.RunOnFrameworkThread(delegate + { + commandManager.ProcessCommand("/qst start"); + }).Wait(); + log.Information("[DCTravel] /qst start command sent on Framework Thread (recovery)"); + dcTravelInProgress = false; + return false; + } + log.Information("[DCTravel] Sending /li " + config.DCTravelWorld + " command on Framework Thread..."); + bool commandSuccess = false; + try + { + framework.RunOnFrameworkThread(delegate + { + try + { + commandManager.ProcessCommand("/li " + config.DCTravelWorld); + commandSuccess = true; + log.Information("[DCTravel] /li " + config.DCTravelWorld + " command executed on Framework Thread"); + } + catch (Exception ex5) + { + log.Error("[DCTravel] Failed to execute /li command: " + ex5.Message); + commandSuccess = false; + } + }).Wait(); + if (!commandSuccess) + { + log.Error("[DCTravel] Failed to send /li command"); + framework.RunOnFrameworkThread(delegate + { + commandManager.ProcessCommand("/qst start"); + }).Wait(); + log.Information("[DCTravel] /qst start command sent (recovery)"); + dcTravelInProgress = false; + return false; + } + Thread.Sleep(1000); + bool isBusy = lifestreamIPC.IsBusy(); + log.Information($"[DCTravel] Lifestream.IsBusy() after command: {isBusy}"); + if (!isBusy) + { + log.Warning("[DCTravel] Lifestream did not become busy after command!"); + log.Warning("[DCTravel] Travel may not have started - check Lifestream manually"); + } + } + catch (Exception ex2) + { + log.Error("[DCTravel] Error sending /li command: " + ex2.Message); + framework.RunOnFrameworkThread(delegate + { + commandManager.ProcessCommand("/qst start"); + }).Wait(); + log.Information("[DCTravel] /qst start command sent (recovery)"); + dcTravelInProgress = false; + return false; + } + log.Information("[DCTravel] Waiting for travel completion..."); + if (!(await WaitForTravelCompletion(120))) + { + log.Warning("[DCTravel] Travel timeout - proceeding anyway"); + } + log.Information("[DCTravel] ========================================"); + log.Information("[DCTravel] === RESUMING QUESTIONABLE ==="); + log.Information("[DCTravel] ========================================"); + try + { + framework.RunOnFrameworkThread(delegate + { + commandManager.ProcessCommand("/qst start"); + }).Wait(); + log.Information("[DCTravel] /qst start command sent on Framework Thread"); + Thread.Sleep(1000); + log.Information($"[DCTravel] Questionable IsRunning after start: {questionableIPC.IsRunning()}"); + } + catch (Exception ex3) + { + log.Error("[DCTravel] Error starting Questionable: " + ex3.Message); + } + dcTravelCompleted = true; + dcTravelInProgress = false; + log.Information("[DCTravel] === DATA CENTER TRAVEL COMPLETE ==="); + return true; + } + catch (Exception ex4) + { + log.Error("[DCTravel] Error during DC travel: " + ex4.Message); + try + { + framework.RunOnFrameworkThread(delegate + { + commandManager.ProcessCommand("/qst start"); + }).Wait(); + log.Information("[DCTravel] /qst start command sent on Framework Thread (error recovery)"); + } + catch + { + } + dcTravelInProgress = false; + return false; + } + } + + private async Task WaitForTravelCompletion(int timeoutSeconds) + { + DateTime startTime = DateTime.Now; + TimeSpan timeout = TimeSpan.FromSeconds(timeoutSeconds); + log.Information("[DCTravel] ========================================"); + log.Information("[DCTravel] === WAITING FOR TRAVEL TO START ==="); + log.Information("[DCTravel] ========================================"); + DateTime phase1Start = DateTime.Now; + TimeSpan phase1Timeout = TimeSpan.FromSeconds(30L); + bool travelStarted = false; + log.Information("[DCTravel] Waiting 5 seconds for Lifestream to queue travel tasks..."); + await Task.Delay(5000); + while (DateTime.Now - phase1Start < phase1Timeout) + { + try + { + if (lifestreamIPC.IsBusy()) + { + log.Information("[DCTravel] Lifestream travel has STARTED!"); + travelStarted = true; + break; + } + double elapsed = (DateTime.Now - phase1Start).TotalSeconds; + if (elapsed % 5.0 < 0.5) + { + log.Information($"[DCTravel] Waiting for travel to start... ({elapsed:F1}s)"); + } + } + catch (Exception ex) + { + log.Debug("[DCTravel] Error checking Lifestream status: " + ex.Message); + } + await Task.Delay(1000); + } + if (!travelStarted) + { + log.Error("[DCTravel] ❌ Travel did not start within 30 seconds!"); + return false; + } + log.Information("[DCTravel] ========================================"); + log.Information("[DCTravel] === WAITING FOR TRAVEL TO COMPLETE ==="); + log.Information("[DCTravel] ========================================"); + while (DateTime.Now - startTime < timeout) + { + try + { + if (!lifestreamIPC.IsBusy()) + { + log.Information("[DCTravel] Lifestream is no longer busy!"); + log.Information("[DCTravel] Waiting 30 seconds for character to stabilize..."); + await Task.Delay(30000); + log.Information("[DCTravel] Travel complete!"); + return true; + } + double elapsed2 = (DateTime.Now - startTime).TotalSeconds; + if (elapsed2 % 10.0 < 0.5) + { + log.Information($"[DCTravel] Lifestream is busy (traveling)... ({elapsed2:F0}s)"); + } + } + catch (Exception ex2) + { + log.Debug("[DCTravel] Error checking Lifestream status: " + ex2.Message); + } + await Task.Delay(1000); + } + log.Warning($"[DCTravel] Travel timeout after {timeoutSeconds}s"); + return false; + } + + public async Task ReturnToHomeworld() + { + if (!lifestreamIPC.IsAvailable) + { + log.Warning("[DCTravel] Lifestream not available for homeworld return"); + return false; + } + if (clientState.LocalPlayer == null) + { + return false; + } + string homeWorld = clientState.LocalPlayer.HomeWorld.Value.Name.ToString(); + string currentWorld = clientState.LocalPlayer.CurrentWorld.Value.Name.ToString(); + if (homeWorld.Equals(currentWorld, StringComparison.OrdinalIgnoreCase)) + { + log.Information("[DCTravel] Already on homeworld"); + return true; + } + log.Information("[DCTravel] Returning to homeworld: " + homeWorld); + bool success = lifestreamIPC.ChangeWorld(homeWorld); + if (success) + { + await Task.Delay(2000); + log.Information("[DCTravel] Homeworld return initiated"); + } + return success; + } + + public void ResetDCTravelState() + { + dcTravelCompleted = false; + dcTravelInProgress = false; + log.Information("[DCTravel] DC Travel state reset"); + } + + public bool IsDCTravelCompleted() + { + return dcTravelCompleted; + } + + public bool IsDCTravelInProgress() + { + return dcTravelInProgress; + } + + public void Dispose() + { + log.Information("[DCTravel] Service disposed"); + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/DataCenterService.cs b/QuestionableCompanion/QuestionableCompanion.Services/DataCenterService.cs new file mode 100644 index 0000000..207c195 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/DataCenterService.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Dalamud.Plugin.Services; +using Lumina.Excel; +using Lumina.Excel.Sheets; + +namespace QuestionableCompanion.Services; + +public class DataCenterService +{ + private readonly IDataManager dataManager; + + private readonly IPluginLog log; + + private readonly Dictionary worldToDCCache = new Dictionary(); + + private readonly Dictionary dataCenterToRegion = new Dictionary + { + { "Chaos", "EU" }, + { "Light", "EU" }, + { "Shadow", "EU" }, + { "Aether", "NA" }, + { "Primal", "NA" }, + { "Crystal", "NA" }, + { "Dynamis", "NA" }, + { "Elemental", "JP" }, + { "Gaia", "JP" }, + { "Mana", "JP" }, + { "Meteor", "JP" }, + { "Materia", "OCE" }, + { "陆行鸟", "Others" }, + { "莫古力", "Others" }, + { "猫小胖", "Others" }, + { "豆豆柴", "Others" } + }; + + public DataCenterService(IDataManager dataManager, IPluginLog log) + { + this.dataManager = dataManager; + this.log = log; + } + + public void InitializeWorldMapping() + { + try + { + ExcelSheet worldSheet = dataManager.GetExcelSheet(); + if (worldSheet == null) + { + return; + } + int worldCount = 0; + int skippedCount = 0; + foreach (World world in worldSheet) + { + if (world.RowId == 0) + { + continue; + } + string worldName = world.Name.ExtractText(); + if (string.IsNullOrEmpty(worldName)) + { + skippedCount++; + continue; + } + WorldDCGroupType? dataCenterGroup = world.DataCenter.ValueNullable; + if (!dataCenterGroup.HasValue) + { + skippedCount++; + continue; + } + string dataCenterName = dataCenterGroup.Value.Name.ExtractText(); + if (string.IsNullOrEmpty(dataCenterName)) + { + skippedCount++; + continue; + } + if (!world.IsPublic) + { + skippedCount++; + continue; + } + string region = GetRegionForDataCenter(dataCenterName); + worldToDCCache[worldName.ToLower()] = region; + worldCount++; + if (worldCount > 10) + { + _ = region != "Others"; + } + } + } + catch (Exception) + { + } + } + + private string GetRegionForDataCenter(string dataCenterName) + { + if (dataCenterToRegion.TryGetValue(dataCenterName, out string region)) + { + return region; + } + return "Others"; + } + + public string GetDataCenterForWorld(string worldName) + { + if (string.IsNullOrEmpty(worldName)) + { + return "Unknown"; + } + string key = worldName.ToLower(); + if (worldToDCCache.TryGetValue(key, out string dataCenter)) + { + return dataCenter; + } + return "Unknown"; + } + + public Dictionary> GroupCharactersByDataCenter(List characters) + { + Dictionary> grouped = new Dictionary> + { + { + "EU", + new List() + }, + { + "NA", + new List() + }, + { + "JP", + new List() + }, + { + "OCE", + new List() + }, + { + "Others", + new List() + }, + { + "Unknown", + new List() + } + }; + foreach (string character in characters) + { + try + { + string[] parts = character.Split('@'); + if (parts.Length != 2) + { + grouped["Unknown"].Add(character); + continue; + } + string worldName = parts[1]; + string dataCenter = GetDataCenterForWorld(worldName); + if (!grouped.ContainsKey(dataCenter)) + { + grouped[dataCenter] = new List(); + } + grouped[dataCenter].Add(character); + } + catch (Exception) + { + grouped["Unknown"].Add(character); + } + } + foreach (KeyValuePair> item in grouped.Where((KeyValuePair> g) => g.Value.Count > 0)) + { + _ = item; + } + return grouped; + } + + public List GetAvailableDataCenters(Dictionary> charactersByDataCenter) + { + List dataCenters = new List { "All" }; + string[] array = new string[6] { "EU", "NA", "JP", "OCE", "Others", "Unknown" }; + foreach (string dc in array) + { + if (charactersByDataCenter.TryGetValue(dc, out List chars) && chars.Count > 0) + { + dataCenters.Add(dc); + } + } + return dataCenters; + } + + public List GetCharactersForDataCenter(List allCharacters, string dataCenterName, Dictionary> charactersByDataCenter) + { + if (dataCenterName == "All") + { + return allCharacters; + } + if (charactersByDataCenter.TryGetValue(dataCenterName, out List characters)) + { + return characters; + } + return new List(); + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/DeathHandlerService.cs b/QuestionableCompanion/QuestionableCompanion.Services/DeathHandlerService.cs new file mode 100644 index 0000000..3771479 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/DeathHandlerService.cs @@ -0,0 +1,252 @@ +using System; +using System.Numerics; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.NativeWrapper; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Component.GUI; +using Lumina.Excel; +using Lumina.Excel.Sheets; + +namespace QuestionableCompanion.Services; + +public class DeathHandlerService : IDisposable +{ + private readonly ICondition condition; + + private readonly IPluginLog log; + + private readonly IClientState clientState; + + private readonly ICommandManager commandManager; + + private readonly IFramework framework; + + private readonly Configuration config; + + private readonly IGameGui gameGui; + + private readonly IDataManager dataManager; + + private bool wasDead; + + private Vector3 deathPosition = Vector3.Zero; + + private uint deathTerritoryId; + + private DateTime deathTime = DateTime.MinValue; + + private bool hasClickedYesNo; + + private bool needsTeleportBack; + + private bool isRotationActive; + + public bool IsDead { get; private set; } + + public TimeSpan TimeSinceDeath + { + get + { + if (!IsDead || !(deathTime != DateTime.MinValue)) + { + return TimeSpan.Zero; + } + return DateTime.Now - deathTime; + } + } + + public DeathHandlerService(ICondition condition, IPluginLog log, IClientState clientState, ICommandManager commandManager, IFramework framework, Configuration config, IGameGui gameGui, IDataManager dataManager) + { + this.condition = condition; + this.log = log; + this.clientState = clientState; + this.commandManager = commandManager; + this.framework = framework; + this.config = config; + this.gameGui = gameGui; + this.dataManager = dataManager; + log.Information("[DeathHandler] Service initialized"); + } + + public void SetRotationActive(bool active) + { + isRotationActive = active; + } + + public unsafe void Update() + { + if (clientState.LocalPlayer == null || !clientState.IsLoggedIn || !config.EnableDeathHandling) + { + return; + } + IPlayerCharacter player = clientState.LocalPlayer; + uint currentHp = player.CurrentHp; + if (currentHp == 0 && !wasDead) + { + IsDead = true; + wasDead = true; + deathTime = DateTime.Now; + deathPosition = player.Position; + deathTerritoryId = clientState.TerritoryType; + hasClickedYesNo = false; + needsTeleportBack = false; + string territoryName = GetTerritoryName(deathTerritoryId); + log.Warning("[DeathHandler] ========================================"); + log.Warning("[DeathHandler] === PLAYER DIED (0% HP) ==="); + log.Warning("[DeathHandler] ========================================"); + log.Information($"[DeathHandler] Death Position: {deathPosition}"); + log.Information($"[DeathHandler] Death Territory: {territoryName} ({deathTerritoryId})"); + } + if (IsDead && !hasClickedYesNo) + { + try + { + AtkUnitBasePtr addonPtr = gameGui.GetAddonByName("SelectYesno"); + if (addonPtr != IntPtr.Zero) + { + log.Information("[DeathHandler] SelectYesNo addon detected - clicking YES"); + AtkUnitBase* addon = (AtkUnitBase*)(nint)addonPtr; + if (addon != null && addon->IsVisible) + { + AtkValue* values = stackalloc AtkValue[1]; + *values = new AtkValue + { + Type = FFXIVClientStructs.FFXIV.Component.GUI.ValueType.Int, + Int = 0 + }; + addon->FireCallback(1u, values); + hasClickedYesNo = true; + log.Information("[DeathHandler] ✓ SelectYesNo callback fired (YES)"); + log.Information("[DeathHandler] Waiting for respawn..."); + } + } + } + catch (Exception ex) + { + log.Error("[DeathHandler] Error clicking SelectYesNo: " + ex.Message); + } + } + if (wasDead && currentHp != 0 && !player.IsDead) + { + log.Information("[DeathHandler] ========================================"); + log.Information("[DeathHandler] === PLAYER RESPAWNED ==="); + log.Information("[DeathHandler] ========================================"); + IsDead = false; + wasDead = false; + needsTeleportBack = true; + log.Information("[DeathHandler] Preparing to teleport back to death location..."); + } + if (needsTeleportBack && !IsDead && currentHp != 0 && (DateTime.Now - deathTime).TotalSeconds >= (double)config.DeathRespawnDelay) + { + TeleportBackToDeathLocation(); + needsTeleportBack = false; + } + } + + private string GetTerritoryName(uint territoryId) + { + try + { + ExcelSheet territorySheet = dataManager.GetExcelSheet(); + if (territorySheet == null) + { + return territoryId.ToString(); + } + if (!territorySheet.HasRow(territoryId)) + { + return territoryId.ToString(); + } + RowRef placeNameRef = territorySheet.GetRow(territoryId).PlaceName; + if (placeNameRef.RowId == 0) + { + return territoryId.ToString(); + } + PlaceName? placeName = placeNameRef.ValueNullable; + if (!placeName.HasValue) + { + return territoryId.ToString(); + } + string name = placeName.Value.Name.ExtractText(); + if (!string.IsNullOrEmpty(name)) + { + return name; + } + } + catch (Exception ex) + { + log.Error("[DeathHandler] Error getting territory name: " + ex.Message); + } + return territoryId.ToString(); + } + + private void TeleportBackToDeathLocation() + { + try + { + string territoryName = GetTerritoryName(deathTerritoryId); + log.Information("[DeathHandler] ========================================"); + log.Information("[DeathHandler] === TELEPORTING BACK TO DEATH LOCATION ==="); + log.Information("[DeathHandler] ========================================"); + log.Information($"[DeathHandler] Target Territory: {territoryName} ({deathTerritoryId})"); + log.Information($"[DeathHandler] Target Position: {deathPosition}"); + if (clientState.TerritoryType == deathTerritoryId) + { + log.Information("[DeathHandler] Already in death territory - using /li to teleport to position"); + framework.RunOnFrameworkThread(delegate + { + try + { + string text = $"/li {deathPosition.X:F2}, {deathPosition.Y:F2}, {deathPosition.Z:F2}"; + commandManager.ProcessCommand(text); + log.Information("[DeathHandler] ✓ Teleport to position: " + text); + } + catch (Exception ex2) + { + log.Error("[DeathHandler] Failed to send teleport command: " + ex2.Message); + } + }).Wait(); + } + else + { + log.Information("[DeathHandler] Different territory - teleporting to death territory"); + framework.RunOnFrameworkThread(delegate + { + try + { + string text = "/li " + territoryName; + commandManager.ProcessCommand(text); + log.Information("[DeathHandler] ✓ Teleport to territory: " + text); + } + catch (Exception ex2) + { + log.Error("[DeathHandler] Failed to send teleport command: " + ex2.Message); + } + }).Wait(); + } + deathPosition = Vector3.Zero; + deathTerritoryId = 0u; + deathTime = DateTime.MinValue; + log.Information("[DeathHandler] Death handling complete"); + } + catch (Exception ex) + { + log.Error("[DeathHandler] Error during teleport: " + ex.Message); + } + } + + public void Reset() + { + IsDead = false; + wasDead = false; + needsTeleportBack = false; + hasClickedYesNo = false; + deathPosition = Vector3.Zero; + deathTerritoryId = 0u; + deathTime = DateTime.MinValue; + log.Information("[DeathHandler] State reset"); + } + + public void Dispose() + { + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/DungeonAutomationService.cs b/QuestionableCompanion/QuestionableCompanion.Services/DungeonAutomationService.cs new file mode 100644 index 0000000..27cd59b --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/DungeonAutomationService.cs @@ -0,0 +1,378 @@ +using System; +using System.Threading.Tasks; +using Dalamud.Game.ClientState.Conditions; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game.Group; +using Newtonsoft.Json.Linq; + +namespace QuestionableCompanion.Services; + +public class DungeonAutomationService : IDisposable +{ + private readonly ICondition condition; + + private readonly IPluginLog log; + + private readonly IClientState clientState; + + private readonly ICommandManager commandManager; + + private readonly IFramework framework; + + private readonly IGameGui gameGui; + + private readonly Configuration config; + + private readonly HelperManager helperManager; + + private readonly MemoryHelper memoryHelper; + + private readonly QuestionableIPC questionableIPC; + + private bool isWaitingForParty; + + private DateTime partyInviteTime = DateTime.MinValue; + + private int inviteAttempts; + + private bool isInvitingHelpers; + + private DateTime helperInviteTime = DateTime.MinValue; + + private bool isInDuty; + + private bool hasStoppedAD; + + private DateTime dutyEntryTime = DateTime.MinValue; + + private bool pendingAutomationStop; + + private DateTime lastDutyExitTime = DateTime.MinValue; + + private DateTime lastDutyEntryTime = DateTime.MinValue; + + private bool expectingDutyEntry; + + private bool isAutomationActive; + + private int originalDutyMode; + + private bool hasSentAtY; + + public bool IsWaitingForParty => isWaitingForParty; + + public int CurrentPartySize { get; private set; } = 1; + + 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) + { + this.condition = condition; + this.log = log; + this.clientState = clientState; + this.commandManager = commandManager; + this.framework = framework; + this.gameGui = gameGui; + this.config = config; + this.helperManager = helperManager; + this.memoryHelper = memoryHelper; + this.questionableIPC = questionableIPC; + condition.ConditionChange += OnConditionChanged; + log.Information("[DungeonAutomation] Service initialized with ConditionChange event"); + log.Information($"[DungeonAutomation] Config - Required Party Size: {config.AutoDutyPartySize}"); + log.Information($"[DungeonAutomation] Config - Party Wait Time: {config.AutoDutyMaxWaitForParty}s"); + log.Information($"[DungeonAutomation] Config - Dungeon Automation Enabled: {config.EnableAutoDutyUnsynced}"); + SetDutyModeBasedOnConfig(); + } + + public void StartDungeonAutomation() + { + if (!isAutomationActive) + { + log.Information("[DungeonAutomation] ========================================"); + log.Information("[DungeonAutomation] === STARTING DUNGEON AUTOMATION ==="); + log.Information("[DungeonAutomation] ========================================"); + isAutomationActive = true; + expectingDutyEntry = true; + log.Information("[DungeonAutomation] Inviting helpers via HelperManager..."); + helperManager.InviteHelpers(); + isInvitingHelpers = true; + helperInviteTime = DateTime.Now; + inviteAttempts = 0; + } + } + + public void SetDutyModeBasedOnConfig() + { + if (config.EnableAutoDutyUnsynced) + { + questionableIPC.SetDefaultDutyMode(2); + log.Information("[DungeonAutomation] Set Duty Mode to Unsync Party (2) - Automation Enabled"); + } + else + { + questionableIPC.SetDefaultDutyMode(0); + log.Information("[DungeonAutomation] Set Duty Mode to Support (0) - Automation Disabled"); + } + } + + public void StopDungeonAutomation() + { + if (isAutomationActive) + { + log.Information("[DungeonAutomation] ========================================"); + log.Information("[DungeonAutomation] === STOPPING DUNGEON AUTOMATION ==="); + log.Information("[DungeonAutomation] ========================================"); + isAutomationActive = false; + Reset(); + } + } + + private void UpdateHelperInvite() + { + double timeSinceInvite = (DateTime.Now - helperInviteTime).TotalSeconds; + try + { + if (timeSinceInvite >= 2.0) + { + isInvitingHelpers = false; + isWaitingForParty = true; + partyInviteTime = DateTime.Now; + log.Information("[DungeonAutomation] Helper invites sent, waiting for party..."); + } + } + catch (Exception ex) + { + log.Error("[DungeonAutomation] Error in helper invite: " + ex.Message); + isInvitingHelpers = false; + } + } + + public void Update() + { + if (config.EnableAutoDutyUnsynced && !isAutomationActive) + { + CheckWaitForPartyTask(); + } + if (!hasStoppedAD && dutyEntryTime != DateTime.MinValue && (DateTime.Now - dutyEntryTime).TotalSeconds >= 1.0) + { + try + { + commandManager.ProcessCommand("/ad stop"); + log.Information("[DungeonAutomation] /ad stop (1s after duty entry)"); + hasStoppedAD = true; + dutyEntryTime = DateTime.MinValue; + } + catch (Exception ex) + { + log.Error("[DungeonAutomation] Failed to stop AD: " + ex.Message); + } + } + if (isInvitingHelpers) + { + UpdateHelperInvite(); + } + else if (pendingAutomationStop && (DateTime.Now - dutyEntryTime).TotalSeconds >= 5.0) + { + log.Information("[DungeonAutomation] 5s delay complete - stopping automation now"); + StopDungeonAutomation(); + pendingAutomationStop = false; + } + else if (isWaitingForParty) + { + UpdatePartySize(); + if (CurrentPartySize >= config.AutoDutyPartySize) + { + log.Information("[DungeonAutomation] ========================================"); + log.Information("[DungeonAutomation] === PARTY FULL ==="); + log.Information("[DungeonAutomation] ========================================"); + log.Information($"[DungeonAutomation] Party Size: {CurrentPartySize}/{config.AutoDutyPartySize}"); + isWaitingForParty = false; + partyInviteTime = DateTime.MinValue; + inviteAttempts = 0; + log.Information("[DungeonAutomation] Party full - ready for dungeon!"); + } + else if ((DateTime.Now - partyInviteTime).TotalSeconds >= (double)config.AutoDutyMaxWaitForParty) + { + log.Warning($"[DungeonAutomation] Party not full after {config.AutoDutyMaxWaitForParty}s - retrying invite (Attempt #{inviteAttempts + 1})"); + log.Information($"[DungeonAutomation] Current Party Size: {CurrentPartySize}/{config.AutoDutyPartySize}"); + log.Information("[DungeonAutomation] Retrying helper invites..."); + helperManager.InviteHelpers(); + partyInviteTime = DateTime.Now; + } + } + } + + private void CheckWaitForPartyTask() + { + if (questionableIPC.GetCurrentTask() is JObject jObject) + { + JToken taskNameToken = jObject["TaskName"]; + if (taskNameToken != null && taskNameToken.ToString() == "WaitForParty") + { + StartDungeonAutomation(); + } + } + } + + private unsafe void UpdatePartySize() + { + try + { + int partySize = 0; + GroupManager* groupManager = GroupManager.Instance(); + if (groupManager != null) + { + GroupManager.Group* group = groupManager->GetGroup(); + if (group != null) + { + partySize = group->MemberCount; + } + } + if (partySize == 0) + { + partySize = 1; + } + if (partySize != CurrentPartySize) + { + CurrentPartySize = partySize; + log.Information($"[DungeonAutomation] Party Size updated: {CurrentPartySize}/{config.AutoDutyPartySize}"); + } + } + catch (Exception ex) + { + log.Error("[DungeonAutomation] Error updating party size: " + ex.Message); + } + } + + private void OnConditionChanged(ConditionFlag flag, bool value) + { + if (flag == ConditionFlag.BoundByDuty) + { + if (value && !isInDuty) + { + isInDuty = true; + OnDutyEntered(); + } + else if (!value && isInDuty) + { + isInDuty = false; + OnDutyExited(); + } + } + } + + public void OnDutyEntered() + { + if ((DateTime.Now - lastDutyEntryTime).TotalSeconds < 5.0) + { + log.Debug("[DungeonAutomation] OnDutyEntered called too soon - ignoring spam"); + return; + } + lastDutyEntryTime = DateTime.Now; + log.Information("[DungeonAutomation] Entered duty"); + if (expectingDutyEntry) + { + log.Information("[DungeonAutomation] Duty started by DungeonAutomation - enabling automation commands"); + expectingDutyEntry = false; + hasStoppedAD = false; + dutyEntryTime = DateTime.Now; + if (!hasSentAtY) + { + commandManager.ProcessCommand("/at y"); + log.Information("[DungeonAutomation] Sent /at y (duty entered)"); + hasSentAtY = true; + } + } + else + { + log.Information("[DungeonAutomation] Duty NOT started by DungeonAutomation (Solo Duty/Quest Battle) - skipping automation commands"); + } + } + + public void OnDutyExited() + { + if ((DateTime.Now - lastDutyExitTime).TotalSeconds < 2.0) + { + log.Debug("[DungeonAutomation] OnDutyExited called too soon - ignoring spam"); + return; + } + lastDutyExitTime = DateTime.Now; + log.Information("[DungeonAutomation] Exited duty"); + if (isAutomationActive) + { + commandManager.ProcessCommand("/at n"); + log.Information("[DungeonAutomation] Sent /at n (duty exited)"); + hasSentAtY = false; + log.Information("[DungeonAutomation] Waiting 8s, then disband + restart quest"); + Task.Run(async delegate + { + await EnsureSoloPartyAsync(); + }); + StopDungeonAutomation(); + } + else + { + log.Information("[DungeonAutomation] Exited non-automated duty - no cleanup needed"); + } + } + + private async Task EnsureSoloPartyAsync() + { + TimeSpan timeout = TimeSpan.FromSeconds(60L); + DateTime start = DateTime.Now; + while (CurrentPartySize > 1 && DateTime.Now - start < timeout) + { + await framework.RunOnFrameworkThread(delegate + { + commandManager.ProcessCommand("/leave"); + }); + log.Information("[DungeonAutomation] Forced /leave sent, rechecking party size..."); + await Task.Delay(1500); + UpdatePartySize(); + } + if (CurrentPartySize > 1) + { + log.Warning("[DungeonAutomation] Still not solo after leave spam!"); + } + else + { + log.Information("[DungeonAutomation] Party reduced to solo after duty exit."); + } + } + + public void DisbandParty() + { + try + { + log.Information("[DungeonAutomation] Disbanding party"); + framework.RunOnFrameworkThread(delegate + { + memoryHelper.SendChatMessage("/leave"); + log.Information("[DungeonAutomation] /leave command sent via UIModule"); + }); + } + catch (Exception ex) + { + log.Error("[DungeonAutomation] Failed to disband party: " + ex.Message); + } + } + + public void Reset() + { + isWaitingForParty = false; + partyInviteTime = DateTime.MinValue; + inviteAttempts = 0; + CurrentPartySize = 1; + isInvitingHelpers = false; + helperInviteTime = DateTime.MinValue; + isAutomationActive = false; + log.Information("[DungeonAutomation] State reset"); + } + + public void Dispose() + { + Reset(); + condition.ConditionChange -= OnConditionChanged; + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/EventQuestExecutionService.cs b/QuestionableCompanion/QuestionableCompanion.Services/EventQuestExecutionService.cs new file mode 100644 index 0000000..4008d7f --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/EventQuestExecutionService.cs @@ -0,0 +1,609 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Dalamud.Game.ClientState.Conditions; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game; +using Newtonsoft.Json.Linq; + +namespace QuestionableCompanion.Services; + +public class EventQuestExecutionService : IDisposable +{ + private readonly AutoRetainerIPC autoRetainerIpc; + + private readonly QuestionableIPC questionableIPC; + + private readonly IPluginLog log; + + private readonly IFramework framework; + + private readonly ICommandManager commandManager; + + private readonly ICondition condition; + + private readonly Configuration configuration; + + private readonly EventQuestResolver eventQuestResolver; + + private EventQuestState currentState = new EventQuestState(); + + private Dictionary> eventQuestCompletionByCharacter = new Dictionary>(); + + private DateTime lastCheckTime = DateTime.MinValue; + + private const double CheckIntervalMs = 250.0; + + private bool isRotationActive; + + private string? lastTerritoryWaitDetected; + + private DateTime lastTerritoryTeleportTime = DateTime.MinValue; + + private Action? onDataChanged; + + public bool IsRotationActive => isRotationActive; + + public EventQuestExecutionService(AutoRetainerIPC autoRetainerIpc, QuestionableIPC questionableIPC, IPluginLog log, IFramework framework, ICommandManager commandManager, ICondition condition, Configuration configuration, IDataManager dataManager, Action? onDataChanged = null) + { + this.autoRetainerIpc = autoRetainerIpc; + this.questionableIPC = questionableIPC; + this.log = log; + this.framework = framework; + this.commandManager = commandManager; + this.condition = condition; + this.configuration = configuration; + this.onDataChanged = onDataChanged; + eventQuestResolver = new EventQuestResolver(dataManager, log); + framework.Update += OnFrameworkUpdate; + log.Information("[EventQuest] Service initialized"); + } + + public bool StartEventQuestRotation(string eventQuestId, List characters) + { + if (characters == null || characters.Count == 0) + { + log.Error("[EventQuest] Cannot start rotation: No characters selected"); + return false; + } + if (string.IsNullOrEmpty(eventQuestId)) + { + log.Error("[EventQuest] Cannot start rotation: Event Quest ID is empty"); + return false; + } + List dependencies = eventQuestResolver.ResolveEventQuestDependencies(eventQuestId); + List remainingChars = new List(); + List completedChars = new List(); + foreach (string character in characters) + { + if (HasCharacterCompletedEventQuest(eventQuestId, character)) + { + completedChars.Add(character); + log.Debug("[EventQuest] " + character + " already completed event quest " + eventQuestId); + } + else + { + remainingChars.Add(character); + log.Debug("[EventQuest] " + character + " needs to complete event quest " + eventQuestId); + } + } + if (remainingChars.Count == 0) + { + log.Information("[EventQuest] All characters have already completed event quest " + eventQuestId); + return false; + } + string currentLoggedInChar = autoRetainerIpc.GetCurrentCharacter(); + bool isAlreadyLoggedIn = !string.IsNullOrEmpty(currentLoggedInChar) && remainingChars.Contains(currentLoggedInChar); + currentState = new EventQuestState + { + EventQuestId = eventQuestId, + EventQuestName = eventQuestResolver.GetQuestName(eventQuestId), + SelectedCharacters = new List(characters), + RemainingCharacters = remainingChars, + CompletedCharacters = completedChars, + DependencyQuests = dependencies, + Phase = ((!isAlreadyLoggedIn) ? EventQuestPhase.InitializingFirstCharacter : EventQuestPhase.CheckingQuestCompletion), + CurrentCharacter = (isAlreadyLoggedIn ? currentLoggedInChar : ""), + PhaseStartTime = DateTime.Now, + RotationStartTime = DateTime.Now + }; + isRotationActive = true; + log.Information("[EventQuest] ═══ Starting Event Quest Rotation ═══"); + log.Information($"[EventQuest] Event Quest: {currentState.EventQuestName} ({eventQuestId})"); + log.Information($"[EventQuest] Total Characters: {characters.Count}"); + log.Information($"[EventQuest] Remaining: {remainingChars.Count} | Completed: {completedChars.Count}"); + log.Information($"[EventQuest] Dependencies to resolve: {dependencies.Count}"); + if (dependencies.Count > 0) + { + log.Information("[EventQuest] Prerequisites: " + string.Join(", ", dependencies.Select((string id) => eventQuestResolver.GetQuestName(id)))); + } + if (isAlreadyLoggedIn) + { + log.Information("[EventQuest] User already logged in as " + currentLoggedInChar + " - starting immediately"); + } + return true; + } + + public EventQuestState GetCurrentState() + { + return currentState; + } + + public void LoadEventQuestCompletionData(Dictionary> data) + { + if (data != null && data.Count > 0) + { + eventQuestCompletionByCharacter = new Dictionary>(data); + log.Information($"[EventQuest] Loaded completion data for {data.Count} event quests"); + } + } + + public Dictionary> GetEventQuestCompletionData() + { + return new Dictionary>(eventQuestCompletionByCharacter); + } + + public void AbortRotation() + { + log.Information("[EventQuest] Aborting Event Quest rotation"); + currentState = new EventQuestState + { + Phase = EventQuestPhase.Idle + }; + isRotationActive = false; + } + + private void MarkEventQuestCompleted(string eventQuestId, string characterName) + { + if (!eventQuestCompletionByCharacter.ContainsKey(eventQuestId)) + { + eventQuestCompletionByCharacter[eventQuestId] = new List(); + } + if (!eventQuestCompletionByCharacter[eventQuestId].Contains(characterName)) + { + eventQuestCompletionByCharacter[eventQuestId].Add(characterName); + log.Debug("[EventQuest] Marked " + characterName + " as completed event quest " + eventQuestId); + onDataChanged?.Invoke(); + } + } + + private bool HasCharacterCompletedEventQuest(string eventQuestId, string characterName) + { + if (eventQuestCompletionByCharacter.TryGetValue(eventQuestId, out List characters)) + { + return characters.Contains(characterName); + } + return false; + } + + private void OnFrameworkUpdate(IFramework framework) + { + if (!isRotationActive) + { + return; + } + DateTime now = DateTime.Now; + if (!((now - lastCheckTime).TotalMilliseconds < 250.0)) + { + lastCheckTime = now; + CheckForTerritoryWait(); + switch (currentState.Phase) + { + case EventQuestPhase.InitializingFirstCharacter: + HandleInitializingFirstCharacter(); + break; + case EventQuestPhase.WaitingForCharacterLogin: + HandleWaitingForCharacterLogin(); + break; + case EventQuestPhase.CheckingQuestCompletion: + HandleCheckingQuestCompletion(); + break; + case EventQuestPhase.ResolvingDependencies: + HandleResolvingDependencies(); + break; + case EventQuestPhase.ExecutingDependencies: + HandleExecutingDependencies(); + break; + case EventQuestPhase.WaitingForQuestStart: + case EventQuestPhase.QuestActive: + HandleQuestMonitoring(); + break; + case EventQuestPhase.WaitingBeforeCharacterSwitch: + HandleWaitingBeforeCharacterSwitch(); + break; + case EventQuestPhase.Completed: + HandleCompleted(); + break; + } + } + } + + private void CheckForTerritoryWait() + { + if (!questionableIPC.IsRunning()) + { + return; + } + object task = questionableIPC.GetCurrentTask(); + if (task == null) + { + return; + } + try + { + if (!(task is JObject jObject)) + { + return; + } + JToken taskNameToken = jObject["TaskName"]; + if (taskNameToken == null) + { + return; + } + string taskName = taskNameToken.ToString(); + if (string.IsNullOrEmpty(taskName)) + { + return; + } + Match waitTerritoryMatch = new Regex("Wait\\(territory:\\s*(.+?)\\s*\\((\\d+)\\)\\)").Match(taskName); + if (!waitTerritoryMatch.Success) + { + return; + } + string territoryName = waitTerritoryMatch.Groups[1].Value.Trim(); + uint territoryId = uint.Parse(waitTerritoryMatch.Groups[2].Value); + string territoryKey = $"{territoryName}_{territoryId}"; + double timeSinceLastTeleport = (DateTime.Now - lastTerritoryTeleportTime).TotalSeconds; + if (lastTerritoryWaitDetected == territoryKey && timeSinceLastTeleport < 60.0) + { + return; + } + log.Information($"[EventQuest] Wait(territory) detected: {territoryName} (ID: {territoryId})"); + log.Information("[EventQuest] Auto-teleporting via Lifestream..."); + lastTerritoryWaitDetected = territoryKey; + lastTerritoryTeleportTime = DateTime.Now; + framework.RunOnFrameworkThread(delegate + { + try + { + string text = "/li " + territoryName; + commandManager.ProcessCommand(text); + log.Information("[EventQuest] Sent teleport command: " + text); + } + catch (Exception ex2) + { + log.Error("[EventQuest] Failed to teleport to " + territoryName + ": " + ex2.Message); + } + }); + } + catch (Exception ex) + { + log.Error("[EventQuest] Error checking Wait(territory) task: " + ex.Message); + } + } + + private void HandleInitializingFirstCharacter() + { + if (currentState.RemainingCharacters.Count == 0) + { + log.Information("[EventQuest] No remaining characters - rotation complete"); + currentState.Phase = EventQuestPhase.Completed; + isRotationActive = false; + return; + } + string firstChar = currentState.RemainingCharacters[0]; + currentState.CurrentCharacter = firstChar; + log.Information("[EventQuest] >>> Initializing first character: " + firstChar); + if (autoRetainerIpc.SwitchCharacter(firstChar)) + { + currentState.Phase = EventQuestPhase.WaitingForCharacterLogin; + currentState.PhaseStartTime = DateTime.Now; + log.Information("[EventQuest] Character switch initiated to " + firstChar); + } + else + { + log.Error("[EventQuest] Failed to switch to " + firstChar); + currentState.Phase = EventQuestPhase.Error; + currentState.ErrorMessage = "Failed to switch to " + firstChar; + } + } + + private void HandleWaitingForCharacterLogin() + { + if ((DateTime.Now - currentState.PhaseStartTime).TotalSeconds > 60.0) + { + log.Error("[EventQuest] Login timeout for " + currentState.CurrentCharacter); + SkipToNextCharacter(); + return; + } + string currentLoggedInChar = autoRetainerIpc.GetCurrentCharacter(); + if (!string.IsNullOrEmpty(currentLoggedInChar) && currentLoggedInChar == currentState.CurrentCharacter && !((DateTime.Now - currentState.PhaseStartTime).TotalSeconds < 5.0)) + { + log.Information("[EventQuest] Successfully logged in as " + currentLoggedInChar); + currentState.Phase = EventQuestPhase.CheckingQuestCompletion; + currentState.PhaseStartTime = DateTime.Now; + } + } + + private void HandleCheckingQuestCompletion() + { + string eventQuestId = currentState.EventQuestId; + string rawId = QuestIdParser.ParseQuestId(eventQuestId).rawId; + QuestIdType questType = QuestIdParser.ClassifyQuestId(eventQuestId); + log.Debug($"[EventQuest] Checking completion for {eventQuestId} (Type: {questType}, RawId: {rawId})"); + if (!uint.TryParse(rawId, out var questIdUint)) + { + log.Error($"[EventQuest] Invalid quest ID: {eventQuestId} (cannot parse numeric part: {rawId})"); + SkipToNextCharacter(); + return; + } + bool isQuestComplete = false; + try + { + isQuestComplete = QuestManager.IsQuestComplete(questIdUint); + } + catch (Exception ex) + { + log.Error("[EventQuest] Error checking quest completion: " + ex.Message); + } + if (isQuestComplete) + { + log.Information("[EventQuest] " + currentState.CurrentCharacter + " already completed event quest " + eventQuestId); + List completedList = currentState.CompletedCharacters; + if (!completedList.Contains(currentState.CurrentCharacter)) + { + completedList.Add(currentState.CurrentCharacter); + currentState.CompletedCharacters = completedList; + } + MarkEventQuestCompleted(eventQuestId, currentState.CurrentCharacter); + SkipToNextCharacter(); + } + else + { + log.Information("[EventQuest] " + currentState.CurrentCharacter + " needs to complete event quest " + eventQuestId); + log.Information($"[EventQuest] >>> Starting event quest with {currentState.DependencyQuests.Count} prerequisites"); + StartEventQuest(); + } + } + + private void HandleResolvingDependencies() + { + log.Information("[EventQuest] All prerequisites completed - starting event quest"); + StartEventQuest(); + } + + private void HandleExecutingDependencies() + { + string depQuestId = currentState.CurrentExecutingQuest; + if (!uint.TryParse(depQuestId, out var questIdUint)) + { + log.Error("[EventQuest] Invalid dependency quest ID: " + depQuestId); + currentState.DependencyIndex++; + currentState.Phase = EventQuestPhase.ResolvingDependencies; + return; + } + bool isDependencyComplete = false; + try + { + isDependencyComplete = QuestManager.IsQuestComplete(questIdUint); + } + catch + { + } + if (isDependencyComplete) + { + log.Information("[EventQuest] Dependency " + eventQuestResolver.GetQuestName(depQuestId) + " already completed"); + currentState.DependencyIndex++; + currentState.Phase = EventQuestPhase.ResolvingDependencies; + return; + } + try + { + commandManager.ProcessCommand("/qst start"); + log.Information("[EventQuest] Started dependency quest: " + eventQuestResolver.GetQuestName(depQuestId)); + } + catch (Exception ex) + { + log.Error("[EventQuest] Failed to start dependency: " + ex.Message); + } + currentState.Phase = EventQuestPhase.QuestActive; + currentState.HasEventQuestBeenAccepted = false; + currentState.PhaseStartTime = DateTime.Now; + } + + private void HandleQuestMonitoring() + { + string eventQuestId = currentState.EventQuestId; + try + { + if (questionableIPC.IsQuestComplete(eventQuestId)) + { + log.Information("[EventQuest] Event quest " + eventQuestId + " completed by " + currentState.CurrentCharacter); + MarkEventQuestCompleted(currentState.EventQuestId, currentState.CurrentCharacter); + List completedList = currentState.CompletedCharacters; + if (!completedList.Contains(currentState.CurrentCharacter)) + { + completedList.Add(currentState.CurrentCharacter); + currentState.CompletedCharacters = completedList; + } + try + { + commandManager.ProcessCommand("/qst stop"); + log.Information("[EventQuest] Sent /qst stop"); + } + catch + { + } + currentState.Phase = EventQuestPhase.WaitingBeforeCharacterSwitch; + currentState.PhaseStartTime = DateTime.Now; + } + } + catch (Exception ex) + { + log.Error("[EventQuest] Error checking quest completion via IPC: " + ex.Message); + } + } + + private void HandleWaitingBeforeCharacterSwitch() + { + if (!condition[ConditionFlag.BetweenAreas] && (DateTime.Now - currentState.PhaseStartTime).TotalSeconds >= 2.0) + { + PerformCharacterSwitch(); + } + } + + private void HandleCompleted() + { + log.Information("[EventQuest] ═══ EVENT QUEST ROTATION COMPLETED ═══"); + log.Information($"[EventQuest] All {currentState.CompletedCharacters.Count} characters completed the event quest"); + if (questionableIPC.IsAvailable) + { + try + { + questionableIPC.ClearQuestPriority(); + log.Information("[EventQuest] Cleared quest priority queue after completion"); + } + catch (Exception ex) + { + log.Warning("[EventQuest] Failed to clear quest priority: " + ex.Message); + } + } + isRotationActive = false; + currentState.Phase = EventQuestPhase.Idle; + } + + private void StartEventQuest() + { + List allQuests = new List(); + if (currentState.DependencyQuests.Count > 0) + { + foreach (string dep in currentState.DependencyQuests) + { + allQuests.Add(dep); + QuestIdType questType = QuestIdParser.ClassifyQuestId(dep); + log.Information($"[EventQuest] Adding dependency: {dep} (Type: {questType})"); + } + } + string mainQuestId = currentState.EventQuestId; + allQuests.Add(mainQuestId); + QuestIdType mainQuestType = QuestIdParser.ClassifyQuestId(mainQuestId); + log.Information($"[EventQuest] Adding main event quest: {mainQuestId} (Type: {mainQuestType})"); + log.Information($"[EventQuest] Setting {allQuests.Count} quests as Questionable priority"); + if (questionableIPC.IsAvailable) + { + try + { + questionableIPC.ClearQuestPriority(); + log.Information("[EventQuest] Cleared existing quest priority queue"); + } + catch (Exception ex) + { + log.Warning("[EventQuest] Failed to clear quest priority: " + ex.Message); + } + foreach (string questId in allQuests) + { + try + { + bool result = questionableIPC.AddQuestPriority(questId); + log.Information($"[EventQuest] Added quest {questId} to priority: {result}"); + } + catch (Exception ex2) + { + log.Warning("[EventQuest] Failed to add quest " + questId + " to priority: " + ex2.Message); + } + } + } + else + { + log.Warning("[EventQuest] Questionable IPC not available - cannot set priority"); + } + if (condition[ConditionFlag.BetweenAreas]) + { + log.Debug("[EventQuest] Character is between areas - waiting before starting quest"); + return; + } + if (questionableIPC.IsAvailable && questionableIPC.IsRunning()) + { + log.Debug("[EventQuest] Questionable is busy - waiting before starting quest"); + return; + } + try + { + commandManager.ProcessCommand("/qst start"); + log.Information("[EventQuest] Sent /qst start for event quest"); + currentState.Phase = EventQuestPhase.QuestActive; + currentState.CurrentExecutingQuest = currentState.EventQuestId; + currentState.HasEventQuestBeenAccepted = false; + currentState.PhaseStartTime = DateTime.Now; + } + catch (Exception ex3) + { + log.Error("[EventQuest] Failed to start quest: " + ex3.Message); + } + } + + private void SkipToNextCharacter() + { + try + { + commandManager.ProcessCommand("/qst stop"); + log.Information("[EventQuest] Sent /qst stop before character switch"); + } + catch + { + } + List remainingList = currentState.RemainingCharacters; + List completedList = currentState.CompletedCharacters; + if (remainingList.Contains(currentState.CurrentCharacter)) + { + remainingList.Remove(currentState.CurrentCharacter); + currentState.RemainingCharacters = remainingList; + } + if (!completedList.Contains(currentState.CurrentCharacter)) + { + completedList.Add(currentState.CurrentCharacter); + currentState.CompletedCharacters = completedList; + log.Information("[EventQuest] Character " + currentState.CurrentCharacter + " marked as completed (skipped)"); + } + currentState.Phase = EventQuestPhase.WaitingBeforeCharacterSwitch; + currentState.PhaseStartTime = DateTime.Now; + } + + private void PerformCharacterSwitch() + { + List remainingList = currentState.RemainingCharacters; + if (remainingList.Contains(currentState.CurrentCharacter)) + { + remainingList.Remove(currentState.CurrentCharacter); + currentState.RemainingCharacters = remainingList; + } + if (currentState.RemainingCharacters.Count == 0) + { + currentState.Phase = EventQuestPhase.Completed; + return; + } + string nextChar = currentState.RemainingCharacters[0]; + currentState.CurrentCharacter = nextChar; + currentState.NextCharacter = nextChar; + log.Information("[EventQuest] Switching to next character: " + nextChar); + log.Information($"[EventQuest] Progress: {currentState.CompletedCharacters.Count}/{currentState.SelectedCharacters.Count} completed"); + if (autoRetainerIpc.SwitchCharacter(nextChar)) + { + currentState.Phase = EventQuestPhase.WaitingForCharacterLogin; + currentState.PhaseStartTime = DateTime.Now; + } + else + { + log.Error("[EventQuest] Failed to switch to " + nextChar); + currentState.Phase = EventQuestPhase.Error; + currentState.ErrorMessage = "Failed to switch character"; + } + } + + public void Dispose() + { + framework.Update -= OnFrameworkUpdate; + log.Information("[EventQuest] Service disposed"); + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/EventQuestPhase.cs b/QuestionableCompanion/QuestionableCompanion.Services/EventQuestPhase.cs new file mode 100644 index 0000000..735a2d7 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/EventQuestPhase.cs @@ -0,0 +1,16 @@ +namespace QuestionableCompanion.Services; + +public enum EventQuestPhase +{ + Idle, + InitializingFirstCharacter, + WaitingForCharacterLogin, + CheckingQuestCompletion, + ResolvingDependencies, + ExecutingDependencies, + WaitingForQuestStart, + QuestActive, + WaitingBeforeCharacterSwitch, + Completed, + Error +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/EventQuestResolver.cs b/QuestionableCompanion/QuestionableCompanion.Services/EventQuestResolver.cs new file mode 100644 index 0000000..21824fd --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/EventQuestResolver.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Dalamud.Plugin.Services; +using Lumina.Excel; +using Lumina.Excel.Sheets; + +namespace QuestionableCompanion.Services; + +public class EventQuestResolver +{ + private readonly IDataManager dataManager; + + private readonly IPluginLog log; + + public EventQuestResolver(IDataManager dataManager, IPluginLog log) + { + this.dataManager = dataManager; + this.log = log; + } + + public List ResolveEventQuestDependencies(string eventQuestId) + { + List dependencies = new List(); + ExcelSheet questSheet = dataManager.GetExcelSheet(); + log.Information("[EventQuestResolver] Searching for quest with ID string: '" + eventQuestId + "'"); + Quest? foundQuest = null; + foreach (Quest q in questSheet) + { + if (q.RowId != 0) + { + string questIdField = q.Id.ExtractText(); + if (questIdField == eventQuestId || questIdField.EndsWith("_" + eventQuestId) || questIdField.EndsWith("_" + eventQuestId.PadLeft(5, '0'))) + { + foundQuest = q; + log.Information($"[EventQuestResolver] Found quest by ID field: '{questIdField}' (searched for '{eventQuestId}')"); + break; + } + } + } + if (!foundQuest.HasValue || foundQuest.Value.RowId == 0) + { + log.Error("[EventQuestResolver] Quest with ID '" + eventQuestId + "' not found in Lumina"); + return dependencies; + } + Quest quest = foundQuest.Value; + string questName = quest.Name.ExtractText(); + log.Information($"[EventQuestResolver] Found quest: RowId={quest.RowId}, Name='{questName}', ID='{quest.Id.ExtractText()}'"); + try + { + foreach (RowRef prevQuestRef in quest.PreviousQuest) + { + if (prevQuestRef.RowId == 0) + { + continue; + } + Quest prevQuest = questSheet.GetRow(prevQuestRef.RowId); + if (prevQuest.RowId != 0) + { + string prevQuestName = prevQuest.Name.ExtractText(); + string prevQuestIdString = prevQuest.Id.ExtractText(); + string[] idParts = prevQuestIdString.Split('_'); + string questIdNumber = ((idParts.Length > 1) ? idParts[1].TrimStart('0') : prevQuestIdString); + if (string.IsNullOrEmpty(questIdNumber)) + { + questIdNumber = "0"; + } + dependencies.Add(questIdNumber); + log.Information($"[EventQuestResolver] Found previous quest: RowId={prevQuestRef.RowId}, Name='{prevQuestName}', ID='{prevQuestIdString}' -> '{questIdNumber}'"); + } + } + } + catch (Exception ex) + { + log.Warning("[EventQuestResolver] Error reading PreviousQuest: " + ex.Message); + } + try + { + foreach (RowRef questLockRef in quest.QuestLock) + { + if (questLockRef.RowId == 0) + { + continue; + } + Quest lockQuest = questSheet.GetRow(questLockRef.RowId); + if (lockQuest.RowId != 0) + { + string lockQuestIdString = lockQuest.Id.ExtractText(); + string[] idParts2 = lockQuestIdString.Split('_'); + string questIdNumber2 = ((idParts2.Length > 1) ? idParts2[1].TrimStart('0') : lockQuestIdString); + if (string.IsNullOrEmpty(questIdNumber2)) + { + questIdNumber2 = "0"; + } + dependencies.Add(questIdNumber2); + log.Information($"[EventQuestResolver] Found quest lock: RowId={questLockRef.RowId}, ID='{lockQuestIdString}' -> '{questIdNumber2}'"); + } + } + } + catch (Exception ex2) + { + log.Warning("[EventQuestResolver] Error reading QuestLock: " + ex2.Message); + } + dependencies = dependencies.Distinct().ToList(); + log.Information($"[EventQuestResolver] Found {dependencies.Count} direct prerequisites"); + if (dependencies.Count > 0) + { + log.Information("[EventQuestResolver] Event Quest " + eventQuestId + " requires: " + string.Join(", ", dependencies)); + } + else + { + log.Information("[EventQuestResolver] Event Quest " + eventQuestId + " has no prerequisites"); + } + return dependencies; + } + + public bool IsValidQuest(string questId, out string classification) + { + string rawId = QuestIdParser.ParseQuestId(questId).rawId; + if (QuestIdParser.ClassifyQuestId(questId) == QuestIdType.EventQuest) + { + classification = "EventQuest"; + log.Debug("[EventQuestResolver] Quest " + questId + " recognized as Event Quest (prefix detected)"); + return true; + } + if (!uint.TryParse(rawId, out var questIdUint)) + { + classification = "Invalid"; + return false; + } + try + { + Quest quest = dataManager.GetExcelSheet().GetRow(questIdUint); + if (quest.RowId != 0) + { + classification = "Standard"; + log.Debug($"[EventQuestResolver] Quest {questId} found in Excel Sheet (RowId: {quest.RowId})"); + return true; + } + classification = "NotFound"; + return false; + } + catch (Exception ex) + { + log.Debug("[EventQuestResolver] Error checking quest availability: " + ex.Message); + classification = "Error"; + return false; + } + } + + public bool IsQuestAvailable(string questId) + { + string classification; + return IsValidQuest(questId, out classification); + } + + public string GetQuestName(string questId) + { + string rawId = QuestIdParser.ParseQuestId(questId).rawId; + QuestIdType questType = QuestIdParser.ClassifyQuestId(questId); + if (!uint.TryParse(rawId, out var questIdUint)) + { + if (questType == QuestIdType.EventQuest) + { + return "Event Quest " + questId; + } + return "Unknown Quest (" + questId + ")"; + } + try + { + Quest quest = dataManager.GetExcelSheet().GetRow(questIdUint); + if (quest.RowId != 0) + { + string name = quest.Name.ExtractText(); + if (!string.IsNullOrEmpty(name)) + { + if (questType == QuestIdType.EventQuest) + { + return name + " (" + questId + ")"; + } + return name; + } + } + if (questType == QuestIdType.EventQuest) + { + return "Event Quest " + questId; + } + return "Quest " + questId; + } + catch (Exception) + { + if (questType == QuestIdType.EventQuest) + { + return "Event Quest " + questId; + } + return "Quest " + questId; + } + } + + public List<(string QuestId, string QuestName)> GetAvailableEventQuests() + { + List<(string, string)> eventQuests = new List<(string, string)>(); + try + { + ExcelSheet questSheet = dataManager.GetExcelSheet(); + if (questSheet == null) + { + log.Error("[EventQuestResolver] Failed to load Quest sheet"); + return eventQuests; + } + foreach (Quest quest in questSheet) + { + if (quest.RowId != 0 && quest.JournalGenre.RowId == 9) + { + string questName = quest.Name.ExtractText(); + if (!string.IsNullOrEmpty(questName)) + { + eventQuests.Add((quest.RowId.ToString(), questName)); + } + } + } + log.Information($"[EventQuestResolver] Found {eventQuests.Count} event quests"); + } + catch (Exception ex) + { + log.Error("[EventQuestResolver] Error getting event quests: " + ex.Message); + } + return eventQuests.OrderBy(((string, string) q) => q.Item2).ToList(); + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/EventQuestState.cs b/QuestionableCompanion/QuestionableCompanion.Services/EventQuestState.cs new file mode 100644 index 0000000..a27c5f7 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/EventQuestState.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; + +namespace QuestionableCompanion.Services; + +public class EventQuestState +{ + public EventQuestPhase Phase { get; set; } + + public string EventQuestId { get; set; } = string.Empty; + + public string EventQuestName { get; set; } = string.Empty; + + public List SelectedCharacters { get; set; } = new List(); + + public List RemainingCharacters { get; set; } = new List(); + + public List CompletedCharacters { get; set; } = new List(); + + public string CurrentCharacter { get; set; } = string.Empty; + + public string NextCharacter { get; set; } = string.Empty; + + public List DependencyQuests { get; set; } = new List(); + + public string CurrentExecutingQuest { get; set; } = string.Empty; + + public int DependencyIndex { get; set; } + + public DateTime PhaseStartTime { get; set; } = DateTime.Now; + + public DateTime RotationStartTime { get; set; } = DateTime.Now; + + public string ErrorMessage { get; set; } = string.Empty; + + public bool HasEventQuestBeenAccepted { get; set; } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/ExecutionService.cs b/QuestionableCompanion/QuestionableCompanion.Services/ExecutionService.cs new file mode 100644 index 0000000..661d7a3 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/ExecutionService.cs @@ -0,0 +1,655 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dalamud.Game.NativeWrapper; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.UI; +using QuestionableCompanion.Models; + +namespace QuestionableCompanion.Services; + +public class ExecutionService : IDisposable +{ + private readonly IPluginLog log; + + private readonly Configuration config; + + private readonly QuestionableIPC questionableIPC; + + private readonly AutoRetainerIPC autoRetainerIPC; + + private readonly QuestDetectionService questDetection; + + private readonly IClientState clientState; + + private readonly IGameGui gameGui; + + private readonly IFramework framework; + + private CharacterSafeWaitService? safeWaitService; + + private QuestPreCheckService? preCheckService; + + private DCTravelService? dcTravelService; + + private int currentCharacterIndex; + + private readonly HashSet completedCharacters = new HashSet(); + + private readonly HashSet failedCharacters = new HashSet(); + + private bool waitingForRelog; + + private string targetRelogCharacter = string.Empty; + + private DateTime relogStartTime = DateTime.MinValue; + + private bool wasLoggedOutDuringRelog; + + public ExecutionState CurrentState { get; private set; } = new ExecutionState(); + + public bool IsRunning { get; private set; } + + public bool IsPaused { get; private set; } + + public List Logs { get; } = new List(); + + public event Action? LogAdded; + + public event Action? StateChanged; + + public ExecutionService(IPluginLog log, Configuration config, QuestionableIPC questionableIPC, AutoRetainerIPC autoRetainerIPC, QuestDetectionService questDetection, IClientState clientState, IGameGui gameGui, IFramework framework) + { + this.log = log; + this.config = config; + this.questionableIPC = questionableIPC; + this.autoRetainerIPC = autoRetainerIPC; + this.questDetection = questDetection; + this.clientState = clientState; + this.gameGui = gameGui; + this.framework = framework; + questDetection.QuestAccepted += OnQuestAccepted; + questDetection.QuestCompleted += OnQuestCompleted; + framework.Update += OnFrameworkUpdate; + AddLog(LogLevel.Info, "Execution service initialized"); + } + + private void OnFrameworkUpdate(IFramework framework) + { + if (!waitingForRelog) + { + return; + } + try + { + if ((DateTime.Now - relogStartTime).TotalSeconds > 60.0) + { + AddLog(LogLevel.Warning, "Relog timeout - moving to next character"); + waitingForRelog = false; + failedCharacters.Add(targetRelogCharacter); + SwitchToNextCharacter(); + } + else if (!clientState.IsLoggedIn) + { + if (!wasLoggedOutDuringRelog) + { + AddLog(LogLevel.Info, "[ExecutionService] Character logged out, waiting for relog..."); + wasLoggedOutDuringRelog = true; + } + } + else + { + if (!clientState.IsLoggedIn || clientState.LocalPlayer == null || !IsNamePlateReady()) + { + return; + } + string currentChar = autoRetainerIPC.GetCurrentCharacter(); + if (string.IsNullOrEmpty(currentChar) || !(currentChar == targetRelogCharacter)) + { + return; + } + AddLog(LogLevel.Success, "[ExecutionService] Relog confirmed: " + currentChar); + AddLog(LogLevel.Info, "[ExecutionService] NamePlate ready, character fully loaded"); + if (config.EnableSafeWaitAfterCharacterSwitch && safeWaitService != null) + { + AddLog(LogLevel.Info, "[SafeWait] Stabilizing after character switch..."); + safeWaitService.PerformQuickSafeWait(); + AddLog(LogLevel.Info, "[SafeWait] Post-switch stabilization complete"); + } + if (config.EnableQuestPreCheck && preCheckService != null) + { + AddLog(LogLevel.Info, "[PreCheck] Scanning quest status for current character..."); + preCheckService.ScanCurrentCharacterQuestStatus(); + } + waitingForRelog = false; + CurrentState.CurrentCharacter = currentChar; + questDetection.ResetTracking(); + AddLog(LogLevel.Info, "[ExecutionService] Refreshing quest cache..."); + questDetection.RefreshQuestCache(); + NotifyStateChanged(); + if (dcTravelService != null && dcTravelService.ShouldPerformDCTravel()) + { + AddLog(LogLevel.Warning, "[DCTravel] DC Travel required for this character!"); + AddLog(LogLevel.Info, "[DCTravel] Initiating DC travel before quest execution..."); + Task.Run(async delegate + { + try + { + if (await dcTravelService.PerformDCTravel()) + { + AddLog(LogLevel.Success, "[DCTravel] DC travel completed - starting quests"); + ExecuteQuestsForCurrentCharacter(); + } + else + { + AddLog(LogLevel.Error, "[DCTravel] DC travel failed - switching character"); + SwitchToNextCharacter(); + } + } + catch (Exception ex2) + { + AddLog(LogLevel.Error, "[DCTravel] Error: " + ex2.Message); + SwitchToNextCharacter(); + } + }); + } + else + { + ExecuteQuestsForCurrentCharacter(); + } + } + } + catch (Exception ex) + { + log.Error("[ExecutionService] Error in relog monitoring: " + ex.Message); + } + } + + public bool Start() + { + QuestProfile profile = config.GetActiveProfile(); + if (profile == null) + { + AddLog(LogLevel.Error, "No active profile selected"); + return false; + } + if (profile.Characters.Count == 0) + { + AddLog(LogLevel.Error, "No characters configured in profile"); + return false; + } + IsRunning = true; + IsPaused = false; + CurrentState.ActiveProfile = profile.Name; + CurrentState.Status = ExecutionStatus.Running; + AddLog(LogLevel.Success, "Started profile: " + profile.Name); + AddLog(LogLevel.Info, $"Characters in rotation: {profile.Characters.Count}"); + currentCharacterIndex = 0; + SwitchToCharacter(profile.Characters[0]); + NotifyStateChanged(); + return true; + } + + public void Stop() + { + IsRunning = false; + IsPaused = false; + waitingForRelog = false; + if (questionableIPC.IsRunning()) + { + questionableIPC.Stop(); + } + CurrentState.Status = ExecutionStatus.Idle; + CurrentState.CurrentQuestId = 0u; + CurrentState.CurrentQuestName = string.Empty; + CurrentState.CurrentSequence = string.Empty; + AddLog(LogLevel.Info, "Execution stopped"); + NotifyStateChanged(); + } + + public void Pause() + { + IsPaused = true; + questionableIPC.Stop(); + CurrentState.Status = ExecutionStatus.Waiting; + AddLog(LogLevel.Warning, "Execution paused"); + NotifyStateChanged(); + } + + public void Resume() + { + IsPaused = false; + CurrentState.Status = ExecutionStatus.Running; + AddLog(LogLevel.Info, "Execution resumed"); + NotifyStateChanged(); + } + + private void OnQuestAccepted(uint questId, string questName) + { + if (!IsRunning || IsPaused) + { + AddLog(LogLevel.Debug, $"Quest {questId} accepted but execution not running"); + return; + } + QuestProfile profile = config.GetActiveProfile(); + if (profile == null) + { + AddLog(LogLevel.Warning, "No active profile found"); + return; + } + QuestConfig questConfig = profile.Quests.FirstOrDefault((QuestConfig q) => q.QuestId == questId && q.TriggerType == TriggerType.OnAccept); + if (questConfig != null) + { + AddLog(LogLevel.Success, $"Quest accepted trigger matched: {questName} (ID: {questId})"); + ExecuteSequence(questConfig); + } + else + { + AddLog(LogLevel.Info, $"Quest {questId} ({questName}) accepted but no OnAccept trigger configured"); + AddLog(LogLevel.Info, "Add this quest to your profile with TriggerType=OnAccept to auto-execute"); + } + } + + private void OnQuestCompleted(uint questId, string questName) + { + if (!IsRunning || IsPaused) + { + return; + } + QuestProfile profile = config.GetActiveProfile(); + if (profile != null) + { + QuestConfig questConfig = profile.Quests.FirstOrDefault((QuestConfig q) => q.QuestId == questId && q.TriggerType == TriggerType.OnComplete); + if (questConfig != null) + { + AddLog(LogLevel.Success, "Quest completed trigger matched: " + questName); + ExecuteSequence(questConfig); + } + } + } + + private async void ExecuteSequence(QuestConfig questConfig) + { + CurrentState.CurrentQuestId = questConfig.QuestId; + CurrentState.CurrentQuestName = questConfig.QuestName; + CurrentState.CurrentSequence = questConfig.SequenceAfterQuest.Value; + CurrentState.Status = ExecutionStatus.Running; + NotifyStateChanged(); + if (config.EnableDryRun) + { + AddLog(LogLevel.Debug, "[DRY RUN] Would execute sequence: " + questConfig.SequenceAfterQuest.Value); + await Task.Delay(2000); + OnSequenceComplete(questConfig); + return; + } + switch (questConfig.SequenceAfterQuest.Type) + { + case SequenceType.QuestionableProfile: + await ExecuteQuestionableProfile(questConfig); + break; + case SequenceType.InternalAction: + await ExecuteInternalAction(questConfig); + break; + } + } + + private async Task ExecuteQuestionableProfile(QuestConfig questConfig) + { + AddLog(LogLevel.Info, $"Monitoring Quest {questConfig.QuestId} for completion..."); + AddLog(LogLevel.Info, "Questionable will handle quest progression automatically"); + CurrentState.CurrentSequence = $"Monitoring Quest {questConfig.QuestId}"; + NotifyStateChanged(); + await WaitForQuestCompletion(questConfig.QuestId); + OnSequenceComplete(questConfig); + } + + private async Task WaitForQuestAcceptanceThenNext(QuestConfig questConfig) + { + int maxWaitSeconds = 3600; + int waited = 0; + AddLog(LogLevel.Info, $"Waiting for quest {questConfig.QuestId} to be accepted..."); + while (waited < maxWaitSeconds && IsRunning) + { + await Task.Delay(5000); + waited += 5; + if (questDetection.IsQuestAccepted(questConfig.QuestId)) + { + AddLog(LogLevel.Success, $"Quest {questConfig.QuestId} accepted!"); + OnSequenceComplete(questConfig); + return; + } + if (questDetection.IsQuestCompletedDirect(questConfig.QuestId)) + { + AddLog(LogLevel.Success, $"Quest {questConfig.QuestId} already completed!"); + OnSequenceComplete(questConfig); + return; + } + if (waited % 60 == 0) + { + AddLog(LogLevel.Debug, $"Still waiting for quest {questConfig.QuestId} acceptance... ({waited}s)"); + } + } + if (!IsRunning) + { + AddLog(LogLevel.Info, "Monitoring stopped - execution not running"); + return; + } + AddLog(LogLevel.Warning, $"Quest {questConfig.QuestId} acceptance timeout after {maxWaitSeconds}s"); + OnSequenceFailed(questConfig); + } + + private async Task WaitForQuestCompletion(uint questId) + { + int maxWaitSeconds = 600; + int waited = 0; + while (waited < maxWaitSeconds && IsRunning) + { + await Task.Delay(5000); + waited += 5; + if (questDetection.IsQuestCompletedDirect(questId)) + { + AddLog(LogLevel.Success, $"Quest {questId} completed!"); + return; + } + if (waited % 30 == 0) + { + AddLog(LogLevel.Debug, $"Still waiting for quest {questId}... ({waited}s)"); + } + } + if (!IsRunning) + { + AddLog(LogLevel.Info, "Monitoring stopped - execution not running"); + return; + } + AddLog(LogLevel.Warning, $"Quest {questId} completion timeout after {maxWaitSeconds}s"); + } + + private async Task WaitForQuestionableCompletion() + { + AddLog(LogLevel.Warning, "Waiting for Questionable to complete..."); + while (questionableIPC.IsRunning()) + { + await Task.Delay(1000); + } + AddLog(LogLevel.Success, "Questionable completed"); + } + + private async Task ExecuteInternalAction(QuestConfig questConfig) + { + AddLog(LogLevel.Warning, "Internal action not yet implemented: " + questConfig.SequenceAfterQuest.Value); + await Task.Delay(1000); + OnSequenceComplete(questConfig); + } + + private void OnSequenceComplete(QuestConfig questConfig) + { + AddLog(LogLevel.Success, "Sequence completed: " + questConfig.SequenceAfterQuest.Value); + CurrentState.Status = ExecutionStatus.Complete; + CurrentState.Progress = 100; + NotifyStateChanged(); + if (questConfig.NextCharacter == "auto_next") + { + SwitchToNextCharacter(); + } + else if (!string.IsNullOrEmpty(questConfig.NextCharacter)) + { + SwitchToCharacter(questConfig.NextCharacter); + } + } + + private void OnSequenceFailed(QuestConfig questConfig) + { + AddLog(LogLevel.Error, "Sequence failed: " + questConfig.SequenceAfterQuest.Value); + CurrentState.Status = ExecutionStatus.Failed; + NotifyStateChanged(); + if (!string.IsNullOrEmpty(CurrentState.CurrentCharacter)) + { + failedCharacters.Add(CurrentState.CurrentCharacter); + } + SwitchToNextCharacter(); + } + + private async Task SwitchToCharacter(string characterName) + { + if (config.EnableDryRun) + { + AddLog(LogLevel.Debug, "[DRY RUN] Would switch to character: " + characterName); + CurrentState.CurrentCharacter = characterName; + NotifyStateChanged(); + return; + } + AddLog(LogLevel.Info, "Switching to character: " + characterName); + if (questionableIPC.IsRunning()) + { + questionableIPC.Stop(); + } + string originalChar = autoRetainerIPC.GetCurrentCharacter(); + AddLog(LogLevel.Debug, "Current character before switch: " + (originalChar ?? "null")); + if (!string.IsNullOrEmpty(originalChar) && originalChar == characterName) + { + AddLog(LogLevel.Info, "Already on character: " + characterName); + AddLog(LogLevel.Info, "Refreshing quest cache..."); + questDetection.RefreshQuestCache(); + CurrentState.CurrentCharacter = characterName; + NotifyStateChanged(); + await Task.Delay(2000); + ExecuteQuestsForCurrentCharacter(); + return; + } + if (string.IsNullOrEmpty(originalChar)) + { + AddLog(LogLevel.Warning, "Could not get current character - might be in transition"); + } + AddLog(LogLevel.Info, "[AutoRetainerIPC] Relog request sent for: " + characterName); + if (autoRetainerIPC.SwitchCharacter(characterName)) + { + waitingForRelog = true; + targetRelogCharacter = characterName; + relogStartTime = DateTime.Now; + wasLoggedOutDuringRelog = false; + AddLog(LogLevel.Info, "Relog command sent, monitoring via Framework.Update..."); + } + else + { + AddLog(LogLevel.Error, "Failed to send relog request for character: " + characterName); + failedCharacters.Add(characterName); + SwitchToNextCharacter(); + } + } + + private void ExecuteQuestsForCurrentCharacter() + { + AddLog(LogLevel.Info, "=== ExecuteQuestsForCurrentCharacter called ==="); + QuestProfile profile = config.GetActiveProfile(); + if (profile == null) + { + AddLog(LogLevel.Error, "No active profile"); + SwitchToNextCharacter(); + return; + } + string currentChar = CurrentState.CurrentCharacter; + if (string.IsNullOrEmpty(currentChar)) + { + AddLog(LogLevel.Warning, "No current character set"); + SwitchToNextCharacter(); + return; + } + AddLog(LogLevel.Info, "Current character: " + currentChar); + AddLog(LogLevel.Info, $"Total quests in profile: {profile.Quests.Count}"); + List characterQuests = (from q in profile.Quests + where string.IsNullOrEmpty(q.AssignedCharacter) || q.AssignedCharacter == currentChar + orderby q.QuestId + select q).ToList(); + if (characterQuests.Count == 0) + { + AddLog(LogLevel.Warning, "No quests configured for " + currentChar); + AddLog(LogLevel.Info, "Please add quests to your profile using /qstcomp"); + completedCharacters.Add(currentChar); + SwitchToNextCharacter(); + return; + } + AddLog(LogLevel.Info, $"Found {characterQuests.Count} quests for {currentChar}"); + foreach (QuestConfig quest in characterQuests) + { + AddLog(LogLevel.Debug, $"Checking quest {quest.QuestId}: {quest.QuestName}"); + bool isCompleted = questDetection.IsQuestCompletedDirect(quest.QuestId); + AddLog(LogLevel.Debug, $"Quest {quest.QuestId} completed status: {isCompleted}"); + if (!isCompleted) + { + AddLog(LogLevel.Success, $"Starting quest {quest.QuestId}: {quest.QuestName}"); + CurrentState.CurrentQuestId = quest.QuestId; + CurrentState.CurrentQuestName = quest.QuestName; + CurrentState.CurrentSequence = $"Quest {quest.QuestId}"; + NotifyStateChanged(); + if (quest.TriggerType == TriggerType.OnAccept) + { + AddLog(LogLevel.Info, $"Quest {quest.QuestId} has OnAccept trigger - waiting for acceptance"); + WaitForQuestAcceptanceThenNext(quest); + } + else + { + AddLog(LogLevel.Info, $"Quest {quest.QuestId} has OnComplete trigger - monitoring for completion"); + ExecuteSequence(quest); + } + return; + } + AddLog(LogLevel.Info, $"Quest {quest.QuestId} already completed, skipping"); + } + AddLog(LogLevel.Success, "All quests completed for " + currentChar + "!"); + completedCharacters.Add(currentChar); + SwitchToNextCharacter(); + } + + public void SetSafeWaitService(CharacterSafeWaitService service) + { + safeWaitService = service; + } + + public void SetPreCheckService(QuestPreCheckService service) + { + preCheckService = service; + } + + public void SetDCTravelService(DCTravelService service) + { + dcTravelService = service; + } + + private void SwitchToNextCharacter() + { + QuestProfile profile = config.GetActiveProfile(); + if (profile == null || profile.Characters.Count == 0) + { + AddLog(LogLevel.Error, "No profile or characters available"); + Stop(); + return; + } + if (config.EnableQuestPreCheck && preCheckService != null) + { + AddLog(LogLevel.Info, "Logging completed quests before logout..."); + preCheckService.LogCompletedQuestsBeforeLogout(); + } + if (dcTravelService != null && dcTravelService.IsDCTravelCompleted()) + { + AddLog(LogLevel.Info, "[DCTravel] Returning to homeworld before character switch..."); + dcTravelService.ReturnToHomeworld(); + Thread.Sleep(2000); + AddLog(LogLevel.Info, "[DCTravel] Returned to homeworld"); + } + if (config.EnableSafeWaitBeforeCharacterSwitch && safeWaitService != null) + { + AddLog(LogLevel.Info, "[SafeWait] Stabilizing character before switch..."); + safeWaitService.PerformSafeWait(); + AddLog(LogLevel.Info, "[SafeWait] Stabilization complete"); + } + if (!string.IsNullOrEmpty(CurrentState.CurrentCharacter)) + { + completedCharacters.Add(CurrentState.CurrentCharacter); + } + int attempts = 0; + while (attempts < profile.Characters.Count) + { + currentCharacterIndex = (currentCharacterIndex + 1) % profile.Characters.Count; + string nextChar = profile.Characters[currentCharacterIndex]; + if (config.EnableQuestPreCheck && preCheckService != null) + { + List stopPoints = config.StopPoints; + if (stopPoints != null && stopPoints.Count > 0) + { + uint firstStopQuest = stopPoints[0].QuestId; + if (preCheckService.ShouldSkipCharacter(nextChar, firstStopQuest)) + { + AddLog(LogLevel.Info, "[PreCheck] Skipping " + nextChar + " - quest already completed"); + completedCharacters.Add(nextChar); + attempts++; + continue; + } + } + } + if (!completedCharacters.Contains(nextChar) && !failedCharacters.Contains(nextChar)) + { + SwitchToCharacter(nextChar); + return; + } + attempts++; + } + AddLog(LogLevel.Success, "All characters completed!"); + Stop(); + } + + private void AddLog(LogLevel level, string message) + { + LogEntry entry = new LogEntry + { + Level = level, + Message = message, + Timestamp = DateTime.Now + }; + Logs.Add(entry); + while (Logs.Count > config.MaxLogEntries) + { + Logs.RemoveAt(0); + } + log.Information("[ExecutionService] " + message); + this.LogAdded?.Invoke(entry); + } + + private void NotifyStateChanged() + { + CurrentState.LastUpdate = DateTime.Now; + this.StateChanged?.Invoke(CurrentState); + } + + private unsafe bool IsNamePlateReady() + { + try + { + AtkUnitBasePtr namePlatePtr = gameGui.GetAddonByName("NamePlate"); + if (namePlatePtr == IntPtr.Zero) + { + return false; + } + AddonNamePlate* namePlate = (AddonNamePlate*)namePlatePtr.Address; + if (namePlate != null && namePlate->AtkUnitBase.IsVisible && namePlate->AtkUnitBase.IsReady) + { + return true; + } + return false; + } + catch + { + return false; + } + } + + public void Dispose() + { + questDetection.QuestAccepted -= OnQuestAccepted; + questDetection.QuestCompleted -= OnQuestCompleted; + framework.Update -= OnFrameworkUpdate; + Stop(); + AddLog(LogLevel.Info, "Execution service disposed"); + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/ExpansionInfo.cs b/QuestionableCompanion/QuestionableCompanion.Services/ExpansionInfo.cs new file mode 100644 index 0000000..216ef74 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/ExpansionInfo.cs @@ -0,0 +1,14 @@ +namespace QuestionableCompanion.Services; + +public class ExpansionInfo +{ + public string Name { get; set; } = ""; + + public string ShortName { get; set; } = ""; + + public uint MinQuestId { get; set; } + + public uint MaxQuestId { get; set; } + + public int ExpectedQuestCount { get; set; } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/ExpansionProgressInfo.cs b/QuestionableCompanion/QuestionableCompanion.Services/ExpansionProgressInfo.cs new file mode 100644 index 0000000..f9407b1 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/ExpansionProgressInfo.cs @@ -0,0 +1,14 @@ +namespace QuestionableCompanion.Services; + +public class ExpansionProgressInfo +{ + public string ExpansionName { get; set; } = ""; + + public string ShortName { get; set; } = ""; + + public int TotalQuests { get; set; } + + public int CompletedQuests { get; set; } + + public float Percentage { get; set; } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/HelperManager.cs b/QuestionableCompanion/QuestionableCompanion.Services/HelperManager.cs new file mode 100644 index 0000000..dc95cc5 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/HelperManager.cs @@ -0,0 +1,444 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Dalamud.Game.ClientState.Conditions; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game.Group; + +namespace QuestionableCompanion.Services; + +public class HelperManager : IDisposable +{ + private readonly Configuration configuration; + + private readonly IPluginLog log; + + private readonly ICommandManager commandManager; + + private readonly ICondition condition; + + private readonly IClientState clientState; + + private readonly IFramework framework; + + private readonly PartyInviteService partyInviteService; + + private readonly MultiClientIPC multiClientIPC; + + private readonly CrossProcessIPC crossProcessIPC; + + private readonly PartyInviteAutoAccept partyInviteAutoAccept; + + private readonly MemoryHelper memoryHelper; + + private bool isInDuty; + + private List<(string Name, ushort WorldId)> availableHelpers = new List<(string, ushort)>(); + + 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) + { + this.configuration = configuration; + this.log = log; + this.commandManager = commandManager; + this.condition = condition; + this.clientState = clientState; + this.framework = framework; + this.partyInviteService = partyInviteService; + this.multiClientIPC = multiClientIPC; + this.crossProcessIPC = crossProcessIPC; + this.memoryHelper = memoryHelper; + this.partyInviteAutoAccept = partyInviteAutoAccept; + condition.ConditionChange += OnConditionChanged; + multiClientIPC.OnHelperRequested += OnHelperRequested; + multiClientIPC.OnHelperDismissed += OnHelperDismissed; + multiClientIPC.OnHelperAvailable += OnHelperAvailable; + crossProcessIPC.OnHelperRequested += OnHelperRequested; + crossProcessIPC.OnHelperDismissed += OnHelperDismissed; + crossProcessIPC.OnHelperAvailable += OnHelperAvailable; + crossProcessIPC.OnHelperReady += OnHelperReady; + crossProcessIPC.OnHelperInParty += OnHelperInParty; + crossProcessIPC.OnHelperInDuty += OnHelperInDuty; + crossProcessIPC.OnRequestHelperAnnouncements += OnRequestHelperAnnouncements; + if (configuration.IsHighLevelHelper) + { + log.Information("[HelperManager] Will announce helper availability on next frame"); + } + log.Information("[HelperManager] Initialized"); + } + + public void AnnounceIfHelper() + { + if (configuration.IsHighLevelHelper) + { + IPlayerCharacter localPlayer = clientState.LocalPlayer; + if (localPlayer == null) + { + log.Warning("[HelperManager] LocalPlayer is null, cannot announce helper"); + return; + } + string localName = localPlayer.Name.ToString(); + ushort localWorldId = (ushort)localPlayer.HomeWorld.RowId; + multiClientIPC.AnnounceHelperAvailable(localName, localWorldId); + crossProcessIPC.AnnounceHelper(); + log.Information($"[HelperManager] Announced as helper: {localName}@{localWorldId} (both IPC systems)"); + } + } + + public void InviteHelpers() + { + if (!configuration.IsQuester) + { + log.Debug("[HelperManager] Not a Quester, skipping helper invites"); + return; + } + log.Information("[HelperManager] Requesting helper announcements..."); + RequestHelperAnnouncements(); + Task.Run(async delegate + { + await Task.Delay(1000); + if (availableHelpers.Count == 0) + { + log.Warning("[HelperManager] No helpers available via IPC!"); + log.Warning("[HelperManager] Make sure helper clients are running with 'I'm a High-Level Helper' enabled"); + } + else + { + log.Information($"[HelperManager] Inviting {availableHelpers.Count} AUTO-DISCOVERED helper(s)..."); + DisbandParty(); + await Task.Delay(500); + foreach (var (name, worldId) in availableHelpers) + { + if (string.IsNullOrEmpty(name) || worldId == 0) + { + log.Warning($"[HelperManager] Invalid helper: {name}@{worldId}"); + } + else + { + log.Information($"[HelperManager] Requesting helper: {name}@{worldId}"); + helperReadyStatus[(name, worldId)] = false; + multiClientIPC.RequestHelper(name, worldId); + crossProcessIPC.RequestHelper(name, worldId); + log.Information("[HelperManager] Waiting for " + name + " to be ready..."); + DateTime timeout = DateTime.Now.AddSeconds(10.0); + while (!helperReadyStatus.GetValueOrDefault((name, worldId), defaultValue: false) && DateTime.Now < timeout) + { + await Task.Delay(100); + } + if (!helperReadyStatus.GetValueOrDefault((name, worldId), defaultValue: false)) + { + log.Warning("[HelperManager] Timeout waiting for " + name + " to be ready!"); + } + else + { + log.Information("[HelperManager] " + name + " is ready! Sending invite..."); + if (partyInviteService.InviteToParty(name, worldId)) + { + log.Information("[HelperManager] Successfully invited " + name); + } + else + { + log.Error("[HelperManager] Failed to invite " + name); + } + await Task.Delay(500); + } + } + } + } + }); + } + + public List<(string Name, ushort WorldId)> GetAvailableHelpers() + { + return new List<(string, ushort)>(availableHelpers); + } + + private void LeaveParty() + { + try + { + log.Information("[HelperManager] Leaving party"); + framework.RunOnFrameworkThread(delegate + { + memoryHelper.SendChatMessage("/leave"); + log.Information("[HelperManager] /leave command sent via UIModule"); + }); + } + catch (Exception ex) + { + log.Error("[HelperManager] Failed to leave party: " + ex.Message); + } + } + + public void DisbandParty() + { + try + { + log.Information("[HelperManager] Disbanding party"); + framework.RunOnFrameworkThread(delegate + { + memoryHelper.SendChatMessage("/leave"); + log.Information("[HelperManager] /leave command sent via UIModule"); + }); + multiClientIPC.DismissHelper(); + crossProcessIPC.DismissHelper(); + } + catch (Exception ex) + { + log.Error("[HelperManager] Failed to disband party: " + ex.Message); + } + } + + private void OnConditionChanged(ConditionFlag flag, bool value) + { + if (flag == ConditionFlag.BoundByDuty) + { + if (value && !isInDuty) + { + isInDuty = true; + OnDutyEnter(); + } + else if (!value && isInDuty) + { + isInDuty = false; + OnDutyLeave(); + } + } + } + + private void OnDutyEnter() + { + log.Information("[HelperManager] Entered duty"); + if (!configuration.IsHighLevelHelper) + { + return; + } + configuration.CurrentHelperStatus = HelperStatus.InDungeon; + configuration.Save(); + log.Information("[HelperManager] Helper status: InDungeon"); + IPlayerCharacter localPlayer = clientState.LocalPlayer; + if (localPlayer != null) + { + string helperName = localPlayer.Name.ToString(); + ushort helperWorld = (ushort)localPlayer.HomeWorld.RowId; + crossProcessIPC.BroadcastHelperStatus(helperName, helperWorld, "InDungeon"); + } + log.Information("[HelperManager] Starting AutoDuty (High-Level Helper)"); + Task.Run(async delegate + { + log.Information("[HelperManager] Waiting 5s before starting AutoDuty..."); + await Task.Delay(5000); + framework.RunOnFrameworkThread(delegate + { + try + { + commandManager.ProcessCommand("/ad start"); + log.Information("[HelperManager] AutoDuty started"); + } + catch (Exception ex) + { + log.Error("[HelperManager] Failed to start AutoDuty: " + ex.Message); + } + }); + }); + } + + private unsafe void OnDutyLeave() + { + log.Information("[HelperManager] Left duty"); + if (configuration.IsHighLevelHelper) + { + if (configuration.CurrentHelperStatus == HelperStatus.InDungeon) + { + configuration.CurrentHelperStatus = HelperStatus.Available; + configuration.Save(); + log.Information("[HelperManager] Helper status: Available"); + IPlayerCharacter localPlayer = clientState.LocalPlayer; + if (localPlayer != null) + { + string helperName = localPlayer.Name.ToString(); + ushort helperWorld = (ushort)localPlayer.HomeWorld.RowId; + crossProcessIPC.BroadcastHelperStatus(helperName, helperWorld, "Available"); + } + } + log.Information("[HelperManager] Stopping AutoDuty (High-Level Helper)"); + framework.RunOnFrameworkThread(delegate + { + try + { + commandManager.ProcessCommand("/ad stop"); + log.Information("[HelperManager] AutoDuty stopped"); + } + catch (Exception ex) + { + log.Error("[HelperManager] Failed to stop AutoDuty: " + ex.Message); + } + }); + log.Information("[HelperManager] Leaving party after duty (High-Level Helper)"); + Task.Run(async delegate + { + log.Information("[HelperManager] Waiting 4 seconds for duty to fully complete..."); + await Task.Delay(4000); + for (int attempt = 1; attempt <= 3; attempt++) + { + bool inParty = false; + GroupManager* groupManager = GroupManager.Instance(); + if (groupManager != null) + { + GroupManager.Group* group = groupManager->GetGroup(); + if (group != null && group->MemberCount > 1) + { + inParty = true; + } + } + if (!inParty) + { + log.Information("[HelperManager] Successfully left party or already solo"); + break; + } + log.Information($"[HelperManager] Attempt {attempt}/3: Still in party - sending /leave command"); + LeaveParty(); + if (attempt < 3) + { + await Task.Delay(2000); + } + } + }); + } + if (configuration.IsQuester) + { + log.Information("[HelperManager] Disbanding party after duty (Quester)"); + DisbandParty(); + } + } + + private unsafe void OnHelperRequested(string characterName, ushort worldId) + { + if (!configuration.IsHighLevelHelper) + { + log.Debug("[HelperManager] Not a High-Level Helper, ignoring request"); + return; + } + IPlayerCharacter localPlayer = clientState.LocalPlayer; + if (localPlayer == null) + { + log.Warning("[HelperManager] Local player is null!"); + return; + } + string localName = localPlayer.Name.ToString(); + ushort localWorldId = (ushort)localPlayer.HomeWorld.RowId; + if (!(localName == characterName) || localWorldId != worldId) + { + return; + } + log.Information("[HelperManager] Helper request is for me! Checking status..."); + Task.Run(async delegate + { + bool needsToLeaveParty = false; + bool isInDuty = false; + GroupManager* groupManager = GroupManager.Instance(); + if (groupManager != null) + { + GroupManager.Group* group = groupManager->GetGroup(); + if (group != null && group->MemberCount > 0) + { + needsToLeaveParty = true; + log.Information("[HelperManager] Currently in party, notifying quester..."); + crossProcessIPC.NotifyHelperInParty(localName, localWorldId); + if (condition[ConditionFlag.BoundByDuty]) + { + isInDuty = true; + log.Information("[HelperManager] Currently in duty, notifying quester..."); + crossProcessIPC.NotifyHelperInDuty(localName, localWorldId); + } + } + } + if (!isInDuty) + { + if (needsToLeaveParty) + { + LeaveParty(); + await Task.Delay(1000); + } + log.Information("[HelperManager] Ready to accept invite!"); + partyInviteAutoAccept.EnableAutoAccept(); + crossProcessIPC.NotifyHelperReady(localName, localWorldId); + } + }); + } + + private void OnHelperDismissed() + { + if (configuration.IsHighLevelHelper) + { + log.Information("[HelperManager] Received dismiss signal, leaving party..."); + DisbandParty(); + } + } + + private void OnHelperAvailable(string characterName, ushort worldId) + { + if (configuration.IsQuester && !availableHelpers.Any<(string, ushort)>(((string Name, ushort WorldId) h) => h.Name == characterName && h.WorldId == worldId)) + { + availableHelpers.Add((characterName, worldId)); + log.Information($"[HelperManager] Helper discovered: {characterName}@{worldId} (Total: {availableHelpers.Count})"); + } + } + + private void OnHelperReady(string characterName, ushort worldId) + { + if (configuration.IsQuester) + { + log.Information($"[HelperManager] Helper {characterName}@{worldId} is ready!"); + helperReadyStatus[(characterName, worldId)] = true; + } + } + + private void OnHelperInParty(string characterName, ushort worldId) + { + if (configuration.IsQuester) + { + log.Information($"[HelperManager] Helper {characterName}@{worldId} is in a party, waiting for them to leave..."); + } + } + + private void OnHelperInDuty(string characterName, ushort worldId) + { + if (configuration.IsQuester) + { + log.Warning($"[HelperManager] Helper {characterName}@{worldId} is in a duty! Cannot invite until they leave."); + } + } + + private void OnRequestHelperAnnouncements() + { + if (configuration.IsHighLevelHelper) + { + log.Information("[HelperManager] Received request for helper announcements, announcing..."); + AnnounceIfHelper(); + } + } + + public void RequestHelperAnnouncements() + { + crossProcessIPC.RequestHelperAnnouncements(); + } + + public void Dispose() + { + condition.ConditionChange -= OnConditionChanged; + multiClientIPC.OnHelperRequested -= OnHelperRequested; + multiClientIPC.OnHelperDismissed -= OnHelperDismissed; + multiClientIPC.OnHelperAvailable -= OnHelperAvailable; + crossProcessIPC.OnHelperRequested -= OnHelperRequested; + crossProcessIPC.OnHelperDismissed -= OnHelperDismissed; + crossProcessIPC.OnHelperAvailable -= OnHelperAvailable; + crossProcessIPC.OnHelperReady -= OnHelperReady; + crossProcessIPC.OnHelperInParty -= OnHelperInParty; + crossProcessIPC.OnHelperInDuty -= OnHelperInDuty; + crossProcessIPC.OnRequestHelperAnnouncements -= OnRequestHelperAnnouncements; + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/LifestreamIPC.cs b/QuestionableCompanion/QuestionableCompanion.Services/LifestreamIPC.cs new file mode 100644 index 0000000..2ca56d1 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/LifestreamIPC.cs @@ -0,0 +1,262 @@ +using System; +using Dalamud.Plugin; +using Dalamud.Plugin.Ipc; +using Dalamud.Plugin.Services; + +namespace QuestionableCompanion.Services; + +public class LifestreamIPC : IDisposable +{ + private readonly IPluginLog log; + + private readonly IDalamudPluginInterface pluginInterface; + + private ICallGateSubscriber? isBusySubscriber; + + private ICallGateSubscriber? changeWorldSubscriber; + + private ICallGateSubscriber? changeWorldByIdSubscriber; + + private ICallGateSubscriber? abortSubscriber; + + private bool _isAvailable; + + private bool _ipcInitialized; + + private DateTime lastAvailabilityCheck = DateTime.MinValue; + + private const int AvailabilityCheckCooldownSeconds = 5; + + private bool hasPerformedInitialCheck; + + public bool IsAvailable + { + get + { + return _isAvailable; + } + private set + { + _isAvailable = value; + } + } + + public LifestreamIPC(IPluginLog log, IDalamudPluginInterface pluginInterface) + { + this.log = log; + this.pluginInterface = pluginInterface; + } + + private void InitializeIPC() + { + if (_ipcInitialized) + { + return; + } + try + { + isBusySubscriber = pluginInterface.GetIpcSubscriber("Lifestream.IsBusy"); + changeWorldSubscriber = pluginInterface.GetIpcSubscriber("Lifestream.ChangeWorld"); + changeWorldByIdSubscriber = pluginInterface.GetIpcSubscriber("Lifestream.ChangeWorldById"); + abortSubscriber = pluginInterface.GetIpcSubscriber("Lifestream.Abort"); + _ipcInitialized = true; + log.Debug("[LifestreamIPC] IPC subscribers initialized (lazy-loading enabled)"); + } + catch (Exception ex) + { + log.Error("[LifestreamIPC] Failed to initialize subscribers: " + ex.Message); + _isAvailable = false; + _ipcInitialized = false; + } + } + + private bool TryEnsureAvailable(bool forceCheck = false) + { + if (_isAvailable) + { + return true; + } + if (!_ipcInitialized) + { + InitializeIPC(); + } + if (!_ipcInitialized) + { + return false; + } + DateTime now = DateTime.Now; + if (!forceCheck && hasPerformedInitialCheck && (now - lastAvailabilityCheck).TotalSeconds < 5.0) + { + log.Debug($"[LifestreamIPC] Cooldown active - skipping check (last check: {(now - lastAvailabilityCheck).TotalSeconds:F1}s ago)"); + return false; + } + if (forceCheck) + { + log.Information("[LifestreamIPC] FORCED availability check requested"); + } + lastAvailabilityCheck = now; + hasPerformedInitialCheck = true; + try + { + if (isBusySubscriber == null) + { + log.Debug("[LifestreamIPC] isBusySubscriber is NULL - cannot check availability"); + _isAvailable = false; + return false; + } + log.Debug("[LifestreamIPC] Attempting to invoke Lifestream.IsBusy()..."); + bool testBusy = isBusySubscriber.InvokeFunc(); + if (!_isAvailable) + { + _isAvailable = true; + log.Information($"[LifestreamIPC] Lifestream is now available (Busy: {testBusy})"); + } + else + { + log.Debug($"[LifestreamIPC] Lifestream still available (Busy: {testBusy})"); + } + return true; + } + catch (Exception ex) + { + if (!hasPerformedInitialCheck) + { + log.Warning("[LifestreamIPC] First availability check FAILED: " + ex.GetType().Name + ": " + ex.Message); + } + else + { + log.Debug("[LifestreamIPC] Lifestream not yet available: " + ex.Message); + } + _isAvailable = false; + return false; + } + } + + public bool IsBusy() + { + TryEnsureAvailable(); + if (!_isAvailable || isBusySubscriber == null) + { + return false; + } + try + { + return isBusySubscriber.InvokeFunc(); + } + catch (Exception ex) + { + log.Error("[LifestreamIPC] Error checking busy status: " + ex.Message); + return false; + } + } + + public bool ForceCheckAvailability() + { + log.Information("[LifestreamIPC] ========================================"); + log.Information("[LifestreamIPC] === FORCING AVAILABILITY CHECK ==="); + log.Information("[LifestreamIPC] ========================================"); + bool result = TryEnsureAvailable(forceCheck: true); + log.Information($"[LifestreamIPC] Force check result: {result}"); + return result; + } + + public bool ChangeWorld(string worldName) + { + TryEnsureAvailable(); + log.Information("[LifestreamIPC] ========================================"); + log.Information("[LifestreamIPC] === CHANGE WORLD REQUEST ==="); + log.Information("[LifestreamIPC] ========================================"); + log.Information("[LifestreamIPC] Target World: '" + worldName + "'"); + log.Information($"[LifestreamIPC] IsAvailable: {_isAvailable}"); + log.Information($"[LifestreamIPC] changeWorldSubscriber != null: {changeWorldSubscriber != null}"); + if (!_isAvailable || changeWorldSubscriber == null) + { + log.Error("[LifestreamIPC] CANNOT CHANGE WORLD - Lifestream not available!"); + log.Error("[LifestreamIPC] Make sure Lifestream plugin is installed and enabled!"); + return false; + } + try + { + log.Information("[LifestreamIPC] Invoking Lifestream.ChangeWorld('" + worldName + "')..."); + bool num = changeWorldSubscriber.InvokeFunc(worldName); + if (num) + { + log.Information("[LifestreamIPC] ========================================"); + log.Information("[LifestreamIPC] WORLD CHANGE ACCEPTED: " + worldName); + log.Information("[LifestreamIPC] ========================================"); + } + else + { + log.Warning("[LifestreamIPC] ========================================"); + log.Warning("[LifestreamIPC] WORLD CHANGE REJECTED: " + worldName); + log.Warning("[LifestreamIPC] ========================================"); + log.Warning("[LifestreamIPC] Possible reasons:"); + log.Warning("[LifestreamIPC] - Lifestream is busy"); + log.Warning("[LifestreamIPC] - World name is invalid"); + log.Warning("[LifestreamIPC] - Cannot visit this world"); + } + return num; + } + catch (Exception ex) + { + log.Error("[LifestreamIPC] ========================================"); + log.Error("[LifestreamIPC] ERROR REQUESTING WORLD CHANGE!"); + log.Error("[LifestreamIPC] ========================================"); + log.Error("[LifestreamIPC] Error: " + ex.Message); + log.Error("[LifestreamIPC] Stack: " + ex.StackTrace); + return false; + } + } + + public bool ChangeWorldById(uint worldId) + { + TryEnsureAvailable(); + if (!_isAvailable || changeWorldByIdSubscriber == null) + { + log.Warning("[LifestreamIPC] Lifestream not available for world change"); + return false; + } + try + { + log.Information($"[LifestreamIPC] Requesting world change to ID: {worldId}"); + bool num = changeWorldByIdSubscriber.InvokeFunc(worldId); + if (num) + { + log.Information($"[LifestreamIPC] World change request accepted for ID: {worldId}"); + } + else + { + log.Warning($"[LifestreamIPC] World change request rejected for ID: {worldId}"); + } + return num; + } + catch (Exception ex) + { + log.Error("[LifestreamIPC] Error requesting world change by ID: " + ex.Message); + return false; + } + } + + public void Abort() + { + TryEnsureAvailable(); + if (!_isAvailable || abortSubscriber == null) + { + return; + } + try + { + abortSubscriber.InvokeAction(); + log.Information("[LifestreamIPC] Abort request sent to Lifestream"); + } + catch (Exception ex) + { + log.Error("[LifestreamIPC] Error aborting Lifestream: " + ex.Message); + } + } + + public void Dispose() + { + log.Information("[LifestreamIPC] Service disposed"); + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/MSQProgressionService.cs b/QuestionableCompanion/QuestionableCompanion.Services/MSQProgressionService.cs new file mode 100644 index 0000000..19f4a67 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/MSQProgressionService.cs @@ -0,0 +1,602 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game; +using Lumina.Excel; +using Lumina.Excel.Sheets; +using QuestionableCompanion.Data; + +namespace QuestionableCompanion.Services; + +public class MSQProgressionService +{ + private readonly IDataManager dataManager; + + private readonly IPluginLog log; + + private readonly QuestDetectionService questDetectionService; + + private readonly IClientState clientState; + + private readonly IFramework framework; + + private List? mainScenarioQuests; + + private Dictionary questNameCache = new Dictionary(); + + private Dictionary> questsByExpansion = new Dictionary>(); + + private static readonly uint[] MSQ_JOURNAL_GENRE_IDS = new uint[14] + { + 1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u, + 11u, 12u, 13u, 14u + }; + + private const uint LAST_ARR_QUEST_ID = 65964u; + + private static readonly Dictionary JournalGenreToExpansion = new Dictionary + { + { + 1u, + MSQExpansionData.Expansion.ARealmReborn + }, + { + 2u, + MSQExpansionData.Expansion.ARealmReborn + }, + { + 3u, + MSQExpansionData.Expansion.Heavensward + }, + { + 4u, + MSQExpansionData.Expansion.Heavensward + }, + { + 5u, + MSQExpansionData.Expansion.Heavensward + }, + { + 6u, + MSQExpansionData.Expansion.Stormblood + }, + { + 7u, + MSQExpansionData.Expansion.Stormblood + }, + { + 8u, + MSQExpansionData.Expansion.Shadowbringers + }, + { + 9u, + MSQExpansionData.Expansion.Shadowbringers + }, + { + 10u, + MSQExpansionData.Expansion.Shadowbringers + }, + { + 11u, + MSQExpansionData.Expansion.Endwalker + }, + { + 12u, + MSQExpansionData.Expansion.Endwalker + }, + { + 13u, + MSQExpansionData.Expansion.Dawntrail + }, + { + 14u, + MSQExpansionData.Expansion.Dawntrail + } + }; + + public MSQProgressionService(IDataManager dataManager, IPluginLog log, QuestDetectionService questDetectionService, IClientState clientState, IFramework framework) + { + this.dataManager = dataManager; + this.log = log; + this.questDetectionService = questDetectionService; + this.clientState = clientState; + this.framework = framework; + InitializeMSQData(); + framework.RunOnTick(delegate + { + DebugCurrentCharacterQuest(); + }, default(TimeSpan), 60); + } + + private void InitializeMSQData() + { + try + { + log.Information("[MSQProgression] === INITIALIZING MSQ DATA ==="); + ExcelSheet questSheet = dataManager.GetExcelSheet(); + if (questSheet == null) + { + log.Error("[MSQProgression] Failed to load Quest sheet from Lumina!"); + return; + } + int totalQuests = questSheet.Count(); + log.Information($"[MSQProgression] ✓ Lumina Quest Sheet loaded: {totalQuests} total quests"); + int manualCount = 0; + foreach (Quest item in questSheet) + { + _ = item; + manualCount++; + } + log.Information($"[MSQProgression] Manual iteration count: {manualCount} quests"); + List highIdQuests = questSheet.Where((Quest q) => q.RowId > 66000).ToList(); + log.Information($"[MSQProgression] Quests with RowId > 66000: {highIdQuests.Count}"); + if (highIdQuests.Count > 0) + { + Quest firstHighId = highIdQuests.First(); + log.Information($"[MSQProgression] First High ID Quest: {firstHighId.RowId}"); + log.Information($"[MSQProgression] - Name: {firstHighId.Name}"); + log.Information($"[MSQProgression] - Expansion.RowId: {firstHighId.Expansion.RowId}"); + log.Information($"[MSQProgression] - JournalGenre.RowId: {firstHighId.JournalGenre.RowId}"); + } + log.Information("[MSQProgression] Analyzing JournalGenre distribution..."); + foreach (IGrouping group in (from q in questSheet + where q.RowId != 0 + group q by q.JournalGenre.RowId into g + orderby g.Key + select g).Take(10)) + { + log.Information($"[MSQProgression] Genre {group.Key}: {group.Count()} quests"); + } + log.Information("[MSQProgression] Filtering MSQ quests by JournalGenre categories (1-14)..."); + mainScenarioQuests = (from q in questSheet + where ((ReadOnlySpan)MSQ_JOURNAL_GENRE_IDS).Contains(q.JournalGenre.RowId) + orderby q.RowId + select q).ToList(); + log.Information($"[MSQProgression] ✓ Found {mainScenarioQuests.Count} total MSQ quests across all expansions!"); + if (mainScenarioQuests.Count == 0) + { + log.Error("[MSQProgression] No MSQ quests found! JournalGenre filter may be incorrect."); + return; + } + log.Information("[MSQProgression] === DETAILED MSQ QUEST ANALYSIS (First 20) ==="); + foreach (Quest quest in mainScenarioQuests.Take(20)) + { + log.Information($"[MSQProgression] Quest {quest.RowId}:"); + log.Information($"[MSQProgression] - Name: {quest.Name}"); + log.Information($"[MSQProgression] - Expansion.RowId: {quest.Expansion.RowId}"); + try + { + string expansionName = quest.Expansion.Value.Name.ToString(); + log.Information("[MSQProgression] - Expansion.Name: " + expansionName); + } + catch (Exception ex) + { + log.Information("[MSQProgression] - Expansion.Name: ERROR - " + ex.Message); + } + log.Information($"[MSQProgression] - JournalGenre.RowId: {quest.JournalGenre.RowId}"); + } + log.Information("[MSQProgression] === MSQ QUESTS BY JOURNALGENRE (EXPANSION) ==="); + foreach (IGrouping group2 in from q in mainScenarioQuests + group q by q.JournalGenre.RowId into g + orderby g.Key + select g) + { + uint genreId = group2.Key; + MSQExpansionData.Expansion expansion = JournalGenreToExpansion.GetValueOrDefault(genreId, MSQExpansionData.Expansion.ARealmReborn); + string genreName = group2.First().JournalGenre.Value.Name.ToString(); + string sampleQuests = string.Join(", ", from q in group2.Take(3) + select q.RowId); + log.Information($"[MSQProgression] JournalGenre {genreId} ({expansion}):"); + log.Information("[MSQProgression] - Name: " + genreName); + log.Information($"[MSQProgression] - Count: {group2.Count()} quests"); + log.Information("[MSQProgression] - Samples: " + sampleQuests + "..."); + } + log.Information("[MSQProgression] === MSQ QUESTS BY EXPANSION (GROUPED) ==="); + foreach (IGrouping group3 in from q in mainScenarioQuests + group q by JournalGenreToExpansion.GetValueOrDefault(q.JournalGenre.RowId, MSQExpansionData.Expansion.ARealmReborn) into g + orderby g.Key + select g) + { + log.Information($"[MSQProgression] {group3.Key}: {group3.Count()} quests total"); + } + MSQExpansionData.ClearQuests(); + log.Information("[MSQProgression] Building expansion quest mappings..."); + foreach (Quest quest2 in mainScenarioQuests) + { + string name = quest2.Name.ToString(); + if (!string.IsNullOrEmpty(name)) + { + questNameCache[quest2.RowId] = name; + } + MSQExpansionData.Expansion expansion2 = JournalGenreToExpansion.GetValueOrDefault(quest2.JournalGenre.RowId, MSQExpansionData.Expansion.ARealmReborn); + if (quest2.JournalGenre.RowId != 2 || quest2.RowId <= 65964) + { + MSQExpansionData.RegisterQuest(quest2.RowId, expansion2); + string shortName = MSQExpansionData.GetExpansionShortName(expansion2); + if (!questsByExpansion.ContainsKey(shortName)) + { + questsByExpansion[shortName] = new List(); + } + questsByExpansion[shortName].Add(quest2); + } + } + log.Information("[MSQProgression] === EXPANSION BREAKDOWN ==="); + foreach (MSQExpansionData.Expansion exp in MSQExpansionData.GetAllExpansions()) + { + string shortName2 = MSQExpansionData.GetExpansionShortName(exp); + List quests = questsByExpansion.GetValueOrDefault(shortName2); + int count = quests?.Count ?? 0; + if (count > 0 && quests != null) + { + string sampleIds = string.Join(", ", from q in quests.Take(5) + select q.RowId); + log.Information($"[MSQProgression] ✓ {MSQExpansionData.GetExpansionName(exp)} ({shortName2}): {count} quests (IDs: {sampleIds}...)"); + } + else + { + log.Warning($"[MSQProgression] ⚠ {MSQExpansionData.GetExpansionName(exp)} ({shortName2}): {count} quests (EMPTY!)"); + } + } + log.Information("[MSQProgression] === MSQ DATA INITIALIZATION COMPLETE ==="); + } + catch (Exception ex2) + { + log.Error("[MSQProgression] EXCEPTION during MSQ data initialization: " + ex2.Message); + log.Error("[MSQProgression] Stack trace: " + ex2.StackTrace); + } + } + + public (uint questId, string questName) GetLastCompletedMSQ(string characterName) + { + if (mainScenarioQuests == null || mainScenarioQuests.Count == 0) + { + return (questId: 0u, questName: "—"); + } + try + { + List completedQuests = questDetectionService.GetAllCompletedQuestIds(); + Quest lastMSQ = (from q in mainScenarioQuests + where completedQuests.Contains(q.RowId) + orderby q.RowId descending + select q).FirstOrDefault(); + if (lastMSQ.RowId != 0) + { + string questName = questNameCache.GetValueOrDefault(lastMSQ.RowId, "Unknown Quest"); + return (questId: lastMSQ.RowId, questName: questName); + } + } + catch (Exception ex) + { + log.Error("[MSQProgression] Failed to get last completed MSQ: " + ex.Message); + } + return (questId: 0u, questName: "—"); + } + + public float GetMSQCompletionPercentage() + { + if (mainScenarioQuests == null || mainScenarioQuests.Count == 0) + { + return 0f; + } + try + { + List completedQuests = questDetectionService.GetAllCompletedQuestIds(); + return (float)mainScenarioQuests.Count((Quest q) => completedQuests.Contains(q.RowId)) / (float)mainScenarioQuests.Count * 100f; + } + catch (Exception ex) + { + log.Error("[MSQProgression] Failed to calculate MSQ completion: " + ex.Message); + return 0f; + } + } + + public int GetTotalMSQCount() + { + return mainScenarioQuests?.Count ?? 0; + } + + public int GetCompletedMSQCount() + { + if (mainScenarioQuests == null || mainScenarioQuests.Count == 0) + { + return 0; + } + try + { + List completedQuests = questDetectionService.GetAllCompletedQuestIds(); + return mainScenarioQuests.Count((Quest q) => completedQuests.Contains(q.RowId)); + } + catch (Exception ex) + { + log.Error("[MSQProgression] Failed to get completed MSQ count: " + ex.Message); + return 0; + } + } + + public string GetQuestName(uint questId) + { + return questNameCache.GetValueOrDefault(questId, "Unknown Quest"); + } + + public bool IsMSQ(uint questId) + { + return mainScenarioQuests?.Any((Quest q) => q.RowId == questId) ?? false; + } + + public ExpansionInfo? GetExpansionForQuest(uint questId) + { + MSQExpansionData.Expansion expansion = MSQExpansionData.GetExpansionForQuest(questId); + return new ExpansionInfo + { + Name = MSQExpansionData.GetExpansionName(expansion), + ShortName = MSQExpansionData.GetExpansionShortName(expansion), + MinQuestId = 0u, + MaxQuestId = 0u, + ExpectedQuestCount = MSQExpansionData.GetExpectedQuestCount(expansion) + }; + } + + public List GetExpansions() + { + return (from exp in MSQExpansionData.GetAllExpansions() + select new ExpansionInfo + { + Name = MSQExpansionData.GetExpansionName(exp), + ShortName = MSQExpansionData.GetExpansionShortName(exp), + MinQuestId = 0u, + MaxQuestId = 0u, + ExpectedQuestCount = MSQExpansionData.GetExpectedQuestCount(exp) + }).ToList(); + } + + public (int completed, int total) GetExpansionProgress(string expansionShortName) + { + List completedQuests = questDetectionService.GetAllCompletedQuestIds(); + List? obj = questsByExpansion.GetValueOrDefault(expansionShortName) ?? new List(); + int completed = obj.Count((Quest q) => completedQuests.Contains(q.RowId)); + int total = obj.Count; + return (completed: completed, total: total); + } + + public ExpansionInfo? GetCurrentExpansion() + { + try + { + log.Information("[MSQProgression] ========================================"); + log.Information("[MSQProgression] === DETECTING CURRENT EXPANSION ==="); + log.Information("[MSQProgression] ========================================"); + List completedQuests = questDetectionService.GetAllCompletedQuestIds(); + log.Information($"[MSQProgression] Total completed quests: {completedQuests.Count}"); + log.Information("[MSQProgression] METHOD 1: Using AgentScenarioTree (Game Data)"); + log.Information("[MSQProgression] ------------------------------------------------"); + (MSQExpansionData.Expansion expansion, string debugInfo) currentExpansionFromGameWithDebug = MSQExpansionData.GetCurrentExpansionFromGameWithDebug(); + MSQExpansionData.Expansion gameExpansion = currentExpansionFromGameWithDebug.expansion; + string[] array = currentExpansionFromGameWithDebug.debugInfo.Split('\n'); + foreach (string line in array) + { + if (!string.IsNullOrWhiteSpace(line)) + { + log.Information("[MSQProgression] " + line); + } + } + log.Information("[MSQProgression] Game Data Result: " + MSQExpansionData.GetExpansionName(gameExpansion)); + log.Information("[MSQProgression] METHOD 2: Using Completed Quests Analysis"); + log.Information("[MSQProgression] ------------------------------------------------"); + MSQExpansionData.Expansion analysisExpansion = MSQExpansionData.GetCurrentExpansion(completedQuests); + log.Information("[MSQProgression] Analysis Result: " + MSQExpansionData.GetExpansionName(analysisExpansion)); + array = MSQExpansionData.GetExpansionDetectionDebugInfo(completedQuests).Split('\n'); + foreach (string line2 in array) + { + if (!string.IsNullOrWhiteSpace(line2)) + { + log.Debug("[MSQProgression] " + line2); + } + } + log.Information("[MSQProgression] COMPARISON:"); + log.Information("[MSQProgression] Game Data: " + MSQExpansionData.GetExpansionName(gameExpansion)); + log.Information("[MSQProgression] Analysis: " + MSQExpansionData.GetExpansionName(analysisExpansion)); + MSQExpansionData.Expansion finalExpansion = gameExpansion; + if (gameExpansion == MSQExpansionData.Expansion.ARealmReborn && analysisExpansion != MSQExpansionData.Expansion.ARealmReborn) + { + log.Warning("[MSQProgression] Game data returned ARR but analysis found higher expansion!"); + log.Warning("[MSQProgression] Using analysis result: " + MSQExpansionData.GetExpansionName(analysisExpansion)); + finalExpansion = analysisExpansion; + } + log.Information("[MSQProgression] ========================================"); + log.Information("[MSQProgression] >>> FINAL EXPANSION: " + MSQExpansionData.GetExpansionName(finalExpansion) + " <<<"); + log.Information("[MSQProgression] ========================================"); + return new ExpansionInfo + { + Name = MSQExpansionData.GetExpansionName(finalExpansion), + ShortName = MSQExpansionData.GetExpansionShortName(finalExpansion), + MinQuestId = 0u, + MaxQuestId = 0u, + ExpectedQuestCount = MSQExpansionData.GetExpectedQuestCount(finalExpansion) + }; + } + catch (Exception ex) + { + log.Error("[MSQProgression] Error detecting expansion: " + ex.Message); + log.Error("[MSQProgression] Stack: " + ex.StackTrace); + return GetExpansions().FirstOrDefault(); + } + } + + public Dictionary GetExpansionProgressForCharacter(List completedQuestIds) + { + Dictionary result = new Dictionary(); + foreach (ExpansionInfo exp in GetExpansions()) + { + List expansionQuests = questsByExpansion.GetValueOrDefault(exp.ShortName) ?? new List(); + int completed = expansionQuests.Count((Quest q) => completedQuestIds.Contains(q.RowId)); + result[exp.ShortName] = (completed, expansionQuests.Count); + } + return result; + } + + public List GetAllMSQQuests() + { + return mainScenarioQuests ?? new List(); + } + + public Dictionary GetExpansionProgress() + { + Dictionary result = new Dictionary(); + List completedQuests = questDetectionService.GetAllCompletedQuestIds(); + foreach (MSQExpansionData.Expansion expansion in MSQExpansionData.GetAllExpansions()) + { + ExpansionProgress progress = MSQExpansionData.GetExpansionProgress(completedQuests, expansion); + result[progress.ExpansionName] = new ExpansionProgressInfo + { + ExpansionName = progress.ExpansionName, + ShortName = progress.ExpansionShortName, + TotalQuests = progress.ExpectedCount, + CompletedQuests = progress.CompletedCount, + Percentage = progress.Percentage + }; + } + return result; + } + + public Dictionary GetExpansionProgressForCharacter(List completedQuestIds) + { + Dictionary result = new Dictionary(); + uint result2; + List completedQuestIdsUint = (from id in completedQuestIds + select uint.TryParse(id, out result2) ? result2 : 0u into id + where id != 0 + select id).ToList(); + foreach (MSQExpansionData.Expansion expansion in MSQExpansionData.GetAllExpansions()) + { + ExpansionProgress progress = MSQExpansionData.GetExpansionProgress(completedQuestIdsUint, expansion); + result[progress.ExpansionName] = new ExpansionProgressInfo + { + ExpansionName = progress.ExpansionName, + ShortName = progress.ExpansionShortName, + TotalQuests = progress.ExpectedCount, + CompletedQuests = progress.CompletedCount, + Percentage = progress.Percentage + }; + } + return result; + } + + public ExpansionInfo? GetCurrentExpansion(uint lastCompletedQuestId) + { + MSQExpansionData.Expansion expansion = MSQExpansionData.GetExpansionForQuest(lastCompletedQuestId); + return new ExpansionInfo + { + Name = MSQExpansionData.GetExpansionName(expansion), + ShortName = MSQExpansionData.GetExpansionShortName(expansion), + MinQuestId = 0u, + MaxQuestId = 0u, + ExpectedQuestCount = MSQExpansionData.GetExpectedQuestCount(expansion) + }; + } + + private MSQExpansionData.Expansion ConvertLuminaExpansionToOurs(uint luminaExpansionId) + { + return luminaExpansionId switch + { + 0u => MSQExpansionData.Expansion.ARealmReborn, + 1u => MSQExpansionData.Expansion.Heavensward, + 2u => MSQExpansionData.Expansion.Stormblood, + 3u => MSQExpansionData.Expansion.Shadowbringers, + 4u => MSQExpansionData.Expansion.Endwalker, + 5u => MSQExpansionData.Expansion.Dawntrail, + _ => MSQExpansionData.Expansion.ARealmReborn, + }; + } + + public void DebugCurrentCharacterQuest() + { + try + { + log.Information("[MSQProgression] === DEBUG CURRENT CHARACTER QUEST ==="); + IPlayerCharacter player = clientState.LocalPlayer; + if (player == null) + { + log.Warning("[MSQProgression] LocalPlayer is null - not logged in yet?"); + framework.RunOnTick(delegate + { + DebugCurrentCharacterQuest(); + }, default(TimeSpan), 60); + return; + } + string characterName = player.Name.TextValue; + string worldName = player.HomeWorld.Value.Name.ToString(); + log.Information("[MSQProgression] Character: " + characterName + " @ " + worldName); + ExcelSheet questSheet = dataManager.GetExcelSheet(); + if (questSheet == null) + { + log.Error("[MSQProgression] Failed to load Quest sheet!"); + return; + } + List completedMSQQuests = new List(); + log.Information("[MSQProgression] Checking MSQ quest completion..."); + foreach (Quest quest in questSheet) + { + if (((ReadOnlySpan)MSQ_JOURNAL_GENRE_IDS).Contains(quest.JournalGenre.RowId) && QuestManager.IsQuestComplete((ushort)quest.RowId)) + { + completedMSQQuests.Add(quest); + } + } + log.Information($"[MSQProgression] Character has {completedMSQQuests.Count} completed MSQ quests"); + if (completedMSQQuests.Count == 0) + { + log.Warning("[MSQProgression] No completed MSQ quests found!"); + return; + } + Quest latestMSQQuest = completedMSQQuests.OrderByDescending((Quest quest2) => quest2.RowId).First(); + log.Information($"[MSQProgression] Latest completed MSQ quest ID: {latestMSQQuest.RowId}"); + Quest questData = latestMSQQuest; + log.Information("[MSQProgression] === LATEST MSQ QUEST DETAILS ==="); + log.Information($"[MSQProgression] Quest ID: {questData.RowId}"); + log.Information($"[MSQProgression] Quest Name: {questData.Name}"); + log.Information($"[MSQProgression] JournalGenre.RowId: {questData.JournalGenre.RowId}"); + try + { + string genreName = questData.JournalGenre.Value.Name.ToString(); + log.Information("[MSQProgression] JournalGenre.Name: " + genreName); + } + catch + { + log.Information("[MSQProgression] JournalGenre.Name: ERROR"); + } + log.Information($"[MSQProgression] Expansion.RowId: {questData.Expansion.RowId}"); + try + { + string expansionName = questData.Expansion.Value.Name.ToString(); + log.Information("[MSQProgression] Expansion.Name: " + expansionName); + } + catch + { + log.Information("[MSQProgression] Expansion.Name: ERROR"); + } + log.Information("[MSQProgression] === CHARACTER IS IN THIS EXPANSION ==="); + log.Information($"[MSQProgression] Character is at Quest {questData.RowId} which is in:"); + log.Information($"[MSQProgression] - JournalGenre: {questData.JournalGenre.RowId}"); + log.Information($"[MSQProgression] - Expansion: {questData.Expansion.RowId}"); + log.Information("[MSQProgression] === RECENT COMPLETED MSQ QUESTS (Last 10) ==="); + foreach (Quest q in completedMSQQuests.OrderByDescending((Quest quest2) => quest2.RowId).Take(10).ToList()) + { + log.Information($"[MSQProgression] Quest {q.RowId}: {q.Name} (Genre: {q.JournalGenre.RowId}, Exp: {q.Expansion.RowId})"); + } + log.Information("[MSQProgression] === COMPLETED MSQ QUESTS BY EXPANSION ==="); + foreach (IGrouping group in from quest2 in completedMSQQuests + group quest2 by JournalGenreToExpansion.GetValueOrDefault(quest2.JournalGenre.RowId, MSQExpansionData.Expansion.ARealmReborn) into g + orderby g.Key + select g) + { + log.Information($"[MSQProgression] {group.Key}: {group.Count()} quests completed"); + } + } + catch (Exception ex) + { + log.Error("[MSQProgression] ERROR in DebugCurrentCharacterQuest: " + ex.Message); + log.Error("[MSQProgression] Stack trace: " + ex.StackTrace); + } + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/MemoryHelper.cs b/QuestionableCompanion/QuestionableCompanion.Services/MemoryHelper.cs new file mode 100644 index 0000000..7318241 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/MemoryHelper.cs @@ -0,0 +1,119 @@ +using System; +using System.Runtime.InteropServices; +using System.Text; +using Dalamud.Hooking; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using FFXIVClientStructs.FFXIV.Client.System.String; +using FFXIVClientStructs.FFXIV.Client.UI; + +namespace QuestionableCompanion.Services; + +public class MemoryHelper : IDisposable +{ + public unsafe delegate void RidePillionDelegate(BattleChara* target, int seatIndex); + + private readonly IPluginLog log; + + private Hook? ridePillionHook; + + private const string RidePillionSignature = "48 85 C9 0F 84 ?? ?? ?? ?? 48 89 6C 24 ?? 56 48 83 EC"; + + public RidePillionDelegate? RidePillion { get; private set; } + + public unsafe MemoryHelper(IPluginLog log, IGameInteropProvider gameInterop) + { + this.log = log; + try + { + ridePillionHook = gameInterop.HookFromSignature("48 85 C9 0F 84 ?? ?? ?? ?? 48 89 6C 24 ?? 56 48 83 EC", RidePillionDetour); + if (ridePillionHook != null && ridePillionHook.Address != IntPtr.Zero) + { + log.Information($"[MemoryHelper] RidePillion function found at 0x{ridePillionHook.Address:X}"); + RidePillion = ridePillionHook.Original; + log.Information("[MemoryHelper] RidePillion function initialized successfully"); + } + else + { + log.Warning("[MemoryHelper] RidePillion function not found - will fall back to commands"); + } + } + catch (Exception ex) + { + log.Error("[MemoryHelper] Error initializing RidePillion: " + ex.Message); + } + } + + private unsafe void RidePillionDetour(BattleChara* target, int seatIndex) + { + ridePillionHook?.Original(target, seatIndex); + } + + public unsafe bool ExecuteRidePillion(BattleChara* target, int seatIndex = 10) + { + if (RidePillion == null) + { + log.Warning("[MemoryHelper] RidePillion function not available"); + return false; + } + if (target == null) + { + log.Error("[MemoryHelper] RidePillion target is null"); + return false; + } + try + { + log.Information($"[MemoryHelper] Executing RidePillion on target (seat {seatIndex})"); + RidePillion(target, seatIndex); + return true; + } + catch (Exception ex) + { + log.Error("[MemoryHelper] RidePillion execution error: " + ex.Message); + return false; + } + } + + public unsafe bool SendChatMessage(string message) + { + try + { + UIModule* uiModule = UIModule.Instance(); + if (uiModule == null) + { + log.Error("[MemoryHelper] UIModule is null!"); + return false; + } + byte[] bytes = Encoding.UTF8.GetBytes(message); + nint mem1 = Marshal.AllocHGlobal(400); + nint mem2 = Marshal.AllocHGlobal(bytes.Length + 30); + try + { + Marshal.Copy(bytes, 0, mem2, bytes.Length); + Marshal.WriteByte(mem2 + bytes.Length, 0); + Marshal.WriteInt64(mem1, ((IntPtr)mem2).ToInt64()); + Marshal.WriteInt64(mem1 + 8, 64L); + Marshal.WriteInt64(mem1 + 8 + 8, bytes.Length + 1); + Marshal.WriteInt64(mem1 + 8 + 8 + 8, 0L); + uiModule->ProcessChatBoxEntry((Utf8String*)mem1); + log.Information("[MemoryHelper] Chat message sent: " + message); + return true; + } + finally + { + Marshal.FreeHGlobal(mem1); + Marshal.FreeHGlobal(mem2); + } + } + catch (Exception ex) + { + log.Error("[MemoryHelper] SendChatMessage error: " + ex.Message); + return false; + } + } + + public void Dispose() + { + ridePillionHook?.Dispose(); + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/MovementMonitorService.cs b/QuestionableCompanion/QuestionableCompanion.Services/MovementMonitorService.cs new file mode 100644 index 0000000..f01725c --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/MovementMonitorService.cs @@ -0,0 +1,167 @@ +using System; +using System.Numerics; +using Dalamud.Plugin.Services; + +namespace QuestionableCompanion.Services; + +public class MovementMonitorService : IDisposable +{ + private readonly IClientState clientState; + + private readonly IPluginLog log; + + private readonly ICommandManager commandManager; + + private readonly IFramework framework; + + private readonly Configuration config; + + private ChauffeurModeService? chauffeurMode; + + private Vector3 lastPosition = Vector3.Zero; + + private DateTime lastMovementTime = DateTime.Now; + + private DateTime lastCheckTime = DateTime.MinValue; + + private bool isMonitoring; + + private const float MovementThreshold = 0.1f; + + public bool IsMonitoring => isMonitoring; + + public MovementMonitorService(IClientState clientState, IPluginLog log, ICommandManager commandManager, IFramework framework, Configuration config) + { + this.clientState = clientState; + this.log = log; + this.commandManager = commandManager; + this.framework = framework; + this.config = config; + log.Information("[MovementMonitor] Service initialized"); + } + + public void SetChauffeurMode(ChauffeurModeService service) + { + chauffeurMode = service; + log.Information("[MovementMonitor] ChauffeurMode service linked for failsafe"); + } + + public void StartMonitoring() + { + if (!isMonitoring) + { + isMonitoring = true; + lastMovementTime = DateTime.Now; + lastCheckTime = DateTime.Now; + lastPosition = Vector3.Zero; + framework.Update += OnFrameworkUpdate; + log.Information("[MovementMonitor] Movement monitoring started"); + } + } + + public void StopMonitoring() + { + if (isMonitoring) + { + isMonitoring = false; + framework.Update -= OnFrameworkUpdate; + log.Information("[MovementMonitor] Movement monitoring stopped"); + } + } + + public void ResetMovementTimer() + { + lastMovementTime = DateTime.Now; + if (clientState.LocalPlayer != null) + { + lastPosition = clientState.LocalPlayer.Position; + } + } + + private void OnFrameworkUpdate(IFramework framework) + { + if (!isMonitoring) + { + return; + } + DateTime now = DateTime.Now; + if ((now - lastCheckTime).TotalSeconds < (double)config.MovementCheckInterval) + { + return; + } + lastCheckTime = now; + if (clientState.LocalPlayer == null || !clientState.IsLoggedIn) + { + return; + } + try + { + Vector3 currentPosition = clientState.LocalPlayer.Position; + if (lastPosition == Vector3.Zero) + { + lastPosition = currentPosition; + lastMovementTime = now; + return; + } + if (Vector3.Distance(lastPosition, currentPosition) > 0.1f) + { + lastMovementTime = now; + lastPosition = currentPosition; + return; + } + double timeSinceMovement = (now - lastMovementTime).TotalSeconds; + if (!(timeSinceMovement >= (double)config.MovementStuckThreshold)) + { + return; + } + log.Warning("[MovementMonitor] ========================================"); + log.Warning("[MovementMonitor] === PLAYER STUCK DETECTED ==="); + log.Warning("[MovementMonitor] ========================================"); + log.Warning($"[MovementMonitor] No movement for {timeSinceMovement:F1} seconds"); + log.Warning($"[MovementMonitor] Position: {currentPosition}"); + if (chauffeurMode != null && (chauffeurMode.IsWaitingForHelper || chauffeurMode.IsTransportingQuester)) + { + log.Warning("[MovementMonitor] FAILSAFE: Resetting Chauffeur Mode due to stuck detection!"); + chauffeurMode.ResetChauffeurState(); + } + log.Warning("[MovementMonitor] Sending /qst reload command..."); + framework.RunOnTick(delegate + { + try + { + commandManager.ProcessCommand("/qst reload"); + log.Information("[MovementMonitor] /qst reload command sent"); + } + catch (Exception ex2) + { + log.Error("[MovementMonitor] Failed to send reload command: " + ex2.Message); + } + }, TimeSpan.FromMilliseconds(100L, 0L)); + framework.RunOnTick(delegate + { + try + { + commandManager.ProcessCommand("/qst start"); + log.Information("[MovementMonitor] /qst start command sent"); + } + catch (Exception ex2) + { + log.Error("[MovementMonitor] Failed to send start command: " + ex2.Message); + } + }, TimeSpan.FromSeconds(1L)); + lastMovementTime = now; + lastPosition = currentPosition; + log.Information("[MovementMonitor] Movement timer reset - monitoring continues..."); + } + catch (Exception ex) + { + log.Error("[MovementMonitor] Error checking movement: " + ex.Message); + } + } + + public void Dispose() + { + StopMonitoring(); + log.Information("[MovementMonitor] Service disposed"); + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/MultiClientIPC.cs b/QuestionableCompanion/QuestionableCompanion.Services/MultiClientIPC.cs new file mode 100644 index 0000000..5ec8fee --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/MultiClientIPC.cs @@ -0,0 +1,230 @@ +using System; +using Dalamud.Plugin; +using Dalamud.Plugin.Ipc; +using Dalamud.Plugin.Services; + +namespace QuestionableCompanion.Services; + +public class MultiClientIPC : IDisposable +{ + private readonly IDalamudPluginInterface pluginInterface; + + private readonly IPluginLog log; + + private readonly ICallGateProvider requestHelperProvider; + + private readonly ICallGateProvider dismissHelperProvider; + + private readonly ICallGateProvider helperAvailableProvider; + + private readonly ICallGateProvider chatMessageProvider; + + private readonly ICallGateProvider passengerMountedProvider; + + private readonly ICallGateSubscriber requestHelperSubscriber; + + private readonly ICallGateSubscriber dismissHelperSubscriber; + + private readonly ICallGateSubscriber helperAvailableSubscriber; + + private readonly ICallGateSubscriber chatMessageSubscriber; + + private readonly ICallGateSubscriber passengerMountedSubscriber; + + public event Action? OnHelperRequested; + + public event Action? OnHelperDismissed; + + public event Action? OnHelperAvailable; + + public event Action? OnChatMessageReceived; + + public event Action? OnPassengerMounted; + + public MultiClientIPC(IDalamudPluginInterface pluginInterface, IPluginLog log) + { + this.pluginInterface = pluginInterface; + this.log = log; + requestHelperProvider = pluginInterface.GetIpcProvider("QSTCompanion.RequestHelper"); + dismissHelperProvider = pluginInterface.GetIpcProvider("QSTCompanion.DismissHelper"); + helperAvailableProvider = pluginInterface.GetIpcProvider("QSTCompanion.HelperAvailable"); + chatMessageProvider = pluginInterface.GetIpcProvider("QSTCompanion.ChatMessage"); + passengerMountedProvider = pluginInterface.GetIpcProvider("QSTCompanion.PassengerMounted"); + requestHelperSubscriber = pluginInterface.GetIpcSubscriber("QSTCompanion.RequestHelper"); + dismissHelperSubscriber = pluginInterface.GetIpcSubscriber("QSTCompanion.DismissHelper"); + helperAvailableSubscriber = pluginInterface.GetIpcSubscriber("QSTCompanion.HelperAvailable"); + chatMessageSubscriber = pluginInterface.GetIpcSubscriber("QSTCompanion.ChatMessage"); + passengerMountedSubscriber = pluginInterface.GetIpcSubscriber("QSTCompanion.PassengerMounted"); + requestHelperProvider.RegisterFunc(delegate(string name, ushort worldId) + { + OnRequestHelperReceived(name, worldId); + return (object?)null; + }); + dismissHelperProvider.RegisterFunc(delegate + { + OnDismissHelperReceived(); + return (object?)null; + }); + helperAvailableProvider.RegisterFunc(delegate(string name, ushort worldId) + { + OnHelperAvailableReceived(name, worldId); + return (object?)null; + }); + chatMessageProvider.RegisterFunc(delegate(string message) + { + OnChatMessageReceivedInternal(message); + return (object?)null; + }); + passengerMountedProvider.RegisterFunc(delegate(string questerName, ushort questerWorld) + { + OnPassengerMountedReceived(questerName, questerWorld); + return (object?)null; + }); + log.Information("[MultiClientIPC] ✅ IPC initialized successfully"); + } + + public void RequestHelper(string characterName, ushort worldId) + { + try + { + log.Information($"[MultiClientIPC] Broadcasting helper request: {characterName}@{worldId}"); + requestHelperSubscriber.InvokeFunc(characterName, worldId); + } + catch (Exception ex) + { + log.Error("[MultiClientIPC] Failed to send helper request: " + ex.Message); + } + } + + public void DismissHelper() + { + try + { + log.Information("[MultiClientIPC] Broadcasting helper dismiss"); + dismissHelperSubscriber.InvokeFunc(); + } + catch (Exception ex) + { + log.Error("[MultiClientIPC] Failed to send helper dismiss: " + ex.Message); + } + } + + private void OnRequestHelperReceived(string characterName, ushort worldId) + { + try + { + log.Information($"[MultiClientIPC] Received helper request: {characterName}@{worldId}"); + this.OnHelperRequested?.Invoke(characterName, worldId); + } + catch (Exception ex) + { + log.Error("[MultiClientIPC] Error handling helper request: " + ex.Message); + } + } + + private void OnDismissHelperReceived() + { + try + { + log.Information("[MultiClientIPC] Received helper dismiss"); + this.OnHelperDismissed?.Invoke(); + } + catch (Exception ex) + { + log.Error("[MultiClientIPC] Error handling helper dismiss: " + ex.Message); + } + } + + public void AnnounceHelperAvailable(string characterName, ushort worldId) + { + try + { + log.Information($"[MultiClientIPC] Broadcasting helper availability: {characterName}@{worldId}"); + helperAvailableSubscriber.InvokeFunc(characterName, worldId); + } + catch (Exception ex) + { + log.Error("[MultiClientIPC] Failed to announce helper: " + ex.Message); + } + } + + private void OnHelperAvailableReceived(string characterName, ushort worldId) + { + try + { + log.Information($"[MultiClientIPC] Received helper available: {characterName}@{worldId}"); + this.OnHelperAvailable?.Invoke(characterName, worldId); + } + catch (Exception ex) + { + log.Error("[MultiClientIPC] Error handling helper available: " + ex.Message); + } + } + + public void SendChatMessage(string message) + { + try + { + log.Information("[MultiClientIPC] Broadcasting chat message: " + message); + chatMessageSubscriber.InvokeFunc(message); + } + catch (Exception ex) + { + log.Error("[MultiClientIPC] Failed to send chat message: " + ex.Message); + } + } + + private void OnChatMessageReceivedInternal(string message) + { + try + { + log.Information("[MultiClientIPC] Received chat message: " + message); + this.OnChatMessageReceived?.Invoke(message); + } + catch (Exception ex) + { + log.Error("[MultiClientIPC] Error handling chat message: " + ex.Message); + } + } + + public void SendPassengerMounted(string questerName, ushort questerWorld) + { + try + { + log.Information($"[MultiClientIPC] Broadcasting passenger mounted: {questerName}@{questerWorld}"); + passengerMountedSubscriber.InvokeFunc(questerName, questerWorld); + } + catch (Exception ex) + { + log.Error("[MultiClientIPC] Failed to send passenger mounted: " + ex.Message); + } + } + + private void OnPassengerMountedReceived(string questerName, ushort questerWorld) + { + try + { + log.Information($"[MultiClientIPC] Received passenger mounted: {questerName}@{questerWorld}"); + this.OnPassengerMounted?.Invoke(questerName, questerWorld); + } + catch (Exception ex) + { + log.Error("[MultiClientIPC] Error handling passenger mounted: " + ex.Message); + } + } + + public void Dispose() + { + try + { + requestHelperProvider.UnregisterFunc(); + dismissHelperProvider.UnregisterFunc(); + helperAvailableProvider.UnregisterFunc(); + chatMessageProvider.UnregisterFunc(); + } + catch (Exception ex) + { + log.Error("[MultiClientIPC] Error during dispose: " + ex.Message); + } + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/PartyInviteAutoAccept.cs b/QuestionableCompanion/QuestionableCompanion.Services/PartyInviteAutoAccept.cs new file mode 100644 index 0000000..8ebd8b9 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/PartyInviteAutoAccept.cs @@ -0,0 +1,148 @@ +using System; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace QuestionableCompanion.Services; + +public class PartyInviteAutoAccept : IDisposable +{ + private readonly IPluginLog log; + + private readonly IFramework framework; + + private readonly IGameGui gameGui; + + private readonly IPartyList partyList; + + private readonly Configuration configuration; + + private bool shouldAutoAccept; + + private DateTime autoAcceptUntil = DateTime.MinValue; + + public PartyInviteAutoAccept(IPluginLog log, IFramework framework, IGameGui gameGui, IPartyList partyList, Configuration configuration) + { + this.log = log; + this.framework = framework; + this.gameGui = gameGui; + this.partyList = partyList; + this.configuration = configuration; + framework.Update += OnFrameworkUpdate; + log.Information("[PartyInviteAutoAccept] Initialized"); + } + + public void EnableAutoAccept() + { + if (!configuration.IsHighLevelHelper && !configuration.IsQuester) + { + log.Debug("[PartyInviteAutoAccept] Not a helper or quester, ignoring auto-accept request"); + return; + } + shouldAutoAccept = true; + autoAcceptUntil = DateTime.Now.AddSeconds(30.0); + string role = (configuration.IsHighLevelHelper ? "Helper" : "Quester"); + log.Information("[PartyInviteAutoAccept] Auto-accept enabled for 30 seconds (" + role + ")"); + log.Information($"[PartyInviteAutoAccept] Will accept until: {autoAcceptUntil:HH:mm:ss}"); + log.Information("[PartyInviteAutoAccept] Will accept ALL party invites during this time!"); + } + + private unsafe void OnFrameworkUpdate(IFramework framework) + { + if (!shouldAutoAccept) + { + return; + } + if (DateTime.Now > autoAcceptUntil) + { + shouldAutoAccept = false; + log.Information("[PartyInviteAutoAccept] Auto-accept window expired"); + return; + } + try + { + string[] obj = new string[6] { "SelectYesno", "SelectYesNo", "_PartyInvite", "PartyInvite", "SelectString", "_Notification" }; + nint addonPtr = IntPtr.Zero; + string[] array = obj; + foreach (string name in array) + { + addonPtr = (nint)gameGui.GetAddonByName(name); + if (addonPtr != IntPtr.Zero) + { + break; + } + } + if (addonPtr == IntPtr.Zero) + { + if (DateTime.Now.Second % 5 != 0) + { + 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!"); + return; + } + if (!addon2->IsVisible) + { + log.Debug("[PartyInviteAutoAccept] Addon exists but not visible yet"); + return; + } + AtkValue* values = stackalloc AtkValue[1]; + *values = new AtkValue + { + Type = FFXIVClientStructs.FFXIV.Component.GUI.ValueType.Int, + Int = 0 + }; + addon2->FireCallback(1u, values); + AtkValue* values2 = stackalloc AtkValue[2]; + *values2 = new AtkValue + { + Type = FFXIVClientStructs.FFXIV.Component.GUI.ValueType.Int, + Int = 0 + }; + values2[1] = new AtkValue + { + Type = FFXIVClientStructs.FFXIV.Component.GUI.ValueType.UInt, + UInt = 0u + }; + addon2->FireCallback(2u, values2); + } + } + catch (Exception ex) + { + log.Error("[PartyInviteAutoAccept] Error: " + ex.Message); + log.Error("[PartyInviteAutoAccept] Stack: " + ex.StackTrace); + } + } + + public void Dispose() + { + framework.Update -= OnFrameworkUpdate; + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/PartyInviteService.cs b/QuestionableCompanion/QuestionableCompanion.Services/PartyInviteService.cs new file mode 100644 index 0000000..c1a4254 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/PartyInviteService.cs @@ -0,0 +1,193 @@ +using System; +using System.Text; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game.Group; +using FFXIVClientStructs.FFXIV.Client.System.String; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Client.UI.Info; +using FFXIVClientStructs.FFXIV.Client.UI.Shell; + +namespace QuestionableCompanion.Services; + +public class PartyInviteService +{ + private readonly IPluginLog log; + + private readonly IObjectTable objectTable; + + private readonly IClientState clientState; + + public PartyInviteService(IPluginLog log, IObjectTable objectTable, IClientState clientState) + { + this.log = log; + this.objectTable = objectTable; + this.clientState = clientState; + } + + public unsafe bool InviteToParty(string characterName, ushort worldId) + { + if (string.IsNullOrWhiteSpace(characterName)) + { + log.Error("[PartyInvite] Character name is null or empty!"); + return false; + } + if (worldId == 0) + { + log.Error("[PartyInvite] World ID is 0 (invalid)!"); + return false; + } + characterName = characterName.Trim(); + try + { + InfoModule* infoModule = InfoModule.Instance(); + if (infoModule == null) + { + log.Error("[PartyInvite] InfoModule is null!"); + return false; + } + InfoProxyPartyInvite* partyInviteProxy = (InfoProxyPartyInvite*)infoModule->GetInfoProxyById(InfoProxyId.PartyInvite); + if (partyInviteProxy == null) + { + log.Error("[PartyInvite] InfoProxyPartyInvite is null!"); + return false; + } + ulong contentId = 0uL; + log.Information($"[PartyInvite] Using name-based invite (ContentId=0, Name={characterName}, World={worldId})"); + log.Information($"[PartyInvite] Sending invite to {characterName}@{worldId} (ContentId: {contentId})"); + fixed (byte* namePtr = Encoding.UTF8.GetBytes(characterName + "\0")) + { + bool num = partyInviteProxy->InviteToParty(contentId, namePtr, worldId); + if (num) + { + log.Information($"[PartyInvite] ✓ Successfully sent invite to {characterName}@{worldId}"); + } + else + { + log.Warning($"[PartyInvite] ✗ Failed to send invite to {characterName}@{worldId}"); + } + return num; + } + } + catch (Exception ex) + { + log.Error("[PartyInvite] Exception: " + ex.Message); + log.Error("[PartyInvite] StackTrace: " + ex.StackTrace); + return false; + } + } + + public unsafe bool InviteToPartyByContentId(ulong contentId, ushort worldId) + { + try + { + InfoModule* infoModule = InfoModule.Instance(); + if (infoModule == null) + { + log.Error("[PartyInvite] InfoModule is null!"); + return false; + } + InfoProxyPartyInvite* partyInviteProxy = (InfoProxyPartyInvite*)infoModule->GetInfoProxyById(InfoProxyId.PartyInvite); + if (partyInviteProxy == null) + { + log.Error("[PartyInvite] InfoProxyPartyInvite is null!"); + return false; + } + log.Information($"[PartyInvite] Sending invite to ContentID {contentId}@{worldId}"); + bool num = partyInviteProxy->InviteToPartyContentId(contentId, worldId); + if (num) + { + log.Information($"[PartyInvite] Successfully sent invite to ContentID {contentId}@{worldId}"); + } + else + { + log.Warning($"[PartyInvite] Failed to send invite to ContentID {contentId}@{worldId}"); + } + return num; + } + catch (Exception ex) + { + log.Error("[PartyInvite] Exception: " + ex.Message); + log.Error("[PartyInvite] StackTrace: " + ex.StackTrace); + return false; + } + } + + public unsafe bool InviteToPartyInInstanceByContentId(ulong contentId) + { + try + { + InfoModule* infoModule = InfoModule.Instance(); + if (infoModule == null) + { + log.Error("[PartyInvite] InfoModule is null!"); + return false; + } + InfoProxyPartyInvite* partyInviteProxy = (InfoProxyPartyInvite*)infoModule->GetInfoProxyById(InfoProxyId.PartyInvite); + if (partyInviteProxy == null) + { + log.Error("[PartyInvite] InfoProxyPartyInvite is null!"); + return false; + } + log.Information($"[PartyInvite] Sending instance invite to ContentID {contentId}"); + bool num = partyInviteProxy->InviteToPartyInInstanceByContentId(contentId); + if (num) + { + log.Information($"[PartyInvite] Successfully sent instance invite to ContentID {contentId}"); + } + else + { + log.Warning($"[PartyInvite] Failed to send instance invite to ContentID {contentId}"); + } + return num; + } + catch (Exception ex) + { + log.Error("[PartyInvite] Exception: " + ex.Message); + log.Error("[PartyInvite] StackTrace: " + ex.StackTrace); + return false; + } + } + + public unsafe bool LeaveParty() + { + try + { + GroupManager* groupManager = GroupManager.Instance(); + if (groupManager == null) + { + log.Error("[PartyInvite] GroupManager is null!"); + return false; + } + GroupManager.Group* group = groupManager->GetGroup(); + if (group == null || group->MemberCount == 0) + { + log.Debug("[PartyInvite] Not in a party"); + return true; + } + log.Information($"[PartyInvite] Leaving party (Members: {group->MemberCount})"); + RaptureShellModule* shellModule = RaptureShellModule.Instance(); + if (shellModule == null) + { + log.Error("[PartyInvite] RaptureShellModule is null!"); + return false; + } + UIModule* uiModule = UIModule.Instance(); + if (uiModule == null) + { + log.Error("[PartyInvite] UIModule is null!"); + return false; + } + Utf8String* leaveCommand = Utf8String.FromString("/leave"); + shellModule->ExecuteCommandInner(leaveCommand, uiModule); + leaveCommand->Dtor(); + log.Information("[PartyInvite] Leave command executed successfully"); + return true; + } + catch (Exception ex) + { + log.Error("[PartyInvite] Exception: " + ex.Message); + log.Error("[PartyInvite] StackTrace: " + ex.StackTrace); + return false; + } + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/PluginLogger.cs b/QuestionableCompanion/QuestionableCompanion.Services/PluginLogger.cs new file mode 100644 index 0000000..55a228f --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/PluginLogger.cs @@ -0,0 +1,39 @@ +using System; +using Dalamud.Plugin.Services; + +namespace QuestionableCompanion.Services; + +public class PluginLogger +{ + private readonly IPluginLog dalamudLog; + + public PluginLogger(IPluginLog dalamudLog) + { + this.dalamudLog = dalamudLog; + } + + public void Debug(string message, string component = "Plugin") + { + dalamudLog.Debug(message); + } + + public void Information(string message, string component = "Plugin") + { + dalamudLog.Information(message); + } + + public void Warning(string message, string component = "Plugin") + { + dalamudLog.Warning(message); + } + + public void Error(string message, string component = "Plugin") + { + dalamudLog.Error(message); + } + + public void Error(Exception ex, string message, string component = "Plugin") + { + dalamudLog.Error(ex, message); + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/QuestDetectionService.cs b/QuestionableCompanion/QuestionableCompanion.Services/QuestDetectionService.cs new file mode 100644 index 0000000..8f5c2f6 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/QuestDetectionService.cs @@ -0,0 +1,256 @@ +using System; +using System.Collections.Generic; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Application.Network.WorkDefinitions; +using FFXIVClientStructs.FFXIV.Client.Game; + +namespace QuestionableCompanion.Services; + +public class QuestDetectionService : IDisposable +{ + private readonly IFramework framework; + + private readonly IPluginLog log; + + private readonly IClientState clientState; + + private readonly HashSet acceptedQuests = new HashSet(); + + private readonly HashSet completedQuests = new HashSet(); + + private HashSet completedQuestCache = new HashSet(); + + private DateTime lastCacheRefresh = DateTime.MinValue; + + private const int CACHE_REFRESH_MINUTES = 5; + + public event Action? QuestAccepted; + + public event Action? QuestCompleted; + + public QuestDetectionService(IFramework framework, IPluginLog log, IClientState clientState) + { + this.framework = framework; + this.log = log; + this.clientState = clientState; + framework.Update += OnFrameworkUpdate; + log.Information("[QuestDetection] Service initialized"); + } + + private void OnFrameworkUpdate(IFramework framework) + { + if (!clientState.IsLoggedIn) + { + return; + } + try + { + CheckQuestUpdates(); + } + catch (Exception ex) + { + log.Debug("[QuestDetection] Error in framework update: " + ex.Message); + } + } + + private unsafe void CheckQuestUpdates() + { + QuestManager* questManager = QuestManager.Instance(); + if (questManager == null) + { + log.Debug("[QuestDetection] QuestManager instance is null"); + return; + } + try + { + Span normalQuests = questManager->NormalQuests; + if (normalQuests.Length == 0) + { + log.Debug("[QuestDetection] NormalQuests array is empty"); + return; + } + int maxSlots = Math.Min(normalQuests.Length, 30); + for (int i = 0; i < maxSlots; i++) + { + try + { + QuestWork quest = normalQuests[i]; + if (quest.QuestId == 0) + { + continue; + } + uint questId = quest.QuestId; + if (!acceptedQuests.Contains(questId)) + { + if (!IsQuestComplete(questId)) + { + acceptedQuests.Add(questId); + string questName = GetQuestName(questId); + log.Information($"[QuestDetection] Quest Accepted: {questId} - {questName}"); + this.QuestAccepted?.Invoke(questId, questName); + } + } + else if (!completedQuests.Contains(questId) && IsQuestComplete(questId)) + { + completedQuests.Add(questId); + string questName2 = GetQuestName(questId); + log.Information($"[QuestDetection] Quest Completed: {questId} - {questName2}"); + this.QuestCompleted?.Invoke(questId, questName2); + } + } + catch (IndexOutOfRangeException) + { + log.Debug($"[QuestDetection] Index {i} out of range, stopping quest check"); + break; + } + catch (Exception ex2) + { + log.Debug($"[QuestDetection] Error checking quest slot {i}: {ex2.Message}"); + } + } + } + catch (Exception ex3) + { + log.Warning("[QuestDetection] Error accessing quest data: " + ex3.Message); + } + } + + private bool IsQuestComplete(uint questId) + { + try + { + return QuestManager.IsQuestComplete(questId); + } + catch + { + return false; + } + } + + public unsafe bool IsQuestCompletedDirect(uint questId) + { + try + { + if (QuestManager.Instance() == null) + { + log.Warning("[QuestDetection] QuestManager instance not available"); + return false; + } + bool isComplete = QuestManager.IsQuestComplete(questId); + log.Debug($"[QuestDetection] Quest {questId} completion status: {isComplete}"); + return isComplete; + } + catch (Exception ex) + { + log.Error($"[QuestDetection] Failed to check quest {questId}: {ex.Message}"); + return false; + } + } + + public unsafe List GetAllCompletedQuestIds() + { + List completed = new List(); + try + { + if (QuestManager.Instance() == null) + { + log.Warning("[QuestDetection] QuestManager instance not available"); + return completed; + } + log.Information("[QuestDetection] Scanning for completed quests..."); + foreach (var item in new List<(uint, uint)> + { + (1u, 3000u), + (65000u, 71000u) + }) + { + uint start = item.Item1; + uint end = item.Item2; + for (uint i = start; i <= end; i++) + { + try + { + if (QuestManager.IsQuestComplete(i)) + { + completed.Add(i); + } + } + catch + { + } + } + } + log.Information($"[QuestDetection] Retrieved {completed.Count} completed quests"); + } + catch (Exception ex) + { + log.Error("[QuestDetection] Error while fetching completed quests: " + ex.Message); + } + return completed; + } + + public void RefreshQuestCache() + { + try + { + log.Information("[QuestDetection] Refreshing quest cache..."); + List allCompleted = GetAllCompletedQuestIds(); + completedQuestCache = new HashSet(allCompleted); + lastCacheRefresh = DateTime.Now; + log.Information($"[QuestDetection] Quest cache refreshed with {completedQuestCache.Count} completed quests"); + } + catch (Exception ex) + { + log.Error("[QuestDetection] Failed to refresh quest cache: " + ex.Message); + } + } + + public bool IsQuestCompletedCached(uint questId) + { + if (completedQuestCache.Count == 0 || (DateTime.Now - lastCacheRefresh).TotalMinutes > 5.0) + { + RefreshQuestCache(); + } + return completedQuestCache.Contains(questId); + } + + private string GetQuestName(uint questId) + { + try + { + return $"Quest {questId}"; + } + catch + { + return $"Quest {questId}"; + } + } + + public void ResetTracking() + { + acceptedQuests.Clear(); + completedQuests.Clear(); + completedQuestCache.Clear(); + lastCacheRefresh = DateTime.MinValue; + log.Information("[QuestDetection] Tracking reset"); + } + + public bool IsQuestAccepted(uint questId) + { + return acceptedQuests.Contains(questId); + } + + public bool IsQuestCompleted(uint questId) + { + return completedQuests.Contains(questId); + } + + public void Dispose() + { + framework.Update -= OnFrameworkUpdate; + acceptedQuests.Clear(); + completedQuests.Clear(); + completedQuestCache.Clear(); + log.Information("[QuestDetection] Service disposed"); + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/QuestIdParser.cs b/QuestionableCompanion/QuestionableCompanion.Services/QuestIdParser.cs new file mode 100644 index 0000000..7782c0f --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/QuestIdParser.cs @@ -0,0 +1,68 @@ +using System.Text.RegularExpressions; + +namespace QuestionableCompanion.Services; + +public static class QuestIdParser +{ + private static readonly Regex EventQuestPattern = new Regex("^([A-Z])(\\d+)$", RegexOptions.Compiled); + + public static (string rawId, string eventQuestId) ParseQuestId(string questInput) + { + if (string.IsNullOrWhiteSpace(questInput)) + { + return (rawId: questInput, eventQuestId: questInput); + } + Match match = EventQuestPattern.Match(questInput); + if (match.Success) + { + _ = match.Groups[1].Value; + return (rawId: match.Groups[2].Value, eventQuestId: questInput); + } + return (rawId: questInput, eventQuestId: questInput); + } + + public static bool HasEventQuestPrefix(string questId) + { + if (string.IsNullOrWhiteSpace(questId)) + { + return false; + } + return EventQuestPattern.IsMatch(questId); + } + + public static string? GetEventQuestPrefix(string questId) + { + if (string.IsNullOrWhiteSpace(questId)) + { + return null; + } + Match match = EventQuestPattern.Match(questId); + if (!match.Success) + { + return null; + } + return match.Groups[1].Value; + } + + public static string GetNumericPart(string questId) + { + return ParseQuestId(questId).rawId; + } + + public static QuestIdType ClassifyQuestId(string questId) + { + if (string.IsNullOrWhiteSpace(questId)) + { + return QuestIdType.Invalid; + } + if (HasEventQuestPrefix(questId)) + { + return QuestIdType.EventQuest; + } + if (uint.TryParse(questId, out var _)) + { + return QuestIdType.Standard; + } + return QuestIdType.Unknown; + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/QuestIdType.cs b/QuestionableCompanion/QuestionableCompanion.Services/QuestIdType.cs new file mode 100644 index 0000000..e34a782 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/QuestIdType.cs @@ -0,0 +1,9 @@ +namespace QuestionableCompanion.Services; + +public enum QuestIdType +{ + Standard, + EventQuest, + Unknown, + Invalid +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/QuestPreCheckService.cs b/QuestionableCompanion/QuestionableCompanion.Services/QuestPreCheckService.cs new file mode 100644 index 0000000..0b51dd4 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/QuestPreCheckService.cs @@ -0,0 +1,301 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game; +using Newtonsoft.Json; + +namespace QuestionableCompanion.Services; + +public class QuestPreCheckService : IDisposable +{ + private readonly IPluginLog log; + + private readonly IClientState clientState; + + private readonly Configuration config; + + private readonly AutoRetainerIPC autoRetainerIPC; + + private Dictionary preCheckResults = new Dictionary(); + + private Dictionary> questDatabase = new Dictionary>(); + + private Dictionary lastRefreshByCharacter = new Dictionary(); + + private readonly TimeSpan refreshInterval = TimeSpan.FromMinutes(30L); + + private string QuestDatabasePath + { + get + { + global::_003C_003Ey__InlineArray5 buffer = default(global::_003C_003Ey__InlineArray5); + global::_003CPrivateImplementationDetails_003E.InlineArrayElementRef, string>(ref buffer, 0) = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + global::_003CPrivateImplementationDetails_003E.InlineArrayElementRef, string>(ref buffer, 1) = "XIVLauncher"; + global::_003CPrivateImplementationDetails_003E.InlineArrayElementRef, string>(ref buffer, 2) = "pluginConfigs"; + global::_003CPrivateImplementationDetails_003E.InlineArrayElementRef, string>(ref buffer, 3) = "QuestionableCompanion"; + global::_003CPrivateImplementationDetails_003E.InlineArrayElementRef, string>(ref buffer, 4) = "QuestDatabase.json"; + return Path.Combine(global::_003CPrivateImplementationDetails_003E.InlineArrayAsReadOnlySpan, string>(in buffer, 5)); + } + } + + public QuestPreCheckService(IPluginLog log, IClientState clientState, Configuration config, AutoRetainerIPC autoRetainerIPC) + { + this.log = log; + this.clientState = clientState; + this.config = config; + this.autoRetainerIPC = autoRetainerIPC; + LoadQuestDatabase(); + } + + private void LoadQuestDatabase() + { + try + { + EnsureQuestDatabasePath(); + if (!File.Exists(QuestDatabasePath)) + { + log.Information("[QuestPreCheck] Creating new quest database..."); + questDatabase = new Dictionary>(); + return; + } + string json = File.ReadAllText(QuestDatabasePath); + if (string.IsNullOrEmpty(json)) + { + questDatabase = new Dictionary>(); + return; + } + questDatabase = JsonConvert.DeserializeObject>>(json) ?? new Dictionary>(); + log.Information($"[QuestPreCheck] Loaded quest database for {questDatabase.Count} characters"); + } + catch (Exception ex) + { + log.Error("[QuestPreCheck] Error loading quest database: " + ex.Message); + questDatabase = new Dictionary>(); + } + } + + private void SaveQuestDatabase() + { + try + { + EnsureQuestDatabasePath(); + string json = JsonConvert.SerializeObject(questDatabase, Formatting.Indented); + File.WriteAllText(QuestDatabasePath, json); + log.Information($"[QuestPreCheck] Quest database saved ({questDatabase.Count} characters)"); + } + catch (Exception ex) + { + log.Error("[QuestPreCheck] Error saving quest database: " + ex.Message); + } + } + + private void EnsureQuestDatabasePath() + { + string directory = Path.GetDirectoryName(QuestDatabasePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + } + + public unsafe void ScanCurrentCharacterQuestStatus(bool verbose = false) + { + if (clientState.LocalPlayer == null) + { + log.Warning("[QuestPreCheck] No local player found"); + return; + } + string worldName = clientState.LocalPlayer.HomeWorld.Value.Name.ToString(); + string charName = $"{clientState.LocalPlayer.Name}@{worldName}"; + if (verbose) + { + log.Information("[QuestPreCheck] Scanning quest status for: " + charName); + } + if (!questDatabase.ContainsKey(charName)) + { + questDatabase[charName] = new Dictionary(); + } + if (QuestManager.Instance() == null) + { + log.Error("[QuestPreCheck] QuestManager not available"); + return; + } + int questsScanned = 0; + int questsCompleted = 0; + int questsChanged = 0; + List newlyCompleted = new List(); + List questsToScan = config.QuestPreCheckRange ?? new List(); + if (questsToScan.Count == 0) + { + for (uint questId = 1u; questId <= 4500; questId++) + { + questsToScan.Add(questId); + } + } + foreach (uint questId2 in questsToScan) + { + try + { + bool num = QuestManager.IsQuestComplete((ushort)(questId2 % 65536)); + questsScanned++; + if (num) + { + questsCompleted++; + if (!questDatabase[charName].GetValueOrDefault(questId2, defaultValue: false)) + { + questDatabase[charName][questId2] = true; + questsChanged++; + newlyCompleted.Add(questId2); + if (verbose) + { + log.Debug($"[QuestPreCheck] {charName} - Quest {questId2}: ✓ NEWLY COMPLETED"); + } + } + else + { + questDatabase[charName][questId2] = true; + } + } + if (verbose && questId2 % 500 == 0) + { + log.Debug($"[QuestPreCheck] Progress: {questId2}/{questsToScan.Count} quests scanned..."); + } + } + catch (Exception ex) + { + log.Error($"[QuestPreCheck] Error checking quest {questId2}: {ex.Message}"); + } + } + if (verbose) + { + log.Information($"[QuestPreCheck] Scan complete: {questsScanned} checked, {questsCompleted} completed, {questsChanged} changed"); + if (newlyCompleted.Count > 0) + { + log.Information("[QuestPreCheck] NEWLY COMPLETED: " + string.Join(", ", newlyCompleted)); + } + } + lastRefreshByCharacter[charName] = DateTime.Now; + SaveQuestDatabase(); + } + + public void RefreshQuestDatabasePeriodic() + { + if (clientState.LocalPlayer != null && clientState.IsLoggedIn) + { + string worldName = clientState.LocalPlayer.HomeWorld.Value.Name.ToString(); + string charName = $"{clientState.LocalPlayer.Name}@{worldName}"; + if (!lastRefreshByCharacter.TryGetValue(charName, out var lastRefresh) || DateTime.Now - lastRefresh >= refreshInterval) + { + log.Information("[QuestDB] === 30-MINUTE REFRESH TRIGGERED ==="); + log.Information("[QuestDB] Updating quest status for: " + charName); + ScanCurrentCharacterQuestStatus(verbose: true); + log.Information("[QuestDB] === 30-MINUTE REFRESH COMPLETE ==="); + } + } + } + + public void LogCompletedQuestsBeforeLogout() + { + if (clientState.LocalPlayer != null) + { + string worldName = clientState.LocalPlayer.HomeWorld.Value.Name.ToString(); + string charName = $"{clientState.LocalPlayer.Name}@{worldName}"; + log.Information("[QuestDB] Logging final quest status before logout: " + charName); + ScanCurrentCharacterQuestStatus(); + log.Information("[QuestDB] Final quest state saved for: " + charName); + } + } + + public Dictionary PerformPreRotationCheck(uint stopQuestId, List characters) + { + log.Information("[QuestPreCheck] === STARTING PRE-ROTATION QUEST VERIFICATION ==="); + log.Information($"[QuestPreCheck] Checking {characters.Count} characters for quest {stopQuestId}..."); + preCheckResults.Clear(); + foreach (string character in characters) + { + try + { + if (questDatabase.ContainsKey(character) && questDatabase[character].ContainsKey(stopQuestId)) + { + bool isCompleted = questDatabase[character][stopQuestId]; + preCheckResults[character] = isCompleted; + string status = (isCompleted ? "✓ COMPLETED" : "○ PENDING"); + log.Information($"[QuestPreCheck] {character}: {status} (from database)"); + } + else + { + log.Debug("[QuestPreCheck] " + character + ": Not in database, will check during rotation"); + preCheckResults[character] = false; + } + } + catch (Exception ex) + { + log.Error("[QuestPreCheck] Error checking " + character + ": " + ex.Message); + preCheckResults[character] = false; + } + } + log.Information("[QuestPreCheck] === PRE-ROTATION CHECK COMPLETE ==="); + return preCheckResults; + } + + public bool ShouldSkipCharacter(string characterName, uint questId) + { + if (preCheckResults.TryGetValue(characterName, out var isCompleted) && isCompleted) + { + log.Information($"[QuestPreCheck] Character {characterName} already completed quest {questId} - SKIPPING"); + return true; + } + bool completed = default(bool); + if (questDatabase.TryGetValue(characterName, out Dictionary quests) && quests.TryGetValue(questId, out completed) && completed) + { + log.Information($"[QuestPreCheck] Character {characterName} already completed quest {questId} (from DB) - SKIPPING"); + return true; + } + return false; + } + + public bool? GetQuestStatus(string characterName, uint questId) + { + if (questDatabase.TryGetValue(characterName, out Dictionary quests) && quests.TryGetValue(questId, out var isCompleted)) + { + return isCompleted; + } + return null; + } + + public List GetCompletedQuests(string characterName) + { + if (!questDatabase.TryGetValue(characterName, out Dictionary quests)) + { + return new List(); + } + return (from kvp in quests + where kvp.Value + select kvp.Key).ToList(); + } + + public void MarkQuestCompleted(string characterName, uint questId) + { + if (!questDatabase.ContainsKey(characterName)) + { + questDatabase[characterName] = new Dictionary(); + } + questDatabase[characterName][questId] = true; + SaveQuestDatabase(); + log.Information($"[QuestPreCheck] Marked quest {questId} as completed for {characterName}"); + } + + public void ClearPreCheckResults() + { + preCheckResults.Clear(); + log.Information("[QuestPreCheck] Pre-check results cleared"); + } + + public void Dispose() + { + SaveQuestDatabase(); + log.Information("[QuestPreCheck] Service disposed"); + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/QuestRotationExecutionService.cs b/QuestionableCompanion/QuestionableCompanion.Services/QuestRotationExecutionService.cs new file mode 100644 index 0000000..99cd0cd --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/QuestRotationExecutionService.cs @@ -0,0 +1,1425 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Dalamud.Game.ClientState.Conditions; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game; +using QuestionableCompanion.Models; + +namespace QuestionableCompanion.Services; + +public class QuestRotationExecutionService : IDisposable +{ + private readonly AutoRetainerIPC autoRetainerIpc; + + private readonly QuestTrackingService questTrackingService; + + private readonly IPluginLog log; + + private readonly IFramework framework; + + private readonly ICommandManager commandManager; + + private readonly ICondition condition; + + private readonly IClientState clientState; + + private readonly SubmarineManager submarineManager; + + private readonly QuestionableIPC questionableIPC; + + private readonly Configuration configuration; + + private DCTravelService? dcTravelService; + + private CharacterSafeWaitService? safeWaitService; + + private QuestPreCheckService? preCheckService; + + private MovementMonitorService? movementMonitor; + + private CombatDutyDetectionService? combatDutyDetection; + + private DeathHandlerService? deathHandler; + + private DungeonAutomationService? dungeonAutomation; + + private StepsOfFaithHandler? stepsOfFaithHandler; + + private readonly List stopPoints = new List(); + + private RotationState currentState = new RotationState(); + + private Dictionary> questCompletionByCharacter = new Dictionary>(); + + private Action? onDataChanged; + + private DateTime lastCheckTime = DateTime.MinValue; + + private const double CheckIntervalMs = 250.0; + + private DateTime lastSubmarineCheckTime = DateTime.MinValue; + + private bool waitingForQuestAcceptForSubmarines; + + private uint? lastDungeonQuestId; + + private int? lastDungeonSequence; + + private uint? lastSoloDutyQuestId; + + private const double CharacterLoginTimeoutSeconds = 60.0; + + private const double PhaseTimeoutSeconds = 120.0; + + private bool isRotationActive; + + public bool IsRotationActive => isRotationActive; + + public QuestRotationExecutionService(AutoRetainerIPC autoRetainerIpc, QuestTrackingService questTrackingService, SubmarineManager submarineManager, QuestionableIPC questionableIPC, Configuration configuration, IPluginLog log, IFramework framework, ICommandManager commandManager, ICondition condition, IClientState clientState, Action? onDataChanged = null) + { + this.autoRetainerIpc = autoRetainerIpc; + this.questTrackingService = questTrackingService; + this.submarineManager = submarineManager; + this.questionableIPC = questionableIPC; + this.configuration = configuration; + this.log = log; + this.framework = framework; + this.commandManager = commandManager; + this.condition = condition; + this.clientState = clientState; + this.onDataChanged = onDataChanged; + framework.Update += OnFrameworkUpdate; + log.Information("[QuestRotation] Service initialized"); + } + + public bool AddStopPoint(uint questId, byte? sequence = null) + { + if (stopPoints.Any((StopPoint sp) => sp.QuestId == questId && sp.Sequence == sequence)) + { + log.Warning($"[QuestRotation] Stop point {questId}" + (sequence.HasValue ? $" Seq {sequence.Value}" : "") + " already exists"); + return false; + } + StopPoint stopPoint = new StopPoint + { + QuestId = questId, + Sequence = sequence, + IsActive = false, + CreatedAt = DateTime.Now + }; + stopPoints.Add(stopPoint); + log.Information($"[QuestRotation] Added stop point: Quest {questId}"); + return true; + } + + public void ImportStopPointsFromQuestionable() + { + try + { + log.Information("[QuestRotation] Importing stop points from Questionable..."); + List stopQuestIds = questionableIPC.GetStopQuestList(); + Dictionary sequenceConditions = null; + try + { + sequenceConditions = questionableIPC.GetAllQuestSequenceStopConditions(); + } + catch (Exception ex) + { + log.Error("[QuestRotation] Wrong Questionable Version "); + log.Error("[QuestRotation] Import failed: " + ex.Message); + return; + } + stopPoints.Clear(); + if (stopQuestIds != null) + { + foreach (string questIdStr in stopQuestIds) + { + if (!uint.TryParse(questIdStr, out var questId)) + { + continue; + } + byte? sequence = null; + if (sequenceConditions != null && sequenceConditions.ContainsKey(questIdStr)) + { + try + { + object seqObj = sequenceConditions[questId.ToString()]; + if (seqObj != null) + { + sequence = Convert.ToByte(seqObj); + } + } + catch + { + } + } + stopPoints.Add(new StopPoint + { + QuestId = questId, + Sequence = sequence, + IsActive = false, + CreatedAt = DateTime.Now + }); + } + } + if (sequenceConditions != null) + { + foreach (KeyValuePair kvp in sequenceConditions) + { + if (uint.TryParse(kvp.Key, out var questId2) && !stopPoints.Any((StopPoint sp) => sp.QuestId == questId2)) + { + byte? sequence2 = null; + try + { + sequence2 = Convert.ToByte(kvp.Value); + } + catch + { + } + stopPoints.Add(new StopPoint + { + QuestId = questId2, + Sequence = sequence2, + IsActive = false, + CreatedAt = DateTime.Now + }); + } + } + } + log.Information($"[QuestRotation] Imported {stopPoints.Count} stop points"); + } + catch (Exception ex2) + { + log.Error("[QuestRotation] Failed to import stop points: " + ex2.Message); + } + } + + public bool RemoveStopPoint(uint questId) + { + if (isRotationActive) + { + log.Error($"[QuestRotation] Cannot remove stop point {questId} during active rotation!"); + return false; + } + StopPoint stopPoint = stopPoints.FirstOrDefault((StopPoint sp) => sp.QuestId == questId); + if (stopPoint == null) + { + log.Warning($"[QuestRotation] Stop point {questId} not found"); + return false; + } + stopPoints.Remove(stopPoint); + log.Information($"[QuestRotation] Removed stop point: Quest {questId}"); + return true; + } + + public bool StartRotation(uint questId, List characters) + { + if (characters == null || characters.Count == 0) + { + log.Error("[QuestRotation] Cannot start rotation: No characters selected"); + return false; + } + StopPoint stopPoint = stopPoints.FirstOrDefault((StopPoint sp) => sp.QuestId == questId); + if (stopPoint == null) + { + log.Error($"[QuestRotation] Cannot start rotation: Quest {questId} not in stop points"); + return false; + } + log.Information("[QuestRotation] Found stop point: " + stopPoint.DisplayName); + List remainingChars = new List(); + List completedChars = new List(); + if (questCompletionByCharacter.TryGetValue(questId, out List savedCompletedChars)) + { + log.Debug($"[QuestRotation] Quest {questId} has {savedCompletedChars.Count} characters marked as completed in saved data"); + } + else + { + log.Debug($"[QuestRotation] Quest {questId} has NO saved completion data"); + } + foreach (string character in characters) + { + if (HasCharacterCompletedQuest(questId, character)) + { + completedChars.Add(character); + log.Debug($"[QuestRotation] {character} already completed quest {questId} (from saved data)"); + } + else + { + remainingChars.Add(character); + log.Debug($"[QuestRotation] {character} needs to complete quest {questId}"); + } + } + if (remainingChars.Count == 0) + { + log.Information($"[QuestRotation] All characters have already completed quest {questId}"); + return false; + } + currentState = new RotationState + { + CurrentStopQuestId = questId, + SelectedCharacters = new List(characters), + RemainingCharacters = remainingChars, + CompletedCharacters = completedChars, + Phase = RotationPhase.InitializingFirstCharacter, + PhaseStartTime = DateTime.Now, + RotationStartTime = DateTime.Now, + HasQuestBeenAccepted = false + }; + stopPoint.IsActive = true; + isRotationActive = true; + combatDutyDetection?.SetRotationActive(active: true); + deathHandler?.SetRotationActive(active: true); + if (configuration.EnableMovementMonitor && movementMonitor != null && !movementMonitor.IsMonitoring) + { + movementMonitor.StartMonitoring(); + log.Information("[QuestRotation] Movement monitor started"); + } + log.Information("[QuestRotation] ═══ Starting Rotation ═══"); + log.Information($"[QuestRotation] Quest ID: {questId}"); + log.Information($"[QuestRotation] Total Characters: {characters.Count}"); + log.Information($"[QuestRotation] Remaining: {remainingChars.Count} | Completed: {completedChars.Count}"); + log.Information("[QuestRotation] Characters to process: " + string.Join(", ", remainingChars)); + return true; + } + + public bool StartRotationLevelOnly(List characters) + { + if (characters == null || characters.Count == 0) + { + log.Error("[QuestRotation] Cannot start rotation: No characters selected"); + return false; + } + StopConditionData levelStopCondition = questionableIPC.GetLevelStopCondition(); + if (levelStopCondition == null || !levelStopCondition.Enabled) + { + log.Error("[QuestRotation] Cannot start level-only rotation: Level stop condition not configured"); + return false; + } + log.Information($"[QuestRotation] Starting level-only rotation (target level: {levelStopCondition.TargetValue})"); + List remainingChars = new List(characters); + currentState = new RotationState + { + CurrentStopQuestId = 0u, + SelectedCharacters = new List(characters), + RemainingCharacters = remainingChars, + CompletedCharacters = new List(), + Phase = RotationPhase.InitializingFirstCharacter, + PhaseStartTime = DateTime.Now, + RotationStartTime = DateTime.Now, + HasQuestBeenAccepted = false + }; + isRotationActive = true; + combatDutyDetection?.SetRotationActive(active: true); + deathHandler?.SetRotationActive(active: true); + if (configuration.EnableMovementMonitor && movementMonitor != null && !movementMonitor.IsMonitoring) + { + movementMonitor.StartMonitoring(); + log.Information("[QuestRotation] Movement monitor started"); + } + log.Information("[QuestRotation] ═══ Starting Level-Only Rotation ═══"); + log.Information($"[QuestRotation] Target Level: {levelStopCondition.TargetValue}"); + log.Information($"[QuestRotation] Total Characters: {characters.Count}"); + log.Information("[QuestRotation] Characters to process: " + string.Join(", ", remainingChars)); + return true; + } + + public bool StartSyncRotation(List characters) + { + if (characters == null || characters.Count == 0) + { + log.Error("[QuestRotation] Cannot start sync rotation: No characters provided"); + return false; + } + List charactersToSync = new List(); + foreach (string character in characters) + { + if (GetCompletedQuestsByCharacter(character).Count == 0) + { + charactersToSync.Add(character); + } + } + if (charactersToSync.Count == 0) + { + log.Information("[QuestRotation] No characters need sync - all have existing data"); + return false; + } + log.Information("[QuestRotation] ═══ Starting Sync Rotation ═══"); + log.Information($"[QuestRotation] Characters to sync: {charactersToSync.Count}"); + log.Information("[QuestRotation] Characters: " + string.Join(", ", charactersToSync)); + currentState = new RotationState + { + CurrentStopQuestId = 0u, + SelectedCharacters = new List(charactersToSync), + RemainingCharacters = new List(charactersToSync), + CompletedCharacters = new List(), + Phase = RotationPhase.InitializingFirstCharacter, + PhaseStartTime = DateTime.Now, + RotationStartTime = DateTime.Now, + HasQuestBeenAccepted = false + }; + isRotationActive = true; + log.Information("[QuestRotation] Sync rotation started successfully!"); + return true; + } + + public RotationState GetCurrentState() + { + return currentState; + } + + public (int completed, int total) GetRotationProgress(uint questId) + { + if (currentState.SelectedCharacters.Count == 0) + { + return (completed: 0, total: 0); + } + return GetRotationProgress(questId, currentState.SelectedCharacters); + } + + public (int completed, int total) GetRotationProgress(uint questId, List characters) + { + if (characters == null || characters.Count == 0) + { + return (completed: 0, total: 0); + } + int completed = 0; + foreach (string character in characters) + { + if (HasCharacterCompletedQuest(questId, character)) + { + completed++; + } + } + return (completed: completed, total: characters.Count); + } + + public List GetAllStopPoints() + { + return new List(stopPoints); + } + + public void LoadQuestCompletionData(Dictionary> data) + { + if (data != null && data.Count > 0) + { + questCompletionByCharacter = new Dictionary>(data); + log.Information($"[QuestRotation] Loaded quest completion data for {data.Count} quests"); + int count = 0; + foreach (KeyValuePair> kvp in data) + { + if (count < 5) + { + log.Information($"[QuestRotation] DEBUG: Quest {kvp.Key} -> {kvp.Value.Count} characters: {string.Join(", ", kvp.Value)}"); + count++; + } + } + if (data.Count > 5) + { + log.Information($"[QuestRotation] DEBUG: ... and {data.Count - 5} more quests"); + } + } + else + { + log.Information("[QuestRotation] No quest completion data to load (empty or null)"); + } + } + + public Dictionary> GetQuestCompletionData() + { + return new Dictionary>(questCompletionByCharacter); + } + + public void SetDCTravelService(DCTravelService service) + { + dcTravelService = service; + log.Information("[QuestRotation] DC Travel service linked"); + } + + public void SetSafeWaitService(CharacterSafeWaitService service) + { + safeWaitService = service; + log.Information("[QuestRotation] Safe Wait service linked"); + } + + public void SetPreCheckService(QuestPreCheckService service) + { + preCheckService = service; + log.Information("[QuestRotation] PreCheck service linked"); + } + + public void SetMovementMonitor(MovementMonitorService service) + { + movementMonitor = service; + log.Information("[QuestRotation] Movement Monitor service linked"); + } + + public void SetCombatDutyDetection(CombatDutyDetectionService service) + { + combatDutyDetection = service; + log.Information("[QuestRotation] Combat/Duty Detection service linked"); + } + + public void SetDeathHandler(DeathHandlerService service) + { + deathHandler = service; + log.Information("[QuestRotation] Death Handler service linked"); + } + + public void SetDungeonAutomation(DungeonAutomationService service) + { + dungeonAutomation = service; + log.Information("[QuestRotation] Dungeon Automation service linked"); + } + + public void SetStepsOfFaithHandler(StepsOfFaithHandler service) + { + stepsOfFaithHandler = service; + log.Information("[QuestRotation] Steps of Faith Handler service linked"); + } + + private void MarkQuestCompleted(uint questId, string characterName) + { + if (!questCompletionByCharacter.ContainsKey(questId)) + { + questCompletionByCharacter[questId] = new List(); + } + if (!questCompletionByCharacter[questId].Contains(characterName)) + { + questCompletionByCharacter[questId].Add(characterName); + log.Debug($"[QuestRotation] Marked {characterName} as completed quest {questId}"); + onDataChanged?.Invoke(); + } + } + + public List GetCompletedQuestsByCharacter(string characterName) + { + List completedQuests = new List(); + foreach (KeyValuePair> kvp in questCompletionByCharacter) + { + if (kvp.Value.Contains(characterName)) + { + completedQuests.Add(kvp.Key); + } + } + if (preCheckService != null) + { + foreach (uint questId in preCheckService.GetCompletedQuests(characterName)) + { + if (!completedQuests.Contains(questId)) + { + completedQuests.Add(questId); + } + } + } + return completedQuests; + } + + private bool HasCharacterCompletedQuest(uint questId, string characterName) + { + if (questCompletionByCharacter.TryGetValue(questId, out List characters) && characters.Contains(characterName)) + { + return true; + } + if (preCheckService != null) + { + bool? status = preCheckService.GetQuestStatus(characterName, questId); + if (status.HasValue && status.Value) + { + return true; + } + } + return false; + } + + private void ScanAndSaveAllCompletedQuests(string characterName) + { + if (string.IsNullOrEmpty(characterName)) + { + return; + } + int questsScanned = 0; + int questsCompleted = 0; + try + { + for (uint questId = 65536u; questId < 72000; questId++) + { + try + { + if (QuestManager.IsQuestComplete(questId)) + { + MarkQuestCompleted(questId, characterName); + questsCompleted++; + } + questsScanned++; + } + catch + { + } + } + log.Information($"[QuestRotation] Scanned {questsScanned} quests, found {questsCompleted} completed for {characterName}"); + onDataChanged?.Invoke(); + framework.RunOnFrameworkThread(delegate + { + if (currentState.Phase == RotationPhase.ScanningQuests) + { + currentState.Phase = RotationPhase.CheckingQuestCompletion; + currentState.PhaseStartTime = DateTime.Now; + log.Information("[QuestRotation] Quest scan complete - moving to quest check"); + } + }); + } + catch (Exception ex) + { + log.Error("[QuestRotation] Error scanning quests for " + characterName + ": " + ex.Message); + } + } + + public void SyncQuestDataForCurrentCharacter() + { + IPlayerCharacter player = clientState.LocalPlayer; + if (player == null) + { + log.Warning("[QuestRotation] No character logged in - cannot sync quest data"); + return; + } + string characterName = $"{player.Name}@{player.HomeWorld.Value.Name}"; + log.Information("[QuestRotation] Starting quest data sync for " + characterName); + ScanAndSaveAllCompletedQuests(characterName); + } + + public void AbortRotation() + { + log.Information("[QuestRotation] Aborting rotation"); + foreach (StopPoint stopPoint in stopPoints) + { + stopPoint.IsActive = false; + } + currentState = new RotationState + { + Phase = RotationPhase.Idle + }; + isRotationActive = false; + combatDutyDetection?.SetRotationActive(active: false); + combatDutyDetection?.Reset(); + deathHandler?.SetRotationActive(active: false); + deathHandler?.Reset(); + if (dungeonAutomation != null) + { + dungeonAutomation.Reset(); + log.Information("[QuestRotation] Dungeon automation reset"); + } + if (movementMonitor != null && movementMonitor.IsMonitoring) + { + movementMonitor.StopMonitoring(); + log.Information("[QuestRotation] Movement monitor stopped"); + } + } + + private void OnFrameworkUpdate(IFramework framework) + { + DateTime now = DateTime.Now; + if ((now - lastCheckTime).TotalMilliseconds < 250.0) + { + return; + } + lastCheckTime = now; + if (deathHandler != null && combatDutyDetection != null && !combatDutyDetection.IsInDuty) + { + deathHandler.Update(); + } + if (dungeonAutomation != null) + { + if (submarineManager.IsSubmarinePaused) + { + log.Debug("[QuestRotation] Submarine multi-mode active - skipping dungeon validation"); + } + else + { + 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) + { + _ = submarineManager.IsSubmarinePaused; + } + } + } + if (combatDutyDetection != null) + { + combatDutyDetection.Update(); + if (combatDutyDetection.JustEnteredDuty && isRotationActive) + { + string currentQuestIdStr = questionableIPC.GetCurrentQuestId(); + uint questId = 0u; + if (!string.IsNullOrEmpty(currentQuestIdStr)) + { + uint.TryParse(currentQuestIdStr, out questId); + } + combatDutyDetection.SetCurrentQuestId(questId); + bool isAutoDutyDungeon = dungeonAutomation != null && dungeonAutomation.IsInAutoDutyDungeon; + combatDutyDetection.SetAutoDutyDungeon(isAutoDutyDungeon); + if (isAutoDutyDungeon) + { + log.Information("[QuestRotation] AutoDuty Dungeon entered - /ad stop after 1s"); + dungeonAutomation?.OnDutyEntered(); + } + else if (questId != 0) + { + bool shouldLog = lastSoloDutyQuestId != questId; + if (questId == 811) + { + if (shouldLog) + { + log.Information("[QuestRotation] Quest 811 Solo Duty - stopping RSR, NO combat commands"); + lastSoloDutyQuestId = questId; + } + } + else if (shouldLog) + { + log.Information("[QuestRotation] Solo Duty entered - combat commands will activate after 8s"); + lastSoloDutyQuestId = questId; + } + if (questId == 4591 && stepsOfFaithHandler != null) + { + IPlayerCharacter player = clientState.LocalPlayer; + string characterName = ((player != null) ? $"{player.Name}@{player.HomeWorld.Value.Name}" : string.Empty); + if (stepsOfFaithHandler.ShouldActivate(questId, isInSoloDuty: true)) + { + log.Information("[QuestRotation] Triggering Steps of Faith handler (will wait for conditions inside)..."); + Task.Run(delegate + { + stepsOfFaithHandler.Execute(characterName); + }); + } + } + if (questId == 811) + { + try + { + commandManager.ProcessCommand("/rotation off"); + log.Information("[QuestRotation] ✓ /rotation off sent for Quest 811"); + } + catch (Exception ex) + { + log.Error("[QuestRotation] Failed to stop RSR: " + ex.Message); + } + } + } + } + if (combatDutyDetection.JustExitedDuty && isRotationActive && dungeonAutomation != null) + { + dungeonAutomation.OnDutyExited(); + if ((DateTime.Now - combatDutyDetection.DutyExitTime).TotalSeconds >= 8.0) + { + if (dungeonAutomation.IsInAutoDutyDungeon) + { + dungeonAutomation.DisbandParty(); + } + try + { + commandManager.ProcessCommand("/qst start"); + log.Information("[QuestRotation] ✓ /qst start (after duty exit)"); + } + catch (Exception ex2) + { + log.Error("[QuestRotation] Failed to restart quest: " + ex2.Message); + } + combatDutyDetection.ClearDutyExitFlag(); + } + } + if (combatDutyDetection.ShouldPauseAutomation && movementMonitor != null && movementMonitor.IsMonitoring) + { + movementMonitor.StopMonitoring(); + log.Debug("[QuestRotation] Movement monitor paused for combat/duty"); + } + else if (!combatDutyDetection.ShouldPauseAutomation && movementMonitor != null && !movementMonitor.IsMonitoring && isRotationActive && configuration.EnableMovementMonitor && currentState.Phase != RotationPhase.WaitingForCharacterLogin && currentState.Phase != RotationPhase.WaitingBeforeCharacterSwitch && currentState.Phase != RotationPhase.WaitingForHomeworldReturn && currentState.Phase != RotationPhase.ScanningQuests && currentState.Phase != RotationPhase.CheckingQuestCompletion && currentState.Phase != RotationPhase.InitializingFirstCharacter) + { + movementMonitor.StartMonitoring(); + log.Debug("[QuestRotation] Movement monitor resumed after combat/duty"); + } + } + if (submarineManager.IsSubmarineJustCompleted && !submarineManager.IsSubmarineCooldownActive()) + { + log.Information("[QuestRotation] Submarine cooldown expired - re-enabling submarine checks"); + submarineManager.ClearSubmarineJustCompleted(); + } + if (!isRotationActive) + { + return; + } + CheckLevelStopCondition(); + switch (currentState.Phase) + { + case RotationPhase.InitializingFirstCharacter: + HandleInitializingFirstCharacter(); + break; + case RotationPhase.WaitingForCharacterLogin: + HandleWaitingForCharacterLogin(); + break; + case RotationPhase.ScanningQuests: + HandleScanningQuests(); + break; + case RotationPhase.CheckingQuestCompletion: + HandleCheckingQuestCompletion(); + break; + case RotationPhase.DCTraveling: + if ((DateTime.Now - currentState.PhaseStartTime).TotalMinutes > 3.0) + { + log.Error("[QuestRotation] DC Travel timeout after 3 minutes - skipping character"); + SkipToNextCharacter(); + } + break; + case RotationPhase.WaitingForQuestStart: + case RotationPhase.QuestActive: + HandleQuestMonitoring(); + break; + case RotationPhase.WaitingBeforeCharacterSwitch: + HandleWaitingBeforeCharacterSwitch(); + break; + case RotationPhase.WaitingForHomeworldReturn: + HandleWaitingForHomeworldReturn(); + break; + case RotationPhase.Completed: + HandleCompleted(); + break; + case RotationPhase.Questing: + case RotationPhase.InCombat: + case RotationPhase.InDungeon: + case RotationPhase.HandlingSubmarines: + case RotationPhase.SyncingCharacterData: + case RotationPhase.WaitingForChauffeur: + case RotationPhase.TravellingWithChauffeur: + case RotationPhase.WaitingForNextCharacterSwitch: + case RotationPhase.Error: + break; + } + } + + private void CheckLevelStopCondition() + { + StopConditionData stopLevelData = questionableIPC.GetLevelStopCondition(); + if (stopLevelData == null || !stopLevelData.Enabled) + { + return; + } + int stopLevel = stopLevelData.TargetValue; + int currentLevel = 0; + if (clientState.LocalPlayer != null) + { + currentLevel = clientState.LocalPlayer.Level; + } + if (currentLevel < stopLevel) + { + return; + } + log.Information($"[QuestRotation] Level Stop Condition reached (Level {currentLevel} >= {stopLevel})"); + if (currentState.Phase == RotationPhase.Questing || currentState.Phase == RotationPhase.QuestActive || currentState.Phase == RotationPhase.WaitingForQuestStart) + { + log.Information("[QuestRotation] Level reached - stopping quest and switching character..."); + try + { + commandManager.ProcessCommand("/qst stop"); + log.Information("[QuestRotation] ✓ /qst stop (level stop condition)"); + } + catch (Exception ex) + { + log.Error("[QuestRotation] Failed to send /qst stop: " + ex.Message); + } + MarkCharacterCompleted(currentState.CurrentCharacter, "level reached"); + currentState.Phase = RotationPhase.WaitingForHomeworldReturn; + currentState.PhaseStartTime = DateTime.Now; + log.Information("[QuestRotation] Waiting 5s before homeworld return..."); + } + } + + private unsafe bool IsTerritoryLoaded() + { + GameMain* gameMain = GameMain.Instance(); + if (gameMain == null) + { + return false; + } + return gameMain->TerritoryLoadState == 2; + } + + private void HandleInitializingFirstCharacter() + { + if (currentState.RemainingCharacters.Count == 0) + { + log.Information("[QuestRotation] No remaining characters - rotation complete"); + currentState.Phase = RotationPhase.Completed; + isRotationActive = false; + combatDutyDetection?.SetRotationActive(active: false); + deathHandler?.SetRotationActive(active: false); + return; + } + string firstChar = currentState.RemainingCharacters[0]; + currentState.CurrentCharacter = firstChar; + log.Information("[QuestRotation] >>> Phase 1: Initializing first character: " + firstChar); + if (autoRetainerIpc.SwitchCharacter(firstChar)) + { + currentState.Phase = RotationPhase.WaitingForCharacterLogin; + currentState.PhaseStartTime = DateTime.Now; + log.Information("[QuestRotation] Character switch initiated to " + firstChar); + } + else + { + log.Error("[QuestRotation] Failed to switch to " + firstChar); + currentState.Phase = RotationPhase.Error; + currentState.ErrorMessage = "Failed to switch to " + firstChar; + } + } + + private void HandleWaitingForCharacterLogin() + { + if (movementMonitor != null && movementMonitor.IsMonitoring) + { + movementMonitor.StopMonitoring(); + log.Debug("[QuestRotation] Movement monitor stopped during character login"); + } + if ((DateTime.Now - currentState.PhaseStartTime).TotalSeconds > 60.0) + { + log.Error("[QuestRotation] Login timeout for " + currentState.CurrentCharacter); + SkipToNextCharacter(); + return; + } + string currentLoggedInChar = autoRetainerIpc.GetCurrentCharacter(); + if (string.IsNullOrEmpty(currentLoggedInChar) || !(currentLoggedInChar == currentState.CurrentCharacter) || (DateTime.Now - currentState.PhaseStartTime).TotalSeconds < 2.0 || (DateTime.Now - currentState.PhaseStartTime).TotalSeconds < 5.0) + { + return; + } + log.Information("[QuestRotation] >>> Phase 2: Successfully logged in as " + currentLoggedInChar); + if (preCheckService != null) + { + log.Information("[QuestRotation] Scanning quest status for current character..."); + try + { + preCheckService.ScanCurrentCharacterQuestStatus(); + } + catch (Exception ex) + { + log.Error("[QuestRotation] Error scanning quest status: " + ex.Message); + } + } + currentState.Phase = RotationPhase.ScanningQuests; + currentState.PhaseStartTime = DateTime.Now; + log.Information("[QuestRotation] Starting quest scan for " + currentState.CurrentCharacter + "..."); + Task.Run(delegate + { + ScanAndSaveAllCompletedQuests(currentState.CurrentCharacter); + }); + } + + private void HandleScanningQuests() + { + if ((DateTime.Now - currentState.PhaseStartTime).TotalSeconds > 30.0) + { + log.Warning("[QuestRotation] Quest scan timeout - proceeding anyway"); + currentState.Phase = RotationPhase.CheckingQuestCompletion; + currentState.PhaseStartTime = DateTime.Now; + } + } + + private void HandleCheckingQuestCompletion() + { + uint questId = currentState.CurrentStopQuestId; + bool isQuestComplete = false; + try + { + isQuestComplete = QuestManager.IsQuestComplete(questId); + } + catch (Exception ex) + { + log.Error("[QuestRotation] Error checking quest completion: " + ex.Message); + } + if (isQuestComplete) + { + log.Information($"[QuestRotation] {currentState.CurrentCharacter} already completed quest {questId} - skipping"); + MarkCharacterCompleted(currentState.CurrentCharacter, $"quest {questId} already complete"); + MarkQuestCompleted(questId, currentState.CurrentCharacter); + SkipToNextCharacter(); + return; + } + if (questId == 0) + { + StopConditionData stopLevelData = questionableIPC.GetLevelStopCondition(); + if (stopLevelData != null && stopLevelData.Enabled) + { + byte currentLevel = clientState.LocalPlayer?.Level ?? 0; + if (currentLevel >= stopLevelData.TargetValue) + { + log.Information($"[QuestRotation] {currentState.CurrentCharacter} already at target level ({currentLevel} >= {stopLevelData.TargetValue}) - skipping"); + MarkCharacterCompleted(currentState.CurrentCharacter, "target level reached"); + SkipToNextCharacter(); + return; + } + } + } + log.Information($"[QuestRotation] {currentState.CurrentCharacter} needs to complete quest {questId}"); + if (configuration.EnableDCTravel && dcTravelService != null && dcTravelService.ShouldPerformDCTravel()) + { + log.Information("[QuestRotation] === DC TRAVEL REQUIRED ==="); + log.Information("[QuestRotation] Performing DC travel before starting quest..."); + currentState.Phase = RotationPhase.DCTraveling; + currentState.PhaseStartTime = DateTime.Now; + PerformDCTravelAndStartQuest(); + return; + } + log.Information("[QuestRotation] >>> Phase 4: Waiting for quest to start..."); + if (configuration.EnableMovementMonitor && movementMonitor != null && !movementMonitor.IsMonitoring) + { + movementMonitor.StartMonitoring(); + log.Information("[QuestRotation] Movement monitor started for quest"); + } + try + { + commandManager.ProcessCommand("/qst start"); + log.Information("[QuestRotation] Sent /qst start command"); + } + catch (Exception ex2) + { + log.Error("[QuestRotation] Failed to send /qst start: " + ex2.Message); + } + currentState.Phase = RotationPhase.WaitingForQuestStart; + currentState.HasQuestBeenAccepted = false; + currentState.PhaseStartTime = DateTime.Now; + } + + private void PerformDCTravelAndStartQuest() + { + if (dcTravelService == null) + { + return; + } + Task.Run(async delegate + { + try + { + if (await dcTravelService.PerformDCTravel()) + { + log.Information("[QuestRotation] DC travel completed - starting quest"); + try + { + commandManager.ProcessCommand("/qst start"); + log.Information("[QuestRotation] Sent /qst start command"); + } + catch (Exception ex) + { + log.Error("[QuestRotation] Failed to send /qst start: " + ex.Message); + } + currentState.Phase = RotationPhase.WaitingForQuestStart; + currentState.HasQuestBeenAccepted = false; + currentState.PhaseStartTime = DateTime.Now; + } + else + { + log.Error("[QuestRotation] DC travel failed - skipping character"); + SkipToNextCharacter(); + } + } + catch (Exception ex2) + { + log.Error("[QuestRotation] DC travel error: " + ex2.Message); + SkipToNextCharacter(); + } + }); + } + + private unsafe void HandleQuestMonitoring() + { + uint questId = currentState.CurrentStopQuestId; + if (!submarineManager.IsSubmarinePaused && !submarineManager.IsSubmarineCooldownActive()) + { + TimeSpan submarineCheckInterval = TimeSpan.FromSeconds(configuration.SubmarineCheckInterval); + if (DateTime.Now - lastSubmarineCheckTime >= submarineCheckInterval) + { + lastSubmarineCheckTime = DateTime.Now; + if (submarineManager.CheckSubmarines()) + { + log.Information("[QuestRotation] ========================================"); + log.Information("[QuestRotation] === SUBMARINES READY - WAITING FOR QUEST COMPLETION ==="); + log.Information("[QuestRotation] ========================================"); + waitingForQuestAcceptForSubmarines = true; + log.Information("[QuestRotation] Waiting for current quest to complete, then will pause for submarines..."); + return; + } + } + } + if (waitingForQuestAcceptForSubmarines) + { + if (QuestManager.Instance() != null && questionableIPC.GetCurrentSequence() == 0) + { + log.Information("[QuestRotation] ========================================"); + log.Information("[QuestRotation] === QUEST ACCEPTED - STOPPING QUESTIONABLE ==="); + log.Information("[QuestRotation] ========================================"); + log.Information("[QuestRotation] Stopping Questionable..."); + framework.RunOnFrameworkThread(delegate + { + commandManager.ProcessCommand("/qst stop"); + }).Wait(); + log.Information("[QuestRotation] ✓ /qst stop command sent"); + log.Information("[QuestRotation] Enabling Multi-Mode..."); + submarineManager.EnableMultiMode(); + waitingForQuestAcceptForSubmarines = false; + if (movementMonitor != null && movementMonitor.IsMonitoring) + { + movementMonitor.StopMonitoring(); + log.Information("[QuestRotation] Movement monitor stopped for submarine operations"); + } + log.Information("[QuestRotation] Multi-Mode enabled - submarines will now run"); + } + } + else if (submarineManager.IsSubmarinePaused) + { + if (movementMonitor != null && movementMonitor.IsMonitoring) + { + movementMonitor.StopMonitoring(); + log.Debug("[QuestRotation] Movement monitor stopped during submarine multi-mode"); + } + log.Debug("[QuestRotation] Submarines running in Multi-Mode..."); + int waitTime = submarineManager.CheckSubmarinesSoon(); + if (waitTime == 0) + { + log.Information("[QuestRotation] ========================================"); + log.Information("[QuestRotation] === NO SUBMARINES IN NEXT 2 MINUTES ==="); + log.Information("[QuestRotation] ========================================"); + log.Information("[QuestRotation] Disabling Multi-Mode and returning to character..."); + submarineManager.DisableMultiModeAndReturn(); + string currentChar = currentState.CurrentCharacter; + log.Information("[QuestRotation] Relogging to " + currentChar + "..."); + if (autoRetainerIpc.SwitchCharacter(currentChar)) + { + log.Information("[QuestRotation] Relog initiated - waiting for character login..."); + submarineManager.CompleteSubmarineRelog(); + if (configuration.EnableMovementMonitor && movementMonitor != null && !movementMonitor.IsMonitoring) + { + movementMonitor.StartMonitoring(); + log.Information("[QuestRotation] Movement monitor restarted after submarine operations"); + } + log.Information("[QuestRotation] Questionable will auto-resume when character is ready"); + } + else + { + log.Error("[QuestRotation] Failed to relog to " + currentChar + "!"); + } + } + else + { + log.Debug($"[QuestRotation] Submarine ready in {waitTime}s - waiting..."); + } + } + else + { + if (submarineManager.IsSubmarineCooldownActive() || waitingForQuestAcceptForSubmarines) + { + return; + } + questTrackingService.UpdateCurrentCharacterQuests(currentState.CurrentCharacter); + StopPoint activeStopPoint = stopPoints.FirstOrDefault((StopPoint sp) => sp.QuestId == questId && sp.IsActive); + bool shouldRotate = false; + if (activeStopPoint != null && activeStopPoint.Sequence.HasValue) + { + string currentQuestIdStr = questionableIPC.GetCurrentQuestId(); + byte? currentSequence = questionableIPC.GetCurrentSequence(); + if (!string.IsNullOrEmpty(currentQuestIdStr) && currentSequence.HasValue && uint.TryParse(currentQuestIdStr, out var currentQuestId)) + { + if (currentQuestId == questId) + { + if (currentSequence.Value > activeStopPoint.Sequence.Value) + { + log.Information($"[QuestRotation] ✓ Quest {questId} Sequence {activeStopPoint.Sequence.Value} completed by {currentState.CurrentCharacter}!"); + log.Information($"[QuestRotation] Current Sequence: {currentSequence.Value} (moved past {activeStopPoint.Sequence.Value})"); + shouldRotate = true; + } + } + else + { + bool isQuestComplete = false; + try + { + isQuestComplete = QuestManager.IsQuestComplete(questId); + } + catch (Exception ex) + { + log.Error("[QuestRotation] Error checking quest completion: " + ex.Message); + return; + } + if (isQuestComplete) + { + log.Information($"[QuestRotation] ✓ Quest {questId} completed by {currentState.CurrentCharacter}!"); + shouldRotate = true; + } + } + } + } + else + { + bool isQuestComplete2 = false; + try + { + isQuestComplete2 = QuestManager.IsQuestComplete(questId); + } + catch (Exception ex2) + { + log.Error("[QuestRotation] Error checking quest completion: " + ex2.Message); + return; + } + if (isQuestComplete2) + { + log.Information($"[QuestRotation] ✓ Quest {questId} completed by {currentState.CurrentCharacter}!"); + shouldRotate = true; + } + } + if (shouldRotate) + { + log.Information($"[QuestRotation] ✓ Quest {questId} completed by {currentState.CurrentCharacter}!"); + if (stepsOfFaithHandler != null && questId == 4591) + { + stepsOfFaithHandler.Reset(); + } + try + { + commandManager.ProcessCommand("/qst stop"); + log.Information("[QuestRotation] Sent /qst stop command"); + } + catch (Exception ex3) + { + log.Error("[QuestRotation] Failed to send /qst stop: " + ex3.Message); + } + log.Information("[QuestRotation] Updating quest completion data for " + currentState.CurrentCharacter + "..."); + ScanAndSaveAllCompletedQuests(currentState.CurrentCharacter); + List completed = currentState.CompletedCharacters; + List remaining = currentState.RemainingCharacters; + log.Debug($"[QuestRotation] DEBUG - Before update: CompletedCharacters count = {completed.Count}, RemainingCharacters count = {remaining.Count}"); + if (!completed.Contains(currentState.CurrentCharacter)) + { + completed.Add(currentState.CurrentCharacter); + log.Information("[QuestRotation] ✓ Added " + currentState.CurrentCharacter + " to CompletedCharacters list"); + } + else + { + log.Warning("[QuestRotation] " + currentState.CurrentCharacter + " was already in CompletedCharacters list!"); + } + bool wasRemoved = remaining.Remove(currentState.CurrentCharacter); + log.Information($"[QuestRotation] Removed {currentState.CurrentCharacter} from RemainingCharacters list: {wasRemoved}"); + currentState.CompletedCharacters = completed; + currentState.RemainingCharacters = remaining; + log.Debug($"[QuestRotation] DEBUG - After update: CompletedCharacters count = {currentState.CompletedCharacters.Count}, RemainingCharacters count = {currentState.RemainingCharacters.Count}"); + log.Debug("[QuestRotation] DEBUG - CompletedCharacters: " + string.Join(", ", currentState.CompletedCharacters)); + log.Debug("[QuestRotation] DEBUG - RemainingCharacters: " + string.Join(", ", currentState.RemainingCharacters)); + currentState.Phase = RotationPhase.WaitingForHomeworldReturn; + currentState.PhaseStartTime = DateTime.Now; + log.Information("[QuestRotation] Waiting 5s before homeworld return..."); + } + else + { + if (currentState.Phase != RotationPhase.WaitingForQuestStart) + { + return; + } + bool isQuestAccepted = false; + try + { + QuestManager* questManager = QuestManager.Instance(); + if (questManager != null) + { + isQuestAccepted = questManager->IsQuestAccepted((ushort)questId); + } + } + catch + { + } + if (isQuestAccepted) + { + log.Information($"[QuestRotation] Quest {questId} accepted by {currentState.CurrentCharacter} - now monitoring for completion"); + currentState.Phase = RotationPhase.QuestActive; + currentState.HasQuestBeenAccepted = true; + } + } + } + } + + private void HandleCompleted() + { + log.Information("[QuestRotation] ═══ ROTATION COMPLETED ═══"); + log.Information($"[QuestRotation] All {currentState.CompletedCharacters.Count} characters completed quest {currentState.CurrentStopQuestId}"); + if (dcTravelService != null && dcTravelService.IsDCTravelCompleted()) + { + log.Information("[QuestRotation] DC Travel state reset after rotation completion"); + dcTravelService.ResetDCTravelState(); + } + StopPoint stopPoint = stopPoints.FirstOrDefault((StopPoint sp) => sp.QuestId == currentState.CurrentStopQuestId); + if (stopPoint != null) + { + stopPoint.IsActive = false; + } + isRotationActive = false; + combatDutyDetection?.SetRotationActive(active: false); + combatDutyDetection?.Reset(); + deathHandler?.SetRotationActive(active: false); + deathHandler?.Reset(); + if (movementMonitor != null && movementMonitor.IsMonitoring) + { + movementMonitor.StopMonitoring(); + log.Information("[QuestRotation] Movement monitor stopped"); + } + currentState.Phase = RotationPhase.Idle; + } + + private void HandleWaitingForHomeworldReturn() + { + if (!((DateTime.Now - currentState.PhaseStartTime).TotalSeconds >= 5.0)) + { + return; + } + log.Information("[QuestRotation] ========================================"); + log.Information("[QuestRotation] === SENDING HOMEWORLD RETURN COMMAND ==="); + log.Information("[QuestRotation] ========================================"); + try + { + commandManager.ProcessCommand("/li"); + log.Information("[QuestRotation] ✓ /li command sent (homeworld return)"); + } + catch (Exception ex) + { + log.Error("[QuestRotation] Failed to send /li command: " + ex.Message); + } + Task.Delay(2000).ContinueWith(delegate + { + framework.RunOnFrameworkThread(delegate + { + log.Information("[QuestRotation] Homeworld return initiated, moving to next character..."); + SkipToNextCharacter(); + }); + }); + } + + private void SkipToNextCharacter() + { + if (preCheckService != null) + { + log.Information("[QuestRotation] Logging completed quests before logout..."); + preCheckService.LogCompletedQuestsBeforeLogout(); + } + if (dcTravelService != null && dcTravelService.IsDCTravelCompleted()) + { + log.Information("[QuestRotation] DC Travel state reset for next character"); + dcTravelService.ResetDCTravelState(); + } + log.Information("[QuestRotation] Waiting 2s before character switch..."); + currentState.Phase = RotationPhase.WaitingBeforeCharacterSwitch; + currentState.PhaseStartTime = DateTime.Now; + } + + private void MarkCharacterCompleted(string characterName, string reason = "") + { + List completedList = currentState.CompletedCharacters; + if (!completedList.Contains(characterName)) + { + completedList.Add(characterName); + currentState.CompletedCharacters = completedList; + log.Debug("[QuestRotation] Added '" + characterName + "' to completed list" + (string.IsNullOrEmpty(reason) ? "" : (" (" + reason + ")"))); + } + List remainingList = currentState.RemainingCharacters; + bool num = remainingList.Remove(characterName); + currentState.RemainingCharacters = remainingList; + if (num) + { + log.Information("[QuestRotation] Removed '" + characterName + "' from remaining list"); + log.Information($"[QuestRotation] Progress: {currentState.CompletedCharacters.Count}/{currentState.SelectedCharacters.Count} completed"); + } + } + + private void HandleWaitingBeforeCharacterSwitch() + { + if (movementMonitor != null && movementMonitor.IsMonitoring) + { + movementMonitor.StopMonitoring(); + log.Debug("[QuestRotation] Movement monitor stopped during character switch wait"); + } + if (condition[ConditionFlag.BetweenAreas]) + { + log.Debug("[QuestRotation] Character is between areas (Condition 32) - waiting..."); + } + else if ((DateTime.Now - currentState.PhaseStartTime).TotalSeconds >= 2.0) + { + log.Debug("[QuestRotation] 2s wait complete and not between areas, performing character switch..."); + PerformCharacterSwitch(); + } + } + + private void PerformCharacterSwitch() + { + if (currentState.RemainingCharacters.Count == 0) + { + StopPoint currentStopPoint = stopPoints.FirstOrDefault((StopPoint sp) => sp.IsActive); + StopPoint nextStopPoint = stopPoints.FirstOrDefault((StopPoint sp) => !sp.IsActive); + if (currentStopPoint != null && nextStopPoint != null && configuration.EnableMultiModeAfterRotation) + { + log.Information("[QuestRotation] ========================================"); + log.Information("[QuestRotation] === CURRENT STOP POINT COMPLETED ==="); + log.Information("[QuestRotation] ========================================"); + log.Information("[QuestRotation] Completed: " + currentStopPoint.DisplayName); + log.Information("[QuestRotation] Moving to next stop point: " + nextStopPoint.DisplayName); + currentStopPoint.IsActive = false; + nextStopPoint.IsActive = true; + currentState.CompletedCharacters.Clear(); + currentState.RemainingCharacters = new List(currentState.SelectedCharacters); + currentState.CurrentStopQuestId = nextStopPoint.QuestId; + string firstChar = currentState.RemainingCharacters[0]; + currentState.CurrentCharacter = firstChar; + currentState.NextCharacter = firstChar; + log.Information("[QuestRotation] Starting next stop point with " + firstChar); + if (autoRetainerIpc.SwitchCharacter(firstChar)) + { + currentState.Phase = RotationPhase.WaitingForCharacterLogin; + currentState.PhaseStartTime = DateTime.Now; + } + else + { + log.Error("[QuestRotation] Failed to switch to " + firstChar); + currentState.Phase = RotationPhase.Error; + currentState.ErrorMessage = "Failed to switch to " + firstChar; + } + } + else + { + log.Information("[QuestRotation] No more stop points to process (Multi-Mode: " + (configuration.EnableMultiModeAfterRotation ? "Enabled" : "Disabled") + ")"); + currentState.Phase = RotationPhase.Completed; + } + return; + } + if (dcTravelService != null) + { + dcTravelService.ResetDCTravelState(); + log.Information("[QuestRotation] DC Travel state reset for next character"); + } + log.Debug($"[QuestRotation] DEBUG - RemainingCharacters count: {currentState.RemainingCharacters.Count}"); + log.Debug("[QuestRotation] DEBUG - RemainingCharacters list: " + string.Join(", ", currentState.RemainingCharacters)); + log.Debug($"[QuestRotation] DEBUG - CompletedCharacters count: {currentState.CompletedCharacters.Count}"); + log.Debug("[QuestRotation] DEBUG - CompletedCharacters list: " + string.Join(", ", currentState.CompletedCharacters)); + string nextChar = currentState.RemainingCharacters[0]; + currentState.CurrentCharacter = nextChar; + currentState.NextCharacter = nextChar; + log.Information("[QuestRotation] Switching to next character: " + nextChar); + log.Information($"[QuestRotation] Progress: {currentState.CompletedCharacters.Count}/{currentState.SelectedCharacters.Count} completed"); + if (autoRetainerIpc.SwitchCharacter(nextChar)) + { + currentState.Phase = RotationPhase.WaitingForCharacterLogin; + currentState.PhaseStartTime = DateTime.Now; + log.Information("[QuestRotation] Character switch initiated to " + nextChar); + } + else + { + log.Error("[QuestRotation] Failed to switch to " + nextChar); + currentState.Phase = RotationPhase.Error; + currentState.ErrorMessage = "Failed to switch character"; + } + } + + public void Dispose() + { + framework.Update -= OnFrameworkUpdate; + log.Information("[QuestRotation] Service disposed"); + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/QuestTrackingService.cs b/QuestionableCompanion/QuestionableCompanion.Services/QuestTrackingService.cs new file mode 100644 index 0000000..b82ac1d --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/QuestTrackingService.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game; + +namespace QuestionableCompanion.Services; + +public class QuestTrackingService : IDisposable +{ + private readonly IPluginLog log; + + private readonly Dictionary> characterQuestCache = new Dictionary>(); + + public QuestTrackingService(IPluginLog log) + { + this.log = log; + } + + public HashSet GetCharacterCompletedQuests(string characterName) + { + if (string.IsNullOrEmpty(characterName)) + { + return new HashSet(); + } + if (characterQuestCache.TryGetValue(characterName, out HashSet quests)) + { + return quests; + } + return new HashSet(); + } + + public void UpdateCurrentCharacterQuests(string characterName) + { + if (string.IsNullOrEmpty(characterName)) + { + return; + } + try + { + HashSet completedQuests = new HashSet(); + for (uint questId = 66000u; questId < 72000; questId++) + { + try + { + if (QuestManager.IsQuestComplete(questId)) + { + completedQuests.Add(questId); + } + } + catch + { + } + } + characterQuestCache[characterName] = completedQuests; + } + catch (Exception ex) + { + log.Error("[QuestTracking] Failed to update quests for " + characterName + ": " + ex.Message); + } + } + + public bool IsQuestCompleted(string characterName, uint questId) + { + return GetCharacterCompletedQuests(characterName).Contains(questId); + } + + public void ClearCharacterCache(string characterName) + { + if (characterQuestCache.ContainsKey(characterName)) + { + characterQuestCache.Remove(characterName); + log.Debug("[QuestTracking] Cleared cache for " + characterName); + } + } + + public void ClearAllCache() + { + characterQuestCache.Clear(); + log.Information("[QuestTracking] Cleared all cached quest data"); + } + + public void Dispose() + { + characterQuestCache.Clear(); + log.Information("[QuestTracking] Service disposed"); + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/QuestionableIPC.cs b/QuestionableCompanion/QuestionableCompanion.Services/QuestionableIPC.cs new file mode 100644 index 0000000..83ed02f --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/QuestionableIPC.cs @@ -0,0 +1,959 @@ +using System; +using System.Collections.Generic; +using Dalamud.Plugin; +using Dalamud.Plugin.Ipc; +using Dalamud.Plugin.Services; + +namespace QuestionableCompanion.Services; + +public class QuestionableIPC : IDisposable +{ + private readonly IDalamudPluginInterface pluginInterface; + + private readonly IPluginLog log; + + private ICallGateSubscriber? importQuestPrioritySubscriber; + + private ICallGateSubscriber? getCurrentQuestIdSubscriber; + + private ICallGateSubscriber? getCurrentStepDataSubscriber; + + private ICallGateSubscriber? isRunningSubscriber; + + private ICallGateSubscriber? getCurrentTaskSubscriber; + + private ICallGateSubscriber? isQuestCompleteSubscriber; + + private ICallGateSubscriber? isReadyToAcceptQuestSubscriber; + + private ICallGateSubscriber? addQuestPrioritySubscriber; + + private ICallGateSubscriber? clearQuestPrioritySubscriber; + + private ICallGateSubscriber>? getCurrentlyActiveEventQuestsSubscriber; + + private ICallGateSubscriber? getAlliedSocietyRemainingAllowancesSubscriber; + + private ICallGateSubscriber>? getAlliedSocietyAvailableQuestIdsSubscriber; + + private ICallGateSubscriber>? getAlliedSocietyAllAvailableQuestCountsSubscriber; + + private ICallGateSubscriber? getAlliedSocietyIsMaxRankSubscriber; + + private ICallGateSubscriber? getAlliedSocietyCurrentRankSubscriber; + + private ICallGateSubscriber>? getAlliedSocietiesWithAvailableQuestsSubscriber; + + private ICallGateSubscriber? addAlliedSocietyOptimalQuestsSubscriber; + + private ICallGateSubscriber>? getAlliedSocietyOptimalQuestsSubscriber; + + private ICallGateSubscriber? getAlliedSocietyTimeUntilResetSubscriber; + + private ICallGateSubscriber? getStopConditionsEnabledSubscriber; + + private ICallGateSubscriber>? getStopQuestListSubscriber; + + private ICallGateSubscriber? getLevelStopConditionSubscriber; + + private ICallGateSubscriber? getSequenceStopConditionSubscriber; + + private ICallGateSubscriber? getQuestSequenceStopConditionSubscriber; + + private ICallGateSubscriber? removeQuestSequenceStopConditionSubscriber; + + private ICallGateSubscriber>? getAllQuestSequenceStopConditionsSubscriber; + + private ICallGateSubscriber? getDefaultDutyModeSubscriber; + + private ICallGateSubscriber? setDefaultDutyModeSubscriber; + + private bool subscribersInitialized; + + private DateTime lastAvailabilityCheck = DateTime.MinValue; + + private const int AvailabilityCheckCooldownSeconds = 5; + + public bool IsAvailable { get; private set; } + + public QuestionableIPC(IDalamudPluginInterface pluginInterface, IPluginLog log) + { + this.pluginInterface = pluginInterface; + this.log = log; + InitializeIPC(); + } + + private void InitializeIPC() + { + try + { + getCurrentQuestIdSubscriber = pluginInterface.GetIpcSubscriber("Questionable.GetCurrentQuestId"); + getCurrentStepDataSubscriber = pluginInterface.GetIpcSubscriber("Questionable.GetCurrentStepData"); + isRunningSubscriber = pluginInterface.GetIpcSubscriber("Questionable.IsRunning"); + importQuestPrioritySubscriber = pluginInterface.GetIpcSubscriber("Questionable.ImportQuestPriority"); + getCurrentTaskSubscriber = pluginInterface.GetIpcSubscriber("Questionable.GetCurrentTask"); + isQuestCompleteSubscriber = pluginInterface.GetIpcSubscriber("Questionable.IsQuestComplete"); + isReadyToAcceptQuestSubscriber = pluginInterface.GetIpcSubscriber("Questionable.IsReadyToAcceptQuest"); + addQuestPrioritySubscriber = pluginInterface.GetIpcSubscriber("Questionable.AddQuestPriority"); + clearQuestPrioritySubscriber = pluginInterface.GetIpcSubscriber("Questionable.ClearQuestPriority"); + getCurrentlyActiveEventQuestsSubscriber = pluginInterface.GetIpcSubscriber>("Questionable.GetCurrentlyActiveEventQuests"); + getAlliedSocietyRemainingAllowancesSubscriber = pluginInterface.GetIpcSubscriber("Questionable.AlliedSociety.GetRemainingAllowances"); + getAlliedSocietyAvailableQuestIdsSubscriber = pluginInterface.GetIpcSubscriber>("Questionable.AlliedSociety.GetAvailableQuestIds"); + getAlliedSocietyAllAvailableQuestCountsSubscriber = pluginInterface.GetIpcSubscriber>("Questionable.AlliedSociety.GetAllAvailableQuestCounts"); + getAlliedSocietyIsMaxRankSubscriber = pluginInterface.GetIpcSubscriber("Questionable.AlliedSociety.IsMaxRank"); + getAlliedSocietyCurrentRankSubscriber = pluginInterface.GetIpcSubscriber("Questionable.AlliedSociety.GetCurrentRank"); + getAlliedSocietiesWithAvailableQuestsSubscriber = pluginInterface.GetIpcSubscriber>("Questionable.AlliedSociety.GetSocietiesWithAvailableQuests"); + addAlliedSocietyOptimalQuestsSubscriber = pluginInterface.GetIpcSubscriber("Questionable.AlliedSociety.AddOptimalQuests"); + getAlliedSocietyOptimalQuestsSubscriber = pluginInterface.GetIpcSubscriber>("Questionable.AlliedSociety.GetOptimalQuests"); + getAlliedSocietyTimeUntilResetSubscriber = pluginInterface.GetIpcSubscriber("Questionable.AlliedSociety.GetTimeUntilReset"); + getStopConditionsEnabledSubscriber = pluginInterface.GetIpcSubscriber("Questionable.GetStopConditionsEnabled"); + getStopQuestListSubscriber = pluginInterface.GetIpcSubscriber>("Questionable.GetStopQuestList"); + getLevelStopConditionSubscriber = pluginInterface.GetIpcSubscriber("Questionable.GetLevelStopCondition"); + getSequenceStopConditionSubscriber = pluginInterface.GetIpcSubscriber("Questionable.GetSequenceStopCondition"); + getQuestSequenceStopConditionSubscriber = pluginInterface.GetIpcSubscriber("Questionable.GetQuestSequenceStopCondition"); + removeQuestSequenceStopConditionSubscriber = pluginInterface.GetIpcSubscriber("Questionable.RemoveQuestSequenceStopCondition"); + getAllQuestSequenceStopConditionsSubscriber = pluginInterface.GetIpcSubscriber>("Questionable.GetAllQuestSequenceStopConditions"); + getDefaultDutyModeSubscriber = pluginInterface.GetIpcSubscriber("Questionable.GetDefaultDutyMode"); + setDefaultDutyModeSubscriber = pluginInterface.GetIpcSubscriber("Questionable.SetDefaultDutyMode"); + subscribersInitialized = true; + log.Debug("[QuestionableIPC] IPC subscribers initialized (lazy-loading enabled)"); + } + catch (Exception ex) + { + IsAvailable = false; + subscribersInitialized = false; + log.Error("[QuestionableIPC] Failed to initialize subscribers: " + ex.Message); + } + } + + private bool TryEnsureAvailable() + { + if (IsAvailable) + { + return true; + } + if (!subscribersInitialized) + { + log.Warning("[QuestionableIPC] Subscribers not initialized!"); + return false; + } + DateTime now = DateTime.Now; + if ((now - lastAvailabilityCheck).TotalSeconds < 5.0) + { + return false; + } + lastAvailabilityCheck = now; + try + { + if (isRunningSubscriber == null) + { + log.Error("[QuestionableIPC] isRunningSubscriber is NULL!"); + return false; + } + isRunningSubscriber.InvokeFunc(); + if (!IsAvailable) + { + IsAvailable = true; + } + return true; + } + catch (Exception ex) + { + log.Error("[QuestionableIPC] Failed to connect to Questionable:"); + log.Error("[QuestionableIPC] Exception Type: " + ex.GetType().Name); + log.Error("[QuestionableIPC] Message: " + ex.Message); + log.Error("[QuestionableIPC] Stack Trace: " + ex.StackTrace); + IsAvailable = false; + return false; + } + } + + public bool ForceCheckAvailability() + { + try + { + if (!subscribersInitialized) + { + log.Error("[QuestionableIPC] Subscribers not initialized!"); + return false; + } + if (isRunningSubscriber == null) + { + log.Error("[QuestionableIPC] isRunningSubscriber is NULL!"); + return false; + } + log.Information("[QuestionableIPC] Force checking Questionable availability..."); + log.Information("[QuestionableIPC] Attempting to call Questionable.IsRunning..."); + bool testRunning = isRunningSubscriber.InvokeFunc(); + log.Information($"[QuestionableIPC] SUCCESS! Questionable.IsRunning returned: {testRunning}"); + IsAvailable = true; + lastAvailabilityCheck = DateTime.Now; + return true; + } + catch (Exception ex) + { + log.Error("[QuestionableIPC] Failed to connect to Questionable:"); + log.Error("[QuestionableIPC] Exception Type: " + ex.GetType().Name); + log.Error("[QuestionableIPC] Message: " + ex.Message); + log.Error("[QuestionableIPC] Stack Trace: " + ex.StackTrace); + IsAvailable = false; + return false; + } + } + + public bool TryEnsureAvailableSilent() + { + if (IsAvailable) + { + return true; + } + if (!subscribersInitialized) + { + return false; + } + lastAvailabilityCheck = DateTime.MinValue; + try + { + if (isRunningSubscriber == null) + { + return false; + } + isRunningSubscriber.InvokeFunc(); + if (!IsAvailable) + { + IsAvailable = true; + } + return true; + } + catch + { + IsAvailable = false; + return false; + } + } + + public string? GetCurrentQuestId() + { + TryEnsureAvailable(); + if (!IsAvailable || getCurrentQuestIdSubscriber == null) + { + return null; + } + try + { + return getCurrentQuestIdSubscriber.InvokeFunc(); + } + catch (Exception ex) + { + log.Debug("[QuestionableIPC] GetCurrentQuestId failed: " + ex.Message); + return null; + } + } + + public StepData? GetCurrentStepData() + { + TryEnsureAvailable(); + if (!IsAvailable || getCurrentStepDataSubscriber == null) + { + return null; + } + try + { + return getCurrentStepDataSubscriber.InvokeFunc(); + } + catch (Exception ex) + { + log.Debug("[QuestionableIPC] GetCurrentStepData failed: " + ex.Message); + return null; + } + } + + public byte? GetCurrentSequence() + { + TryEnsureAvailable(); + if (!IsAvailable || getCurrentStepDataSubscriber == null) + { + return null; + } + try + { + return getCurrentStepDataSubscriber.InvokeFunc()?.Sequence; + } + catch (Exception ex) + { + log.Debug("[QuestionableIPC] GetCurrentSequence failed: " + ex.Message); + return null; + } + } + + public bool IsRunning() + { + TryEnsureAvailable(); + if (isRunningSubscriber == null) + { + return false; + } + try + { + bool result = isRunningSubscriber.InvokeFunc(); + if (!IsAvailable) + { + IsAvailable = true; + log.Information("[QuestionableIPC] Questionable is now available"); + } + return result; + } + catch (Exception ex) + { + if (IsAvailable) + { + IsAvailable = false; + log.Warning("[QuestionableIPC] Questionable is no longer available: " + ex.Message); + } + log.Debug("[QuestionableIPC] IsRunning failed: " + ex.Message); + return false; + } + } + + public object? GetCurrentTask() + { + TryEnsureAvailable(); + if (getCurrentTaskSubscriber == null) + { + return null; + } + try + { + object? result = getCurrentTaskSubscriber.InvokeFunc(); + if (!IsAvailable) + { + IsAvailable = true; + log.Information("[QuestionableIPC] Questionable is now available"); + } + return result; + } + catch (Exception ex) + { + if (IsAvailable) + { + IsAvailable = false; + log.Warning("[QuestionableIPC] Questionable is no longer available: " + ex.Message); + } + log.Debug("[QuestionableIPC] GetCurrentTask failed: " + ex.Message); + return null; + } + } + + public bool Start() + { + log.Warning("[QuestionableIPC] Start() called - NOT AVAILABLE VIA IPC!"); + log.Warning("[QuestionableIPC] Use /qst start command instead"); + return false; + } + + public bool Stop() + { + log.Warning("[QuestionableIPC] Stop() called - NOT AVAILABLE VIA IPC!"); + log.Warning("[QuestionableIPC] Use /qst stop command instead"); + return false; + } + + public bool ImportQuestPriority(string base64QuestData) + { + TryEnsureAvailable(); + if (!IsAvailable || importQuestPrioritySubscriber == null) + { + return false; + } + try + { + bool result = importQuestPrioritySubscriber.InvokeFunc(base64QuestData); + log.Information($"[QuestionableIPC] Imported priority quest: {result}"); + return result; + } + catch (Exception ex) + { + log.Error("[QuestionableIPC] ImportQuestPriority failed: " + ex.Message); + return false; + } + } + + public bool AddQuestPriority(string questId) + { + TryEnsureAvailable(); + if (!IsAvailable || addQuestPrioritySubscriber == null) + { + return false; + } + try + { + bool result = addQuestPrioritySubscriber.InvokeFunc(questId); + log.Debug($"[QuestionableIPC] Added quest {questId} to priority: {result}"); + return result; + } + catch (Exception ex) + { + log.Error("[QuestionableIPC] AddQuestPriority failed: " + ex.Message); + return false; + } + } + + public bool ClearQuestPriority() + { + TryEnsureAvailable(); + if (!IsAvailable || clearQuestPrioritySubscriber == null) + { + return false; + } + try + { + bool result = clearQuestPrioritySubscriber.InvokeFunc(); + log.Debug($"[QuestionableIPC] Cleared quest priority: {result}"); + return result; + } + catch (Exception ex) + { + log.Error("[QuestionableIPC] ClearQuestPriority failed: " + ex.Message); + return false; + } + } + + public bool IsQuestComplete(string questId) + { + TryEnsureAvailable(); + if (!IsAvailable || isQuestCompleteSubscriber == null) + { + return false; + } + try + { + bool result = isQuestCompleteSubscriber.InvokeFunc(questId); + log.Debug($"[QuestionableIPC] Quest {questId} complete: {result}"); + return result; + } + catch (Exception ex) + { + log.Error("[QuestionableIPC] IsQuestComplete failed: " + ex.Message); + return false; + } + } + + public bool IsReadyToAcceptQuest(string questId) + { + TryEnsureAvailable(); + if (!IsAvailable || isReadyToAcceptQuestSubscriber == null) + { + return false; + } + try + { + bool result = isReadyToAcceptQuestSubscriber.InvokeFunc(questId); + log.Debug($"[QuestionableIPC] Quest {questId} ready to accept: {result}"); + return result; + } + catch (Exception ex) + { + log.Error("[QuestionableIPC] IsReadyToAcceptQuest failed: " + ex.Message); + return false; + } + } + + public bool AddQuestsToQueue(List questIds) + { + TryEnsureAvailable(); + if (!IsAvailable) + { + log.Warning("[QuestionableIPC] Cannot add quests to queue - Questionable not available"); + return false; + } + if (questIds == null || questIds.Count == 0) + { + return true; + } + try + { + log.Information($"[QuestionableIPC] Adding {questIds.Count} quests to priority queue"); + foreach (string questId in questIds) + { + if (!string.IsNullOrEmpty(questId)) + { + try + { + bool? result = addQuestPrioritySubscriber?.InvokeFunc(questId); + log.Debug($"[QuestionableIPC] Added quest {questId} to queue: {result}"); + } + catch (Exception ex) + { + log.Warning("[QuestionableIPC] Failed to add quest " + questId + " to queue: " + ex.Message); + } + } + } + log.Information("[QuestionableIPC] All quests added to priority queue"); + return true; + } + catch (Exception ex2) + { + log.Error("[QuestionableIPC] Error adding quests to queue: " + ex2.Message); + return false; + } + } + + public List GetCurrentlyActiveEventQuests() + { + TryEnsureAvailable(); + if (!IsAvailable || getCurrentlyActiveEventQuestsSubscriber == null) + { + log.Warning("[QuestionableIPC] Cannot get active event quests - Questionable not available"); + return new List(); + } + try + { + List eventQuests = getCurrentlyActiveEventQuestsSubscriber.InvokeFunc(); + log.Debug($"[QuestionableIPC] Retrieved {eventQuests?.Count ?? 0} active event quests"); + return eventQuests ?? new List(); + } + catch (Exception ex) + { + log.Error("[QuestionableIPC] Error getting active event quests: " + ex.Message); + return new List(); + } + } + + public int GetAlliedSocietyRemainingAllowances() + { + TryEnsureAvailable(); + if (!IsAvailable || getAlliedSocietyRemainingAllowancesSubscriber == null) + { + log.Debug("[AlliedSociety] Cannot get remaining allowances - Questionable not available"); + return 12; + } + try + { + int result = getAlliedSocietyRemainingAllowancesSubscriber.InvokeFunc(); + log.Debug($"[AlliedSociety] Remaining allowances: {result}"); + return result; + } + catch (Exception ex) + { + log.Error("[AlliedSociety] Error getting remaining allowances: " + ex.Message); + return 12; + } + } + + public List GetAlliedSocietyAvailableQuestIds(byte societyId) + { + TryEnsureAvailable(); + if (!IsAvailable || getAlliedSocietyAvailableQuestIdsSubscriber == null) + { + log.Debug($"[AlliedSociety] Cannot get quest IDs for society {societyId} - Questionable not available"); + return new List(); + } + try + { + List result = getAlliedSocietyAvailableQuestIdsSubscriber.InvokeFunc(societyId); + log.Debug($"[AlliedSociety] Society {societyId} has {result?.Count ?? 0} available quests"); + return result ?? new List(); + } + catch (Exception ex) + { + log.Error($"[AlliedSociety] Error getting quest IDs for society {societyId}: {ex.Message}"); + return new List(); + } + } + + public Dictionary GetAlliedSocietyAllAvailableQuestCounts() + { + TryEnsureAvailable(); + if (!IsAvailable || getAlliedSocietyAllAvailableQuestCountsSubscriber == null) + { + log.Debug("[AlliedSociety] Cannot get quest counts - Questionable not available"); + return new Dictionary(); + } + try + { + Dictionary result = getAlliedSocietyAllAvailableQuestCountsSubscriber.InvokeFunc(); + log.Debug($"[AlliedSociety] Found {result?.Count ?? 0} societies with available quests"); + return result ?? new Dictionary(); + } + catch (Exception ex) + { + log.Error("[AlliedSociety] Error getting quest counts: " + ex.Message); + return new Dictionary(); + } + } + + public bool GetAlliedSocietyIsMaxRank(byte societyId) + { + TryEnsureAvailable(); + if (!IsAvailable || getAlliedSocietyIsMaxRankSubscriber == null) + { + log.Debug($"[AlliedSociety] Cannot check max rank for society {societyId} - Questionable not available"); + return false; + } + try + { + bool result = getAlliedSocietyIsMaxRankSubscriber.InvokeFunc(societyId); + log.Debug($"[AlliedSociety] Society {societyId} max rank: {result}"); + return result; + } + catch (Exception ex) + { + log.Error($"[AlliedSociety] Error checking max rank for society {societyId}: {ex.Message}"); + return false; + } + } + + public int GetAlliedSocietyCurrentRank(byte societyId) + { + TryEnsureAvailable(); + if (!IsAvailable || getAlliedSocietyCurrentRankSubscriber == null) + { + log.Debug($"[AlliedSociety] Cannot get rank for society {societyId} - Questionable not available"); + return -1; + } + try + { + int result = getAlliedSocietyCurrentRankSubscriber.InvokeFunc(societyId); + log.Debug($"[AlliedSociety] Society {societyId} current rank: {result}"); + return result; + } + catch (Exception ex) + { + log.Error($"[AlliedSociety] Error getting rank for society {societyId}: {ex.Message}"); + return -1; + } + } + + public List GetAlliedSocietiesWithAvailableQuests() + { + TryEnsureAvailable(); + if (!IsAvailable || getAlliedSocietiesWithAvailableQuestsSubscriber == null) + { + log.Debug("[AlliedSociety] Cannot get societies with quests - Questionable not available"); + return new List(); + } + try + { + List result = getAlliedSocietiesWithAvailableQuestsSubscriber.InvokeFunc(); + log.Debug($"[AlliedSociety] Found {result?.Count ?? 0} societies with available quests"); + return result ?? new List(); + } + catch (Exception ex) + { + log.Error("[AlliedSociety] Error getting societies with quests: " + ex.Message); + return new List(); + } + } + + public int AddAlliedSocietyOptimalQuests(byte societyId) + { + TryEnsureAvailable(); + if (!IsAvailable || addAlliedSocietyOptimalQuestsSubscriber == null) + { + log.Debug($"[AlliedSociety] Cannot add optimal quests for society {societyId} - Questionable not available"); + return 0; + } + try + { + int result = addAlliedSocietyOptimalQuestsSubscriber.InvokeFunc(societyId); + log.Information($"[AlliedSociety] Added {result} optimal quests for society {societyId}"); + return result; + } + catch (Exception ex) + { + log.Error($"[AlliedSociety] Error adding optimal quests for society {societyId}: {ex.Message}"); + return 0; + } + } + + public List GetAlliedSocietyOptimalQuests(byte societyId) + { + TryEnsureAvailable(); + if (!IsAvailable || getAlliedSocietyOptimalQuestsSubscriber == null) + { + log.Debug($"[AlliedSociety] Cannot get optimal quests for society {societyId} - Questionable not available"); + return new List(); + } + try + { + List result = getAlliedSocietyOptimalQuestsSubscriber.InvokeFunc(societyId); + log.Debug($"[AlliedSociety] Found {result?.Count ?? 0} optimal quests for society {societyId}"); + return result ?? new List(); + } + catch (Exception ex) + { + log.Error($"[AlliedSociety] Error getting optimal quests for society {societyId}: {ex.Message}"); + return new List(); + } + } + + public TimeSpan GetAlliedSocietyTimeUntilReset() + { + TryEnsureAvailable(); + if (!IsAvailable || getAlliedSocietyTimeUntilResetSubscriber == null) + { + log.Debug("[AlliedSociety] Cannot get time until reset - Questionable not available"); + return TimeSpan.Zero; + } + try + { + TimeSpan timeSpan = TimeSpan.FromTicks(getAlliedSocietyTimeUntilResetSubscriber.InvokeFunc()); + log.Debug($"[AlliedSociety] Time until reset: {timeSpan}"); + return timeSpan; + } + catch (Exception ex) + { + log.Error("[AlliedSociety] Error getting time until reset: " + ex.Message); + return TimeSpan.Zero; + } + } + + public bool GetStopConditionsEnabled() + { + TryEnsureAvailable(); + if (!IsAvailable || getStopConditionsEnabledSubscriber == null) + { + log.Debug("[StopCondition] Cannot get stop conditions enabled - Questionable not available"); + return false; + } + try + { + bool result = getStopConditionsEnabledSubscriber.InvokeFunc(); + log.Debug($"[StopCondition] Stop conditions enabled: {result}"); + return result; + } + catch (Exception ex) + { + log.Error("[StopCondition] Error getting stop conditions enabled: " + ex.Message); + return false; + } + } + + public List GetStopQuestList() + { + TryEnsureAvailable(); + if (!IsAvailable || getStopQuestListSubscriber == null) + { + return new List(); + } + try + { + List result = getStopQuestListSubscriber.InvokeFunc(); + log.Debug($"[StopCondition] Found {result?.Count ?? 0} stop quests"); + return result ?? new List(); + } + catch (Exception ex) + { + log.Error("[StopCondition] Error getting stop quest list: " + ex.Message); + return new List(); + } + } + + public StopConditionData? GetLevelStopCondition() + { + TryEnsureAvailable(); + if (!IsAvailable || getLevelStopConditionSubscriber == null) + { + log.Debug("[StopCondition] Cannot get level stop condition - Questionable not available"); + return null; + } + try + { + return getLevelStopConditionSubscriber.InvokeFunc(); + } + catch (Exception ex) + { + log.Error("[StopCondition] Error getting level stop condition: " + ex.Message); + return null; + } + } + + public StopConditionData? GetSequenceStopCondition() + { + TryEnsureAvailable(); + if (!IsAvailable || getSequenceStopConditionSubscriber == null) + { + log.Debug("[StopCondition] Cannot get sequence stop condition - Questionable not available"); + return null; + } + try + { + StopConditionData result = getSequenceStopConditionSubscriber.InvokeFunc(); + log.Debug($"[StopCondition] Sequence stop condition - Enabled: {result?.Enabled}, Target: {result?.TargetValue}"); + return result; + } + catch (Exception ex) + { + log.Error("[StopCondition] Error getting sequence stop condition: " + ex.Message); + return null; + } + } + + public object? GetQuestSequenceStopCondition(string questId, uint sequence, int step) + { + TryEnsureAvailable(); + if (!IsAvailable || getQuestSequenceStopConditionSubscriber == null) + { + log.Warning("[StopCondition] Cannot get quest sequence stop condition - Questionable not available"); + return null; + } + try + { + object result = getQuestSequenceStopConditionSubscriber.InvokeFunc(questId, sequence, step); + if (result == null) + { + log.Information($"[StopCondition] No quest sequence stop condition found for {questId} at {sequence}-{step}"); + } + else + { + log.Information($"[StopCondition] Quest sequence stop condition for {questId} at {sequence}-{step}: {result}"); + } + return result; + } + catch (Exception ex) + { + log.Error("[StopCondition] Error getting quest sequence stop condition: " + ex.Message); + return null; + } + } + + public bool RemoveQuestSequenceStopCondition(string questId) + { + TryEnsureAvailable(); + if (!IsAvailable || removeQuestSequenceStopConditionSubscriber == null) + { + log.Warning("[StopCondition] Cannot remove quest sequence stop condition - Questionable not available"); + return false; + } + try + { + bool num = removeQuestSequenceStopConditionSubscriber.InvokeFunc(questId); + if (num) + { + log.Information("[StopCondition] ✓ Removed quest sequence stop condition for " + questId); + } + else + { + log.Warning("[StopCondition] ✗ No quest sequence stop condition to remove for " + questId + " (or removal failed)"); + } + return num; + } + catch (Exception ex) + { + log.Error("[StopCondition] Error removing quest sequence stop condition: " + ex.Message); + return false; + } + } + + public Dictionary GetAllQuestSequenceStopConditions() + { + TryEnsureAvailable(); + if (!IsAvailable || getAllQuestSequenceStopConditionsSubscriber == null) + { + return new Dictionary(); + } + try + { + Dictionary result = getAllQuestSequenceStopConditionsSubscriber.InvokeFunc(); + if (result == null || result.Count == 0) + { + log.Information("[StopCondition] No quest sequence stop conditions configured (empty or null result)"); + return new Dictionary(); + } + log.Information($"[StopCondition] Found {result.Count} quest sequence stop condition(s)"); + return result; + } + catch (Exception ex) + { + log.Error("[StopCondition] Error getting all quest sequence stop conditions: " + ex.Message); + return new Dictionary(); + } + } + + public int GetDefaultDutyMode() + { + TryEnsureAvailable(); + if (!IsAvailable || getDefaultDutyModeSubscriber == null) + { + log.Debug("[QuestionableIPC] Cannot get default duty mode - Questionable not available"); + return 0; + } + try + { + int result = getDefaultDutyModeSubscriber.InvokeFunc(); + log.Debug($"[QuestionableIPC] Default Duty Mode: {result}"); + return result; + } + catch (Exception ex) + { + log.Error("[QuestionableIPC] GetDefaultDutyMode failed: " + ex.Message); + return 0; + } + } + + public bool SetDefaultDutyMode(int dutyMode) + { + TryEnsureAvailable(); + if (!IsAvailable || setDefaultDutyModeSubscriber == null) + { + log.Debug("[QuestionableIPC] Cannot set default duty mode - Questionable not available"); + return false; + } + try + { + bool result = setDefaultDutyModeSubscriber.InvokeFunc(dutyMode); + log.Information($"[QuestionableIPC] Set Default Duty Mode to {dutyMode}: {result}"); + return result; + } + catch (Exception ex) + { + log.Error("[QuestionableIPC] SetDefaultDutyMode failed: " + ex.Message); + return false; + } + } + + public bool ValidateFeatureCompatibility() + { + if (!IsAvailable) + { + return false; + } + try + { + if (getAllQuestSequenceStopConditionsSubscriber == null) + { + return false; + } + if (getLevelStopConditionSubscriber == null) + { + return false; + } + if (getAlliedSocietyOptimalQuestsSubscriber == null) + { + return false; + } + if (getDefaultDutyModeSubscriber == null) + { + return false; + } + try + { + getAllQuestSequenceStopConditionsSubscriber.InvokeFunc(); + } + catch + { + return false; + } + return true; + } + catch + { + return false; + } + } + + public void Dispose() + { + IsAvailable = false; + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/StepData.cs b/QuestionableCompanion/QuestionableCompanion.Services/StepData.cs new file mode 100644 index 0000000..69544c9 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/StepData.cs @@ -0,0 +1,18 @@ +using System.Numerics; + +namespace QuestionableCompanion.Services; + +public class StepData +{ + public required string QuestId { get; init; } + + public required byte Sequence { get; init; } + + public required byte Step { get; init; } + + public required string InteractionType { get; init; } + + public Vector3? Position { get; init; } + + public ushort TerritoryId { get; init; } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/StepsOfFaithHandler.cs b/QuestionableCompanion/QuestionableCompanion.Services/StepsOfFaithHandler.cs new file mode 100644 index 0000000..6443eb9 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/StepsOfFaithHandler.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Dalamud.Game.ClientState.Conditions; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Plugin.Services; + +namespace QuestionableCompanion.Services; + +public class StepsOfFaithHandler : IDisposable +{ + private readonly ICondition condition; + + private readonly IPluginLog log; + + private readonly IClientState clientState; + + private readonly ICommandManager commandManager; + + private readonly IFramework framework; + + private readonly Configuration config; + + private bool isActive; + + private readonly Dictionary characterHandledStatus = new Dictionary(); + + private const uint StepsOfFaithQuestId = 4591u; + + public bool IsActive => isActive; + + public bool IsStepsOfFaithQuest(uint questId) + { + return questId == 4591; + } + + public StepsOfFaithHandler(ICondition condition, IPluginLog log, IClientState clientState, ICommandManager commandManager, IFramework framework, Configuration config) + { + this.condition = condition; + this.log = log; + this.clientState = clientState; + this.commandManager = commandManager; + this.framework = framework; + this.config = config; + log.Information("[StepsOfFaith] Handler initialized"); + } + + public bool ShouldActivate(uint questId, bool isInSoloDuty) + { + if (!config.EnableAutoDutyUnsynced) + { + return false; + } + if (isActive) + { + return false; + } + if (questId != 4591) + { + return false; + } + if (!isInSoloDuty) + { + return false; + } + string characterName = GetCurrentCharacterName(); + if (string.IsNullOrEmpty(characterName)) + { + return false; + } + if (characterHandledStatus.GetValueOrDefault(characterName, defaultValue: false)) + { + return false; + } + return true; + } + + public void Execute(string characterName) + { + isActive = true; + if (!string.IsNullOrEmpty(characterName)) + { + characterHandledStatus[characterName] = true; + log.Information("[StepsOfFaith] Marked " + characterName + " as handled"); + } + log.Information("[StepsOfFaith] ========================================"); + log.Information("[StepsOfFaith] === STEPS OF FAITH HANDLER ACTIVATED ==="); + log.Information("[StepsOfFaith] ========================================"); + try + { + log.Information("[StepsOfFaith] Waiting for conditions to clear..."); + DateTime startTime = DateTime.Now; + TimeSpan maxWaitTime = TimeSpan.FromSeconds(6000L); + while (DateTime.Now - startTime < maxWaitTime) + { + bool hasCondition29 = condition[ConditionFlag.Occupied]; + bool hasCondition63 = condition[ConditionFlag.SufferingStatusAffliction63]; + if (!hasCondition29 && !hasCondition63) + { + log.Information("[StepsOfFaith] Conditions cleared!"); + break; + } + if ((DateTime.Now - startTime).TotalSeconds % 5.0 < 0.1) + { + log.Information($"[StepsOfFaith] Waiting... (29: {hasCondition29}, 63: {hasCondition63})"); + } + Thread.Sleep(200); + } + log.Information("[StepsOfFaith] Waiting 25s for stabilization..."); + Thread.Sleep(25000); + log.Information("[StepsOfFaith] Disabling Bossmod Rotation..."); + framework.RunOnFrameworkThread(delegate + { + commandManager.ProcessCommand("/vbm ar disable"); + }); + log.Information("[StepsOfFaith] Moving to target position..."); + framework.RunOnFrameworkThread(delegate + { + commandManager.ProcessCommand("/vnav moveto 2.8788917064667 0.0 293.36273193359"); + }); + log.Information("[StepsOfFaith] Enabling combat commands..."); + framework.RunOnFrameworkThread(delegate + { + commandManager.ProcessCommand("/rsr auto"); + Thread.Sleep(100); + commandManager.ProcessCommand("/vbmai on"); + Thread.Sleep(100); + commandManager.ProcessCommand("/bmrai on"); + }); + log.Information("[StepsOfFaith] === HANDLER COMPLETE ==="); + } + catch (Exception ex) + { + log.Error("[StepsOfFaith] Error: " + ex.Message); + } + finally + { + isActive = false; + } + } + + public void Reset() + { + isActive = false; + log.Information("[StepsOfFaith] Active state reset (character completion status preserved)"); + } + + private string GetCurrentCharacterName() + { + try + { + IPlayerCharacter player = clientState.LocalPlayer; + if (player != null) + { + return $"{player.Name}@{player.HomeWorld.Value.Name}"; + } + } + catch (Exception ex) + { + log.Error("[StepsOfFaith] Failed to get character name: " + ex.Message); + } + return string.Empty; + } + + public void Dispose() + { + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/StopConditionData.cs b/QuestionableCompanion/QuestionableCompanion.Services/StopConditionData.cs new file mode 100644 index 0000000..3c8fb66 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/StopConditionData.cs @@ -0,0 +1,8 @@ +namespace QuestionableCompanion.Services; + +public class StopConditionData +{ + public required bool Enabled { get; init; } + + public required int TargetValue { get; init; } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/SubmarineManager.cs b/QuestionableCompanion/QuestionableCompanion.Services/SubmarineManager.cs new file mode 100644 index 0000000..3ecb6bb --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/SubmarineManager.cs @@ -0,0 +1,388 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; +using Dalamud.Plugin.Services; +using Newtonsoft.Json.Linq; + +namespace QuestionableCompanion.Services; + +public class SubmarineManager : IDisposable +{ + private readonly IPluginLog log; + + private readonly AutoRetainerIPC autoRetainerIPC; + + private readonly Configuration config; + + private readonly ICommandManager? commandManager; + + private readonly IFramework? framework; + + private DateTime lastSubmarineCheck = DateTime.MinValue; + + private DateTime submarineReloginCooldownEnd = DateTime.MinValue; + + private DateTime submarineNoAvailableWaitEnd = DateTime.MinValue; + + private bool submarinesPaused; + + private bool submarinesWaitingForSeq0; + + private bool submarineReloginInProgress; + + private bool submarineJustCompleted; + + private string? originalCharacterForSubmarines; + + public bool IsSubmarinePaused => submarinesPaused; + + public bool IsWaitingForSequence0 => submarinesWaitingForSeq0; + + public bool IsReloginInProgress => submarineReloginInProgress; + + public bool IsSubmarineJustCompleted => submarineJustCompleted; + + public SubmarineManager(IPluginLog log, AutoRetainerIPC autoRetainerIPC, Configuration config, ICommandManager? commandManager = null, IFramework? framework = null) + { + this.log = log; + this.autoRetainerIPC = autoRetainerIPC; + this.config = config; + this.commandManager = commandManager; + this.framework = framework; + log.Information("[SubmarineManager] Service initialized"); + } + + private string? GetConfigPath() + { + try + { + string userProfile = Environment.GetEnvironmentVariable("USERPROFILE"); + if (string.IsNullOrEmpty(userProfile)) + { + string username = Environment.GetEnvironmentVariable("USERNAME"); + if (string.IsNullOrEmpty(username)) + { + log.Warning("[SubmarineManager] Could not resolve USERPROFILE or USERNAME"); + return null; + } + userProfile = "C:\\Users\\" + username; + } + _003C_003Ey__InlineArray7 buffer = default(_003C_003Ey__InlineArray7); + global::_003CPrivateImplementationDetails_003E.InlineArrayElementRef<_003C_003Ey__InlineArray7, string>(ref buffer, 0) = userProfile; + global::_003CPrivateImplementationDetails_003E.InlineArrayElementRef<_003C_003Ey__InlineArray7, string>(ref buffer, 1) = "AppData"; + global::_003CPrivateImplementationDetails_003E.InlineArrayElementRef<_003C_003Ey__InlineArray7, string>(ref buffer, 2) = "Roaming"; + global::_003CPrivateImplementationDetails_003E.InlineArrayElementRef<_003C_003Ey__InlineArray7, string>(ref buffer, 3) = "XIVLauncher"; + global::_003CPrivateImplementationDetails_003E.InlineArrayElementRef<_003C_003Ey__InlineArray7, string>(ref buffer, 4) = "pluginConfigs"; + global::_003CPrivateImplementationDetails_003E.InlineArrayElementRef<_003C_003Ey__InlineArray7, string>(ref buffer, 5) = "AutoRetainer"; + global::_003CPrivateImplementationDetails_003E.InlineArrayElementRef<_003C_003Ey__InlineArray7, string>(ref buffer, 6) = "DefaultConfig.json"; + return Path.Combine(global::_003CPrivateImplementationDetails_003E.InlineArrayAsReadOnlySpan<_003C_003Ey__InlineArray7, string>(in buffer, 7)); + } + catch (Exception ex) + { + log.Error("[SubmarineManager] Error resolving config path: " + ex.Message); + return null; + } + } + + public bool CheckSubmarines() + { + if (!config.EnableSubmarineCheck) + { + return false; + } + string configPath = GetConfigPath(); + if (string.IsNullOrEmpty(configPath)) + { + log.Warning("[SubmarineManager] Could not resolve config path"); + return false; + } + if (!File.Exists(configPath)) + { + log.Debug("[SubmarineManager] Config file not found: " + configPath); + return false; + } + try + { + string content = File.ReadAllText(configPath); + if (string.IsNullOrEmpty(content)) + { + log.Warning("[SubmarineManager] Config file is empty"); + return false; + } + List returnTimes = ParseReturnTimes(content); + if (returnTimes.Count == 0) + { + return false; + } + long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + int available = 0; + long? minDelta = null; + foreach (long item in returnTimes) + { + long delta = item - now; + if (delta <= 0) + { + available++; + } + else if (!minDelta.HasValue || delta < minDelta.Value) + { + minDelta = delta; + } + } + if (available > 0) + { + string plural = ((available == 1) ? "Sub" : "Subs"); + log.Information($"[SubmarineManager] {available} {plural} available - pausing quest rotation!"); + return true; + } + if (minDelta.HasValue && minDelta.Value > 0) + { + int minutes = Math.Max(0, (int)Math.Ceiling((double)minDelta.Value / 60.0)); + string plural2 = ((minutes == 1) ? "minute" : "minutes"); + log.Debug($"[SubmarineManager] Next submarine in {minutes} {plural2}"); + } + return false; + } + catch (Exception ex) + { + log.Error("[SubmarineManager] Error checking submarines: " + ex.Message); + return false; + } + } + + public int CheckSubmarinesSoon() + { + if (!config.EnableSubmarineCheck) + { + return 0; + } + string configPath = GetConfigPath(); + if (string.IsNullOrEmpty(configPath) || !File.Exists(configPath)) + { + return 0; + } + try + { + string content = File.ReadAllText(configPath); + if (string.IsNullOrEmpty(content)) + { + return 0; + } + List returnTimes = ParseReturnTimes(content); + if (returnTimes.Count == 0) + { + return 0; + } + long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + long? minDelta = null; + int availableNow = 0; + foreach (long item in returnTimes) + { + long delta = item - now; + if (delta <= 0) + { + availableNow++; + } + else if (delta <= 120 && (!minDelta.HasValue || delta < minDelta.Value)) + { + minDelta = delta; + } + } + if (availableNow > 0) + { + log.Debug($"[SubmarineManager] {availableNow} submarines ready NOW - continue Multi-Mode"); + return 999; + } + if (minDelta.HasValue) + { + int minutes = (int)Math.Ceiling((double)minDelta.Value / 60.0); + log.Debug($"[SubmarineManager] Submarine will be ready in {minDelta.Value} seconds ({minutes} min) - waiting before character switch"); + return (int)minDelta.Value; + } + if (submarineNoAvailableWaitEnd == DateTime.MinValue) + { + submarineNoAvailableWaitEnd = DateTime.Now.AddSeconds(60.0); + log.Information("[SubmarineManager] No submarines available - waiting 60 seconds before relog"); + return 60; + } + if (DateTime.Now < submarineNoAvailableWaitEnd) + { + int remaining = (int)(submarineNoAvailableWaitEnd - DateTime.Now).TotalSeconds; + log.Debug($"[SubmarineManager] Waiting {remaining}s before relog..."); + return remaining; + } + submarineNoAvailableWaitEnd = DateTime.MinValue; + return 0; + } + catch (Exception ex) + { + log.Error("[SubmarineManager] Error checking submarines soon: " + ex.Message); + return 0; + } + } + + private List ParseReturnTimes(string jsonContent) + { + List returnTimes = new List(); + try + { + JObject json = JObject.Parse(jsonContent); + FindReturnTimes(json, returnTimes); + } + catch + { + string pattern = "\"ReturnTime\"\\s*:\\s*(\\d+)"; + foreach (Match match in Regex.Matches(jsonContent, pattern)) + { + if (match.Groups.Count > 1 && long.TryParse(match.Groups[1].Value, out var timestamp)) + { + returnTimes.Add(timestamp); + } + } + } + return returnTimes; + } + + private void FindReturnTimes(JToken token, List returnTimes) + { + if (token is JObject obj) + { + { + foreach (JProperty property in obj.Properties()) + { + if (property.Name == "ReturnTime" && property.Value.Type == JTokenType.Integer) + { + returnTimes.Add(property.Value.Value()); + } + else + { + FindReturnTimes(property.Value, returnTimes); + } + } + return; + } + } + if (!(token is JArray array)) + { + return; + } + foreach (JToken item in array) + { + FindReturnTimes(item, returnTimes); + } + } + + public void StartSubmarineWait(string currentCharacter) + { + submarinesWaitingForSeq0 = true; + originalCharacterForSubmarines = currentCharacter; + log.Information("[SubmarineManager] Waiting for Sequence 0 completion before enabling Multi-Mode"); + } + + public void EnableMultiMode() + { + if (!autoRetainerIPC.IsAvailable) + { + log.Warning("[SubmarineManager] AutoRetainer not available - cannot enable Multi-Mode"); + return; + } + try + { + if (autoRetainerIPC.GetMultiModeEnabled()) + { + log.Information("[SubmarineManager] Multi-Mode is already enabled - skipping activation"); + submarinesPaused = true; + submarinesWaitingForSeq0 = false; + return; + } + if (commandManager != null && framework != null) + { + log.Information("[SubmarineManager] Sending /ays multi e command..."); + framework.RunOnFrameworkThread(delegate + { + commandManager.ProcessCommand("/ays multi e"); + }).Wait(); + log.Information("[SubmarineManager] ✓ /ays multi e command sent"); + } + autoRetainerIPC.SetMultiModeEnabled(enabled: true); + submarinesPaused = true; + submarinesWaitingForSeq0 = false; + log.Information("[SubmarineManager] Multi-Mode enabled - quest automation paused"); + } + catch (Exception ex) + { + log.Error("[SubmarineManager] Failed to enable Multi-Mode: " + ex.Message); + } + } + + public void DisableMultiModeAndReturn() + { + if (!autoRetainerIPC.IsAvailable) + { + log.Warning("[SubmarineManager] AutoRetainer not available"); + return; + } + try + { + if (commandManager != null && framework != null) + { + log.Information("[SubmarineManager] Sending /ays multi d command..."); + framework.RunOnFrameworkThread(delegate + { + commandManager.ProcessCommand("/ays multi d"); + }).Wait(); + log.Information("[SubmarineManager] ✓ /ays multi d command sent"); + } + autoRetainerIPC.SetMultiModeEnabled(enabled: false); + log.Information("[SubmarineManager] Multi-Mode disabled - starting return to original character"); + submarineNoAvailableWaitEnd = DateTime.MinValue; + if (!string.IsNullOrEmpty(originalCharacterForSubmarines)) + { + submarineReloginInProgress = true; + log.Information("[SubmarineManager] Returning to original character: " + originalCharacterForSubmarines); + } + } + catch (Exception ex) + { + log.Error("[SubmarineManager] Failed to disable Multi-Mode: " + ex.Message); + } + } + + public void CompleteSubmarineRelog() + { + submarineReloginInProgress = false; + submarinesPaused = false; + submarineJustCompleted = true; + submarineReloginCooldownEnd = DateTime.Now.AddSeconds(config.SubmarineReloginCooldown); + log.Information($"[SubmarineManager] Submarine rotation complete - cooldown active for {config.SubmarineReloginCooldown} seconds"); + } + + public bool IsSubmarineCooldownActive() + { + return DateTime.Now < submarineReloginCooldownEnd; + } + + public void ClearSubmarineJustCompleted() + { + submarineJustCompleted = false; + log.Information("[SubmarineManager] Cooldown expired - submarine checks re-enabled"); + } + + public void Reset() + { + submarinesPaused = false; + submarinesWaitingForSeq0 = false; + submarineReloginInProgress = false; + submarineJustCompleted = false; + originalCharacterForSubmarines = null; + submarineReloginCooldownEnd = DateTime.MinValue; + log.Information("[SubmarineManager] State reset"); + } + + public void Dispose() + { + Reset(); + log.Information("[SubmarineManager] Service disposed"); + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Services/VNavmeshIPC.cs b/QuestionableCompanion/QuestionableCompanion.Services/VNavmeshIPC.cs new file mode 100644 index 0000000..ce01469 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Services/VNavmeshIPC.cs @@ -0,0 +1,62 @@ +using System; +using System.Numerics; +using Dalamud.Plugin; +using Dalamud.Plugin.Ipc; + +namespace QuestionableCompanion.Services; + +public class VNavmeshIPC : IDisposable +{ + private readonly ICallGateSubscriber pointOnFloorSubscriber; + + private readonly ICallGateSubscriber nearestPointSubscriber; + + private readonly ICallGateSubscriber isReadySubscriber; + + public VNavmeshIPC(IDalamudPluginInterface pluginInterface) + { + pointOnFloorSubscriber = pluginInterface.GetIpcSubscriber("vnavmesh.Query.Mesh.PointOnFloor"); + nearestPointSubscriber = pluginInterface.GetIpcSubscriber("vnavmesh.Query.Mesh.NearestPoint"); + isReadySubscriber = pluginInterface.GetIpcSubscriber("vnavmesh.Nav.IsReady"); + } + + public bool IsReady() + { + try + { + return isReadySubscriber.InvokeFunc(); + } + catch + { + return false; + } + } + + public Vector3? FindPointOnFloor(Vector3 position, bool allowUnlandable = false, float searchRadius = 10f) + { + try + { + return pointOnFloorSubscriber.InvokeFunc(position, allowUnlandable, searchRadius); + } + catch (Exception) + { + return null; + } + } + + public Vector3? FindNearestPoint(Vector3 position, float horizontalRadius = 10f, float verticalRadius = 5f) + { + try + { + return nearestPointSubscriber.InvokeFunc(position, horizontalRadius, verticalRadius); + } + catch (Exception) + { + return null; + } + } + + public void Dispose() + { + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Windows/AlliedSocietyPriorityWindow.cs b/QuestionableCompanion/QuestionableCompanion.Windows/AlliedSocietyPriorityWindow.cs new file mode 100644 index 0000000..3e35ba6 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Windows/AlliedSocietyPriorityWindow.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Windowing; +using QuestionableCompanion.Helpers; +using QuestionableCompanion.Models; +using QuestionableCompanion.Services; + +namespace QuestionableCompanion.Windows; + +public class AlliedSocietyPriorityWindow : Window, IDisposable +{ + private readonly Configuration configuration; + + private readonly AlliedSocietyDatabase database; + + private List editingPriorities = new List(); + + private int? draggedIndex; + + private const string DragDropId = "ALLIED_SOCIETY_PRIORITY"; + + private readonly Dictionary societyNames = new Dictionary + { + { 1, "Amalj'aa" }, + { 2, "Sylphs" }, + { 3, "Kobolds" }, + { 4, "Sahagin" }, + { 5, "Ixal" }, + { 6, "Vanu Vanu" }, + { 7, "Vath" }, + { 8, "Moogles" }, + { 9, "Kojin" }, + { 10, "Ananta" }, + { 11, "Namazu" }, + { 12, "Pixies" }, + { 13, "Qitari" }, + { 14, "Dwarves" }, + { 15, "Arkasodara" }, + { 16, "Omicrons" }, + { 17, "Loporrits" }, + { 18, "Pelupelu" }, + { 19, "Mamool Ja" }, + { 20, "Yok Huy" } + }; + + public AlliedSocietyPriorityWindow(Configuration configuration, AlliedSocietyDatabase database) + : base("Allied Society Priority Configuration", ImGuiWindowFlags.NoCollapse) + { + this.configuration = configuration; + this.database = database; + base.Size = new Vector2(400f, 600f); + base.SizeCondition = ImGuiCond.FirstUseEver; + } + + public void Dispose() + { + } + + public override void OnOpen() + { + if (configuration.AlliedSociety.RotationConfig.Priorities.Count == 0) + { + configuration.AlliedSociety.RotationConfig.InitializeDefaults(); + database.SaveToConfig(); + } + editingPriorities = (from p in configuration.AlliedSociety.RotationConfig.Priorities + orderby p.Order + select new AlliedSocietyPriority + { + SocietyId = p.SocietyId, + Enabled = p.Enabled, + Order = p.Order + }).ToList(); + } + + public override void Draw() + { + ImGui.TextWrapped("Drag societies to reorder priorities. Uncheck to disable specific societies."); + ImGui.Separator(); + float availableHeight = ImGui.GetWindowSize().Y - 120f; + ImGui.BeginChild("PriorityList", new Vector2(0f, availableHeight), border: true); + for (int i = 0; i < editingPriorities.Count; i++) + { + AlliedSocietyPriority priority = editingPriorities[i]; + string name = (societyNames.ContainsKey(priority.SocietyId) ? societyNames[priority.SocietyId] : $"Unknown ({priority.SocietyId})"); + ImGui.PushID(i); + if (draggedIndex == i) + { + uint highlightColor = ImGui.ColorConvertFloat4ToU32(new Vector4(0f, 0.7f, 0f, 0.3f)); + Vector2 cursorPos = ImGui.GetCursorScreenPos(); + Vector2 itemSize = new Vector2(ImGui.GetContentRegionAvail().X, ImGui.GetFrameHeight()); + ImGui.GetWindowDrawList().AddRectFilled(cursorPos, cursorPos + itemSize, highlightColor); + } + ImGui.PushFont(UiBuilder.IconFont); + ImU8String label = new ImU8String(6, 1); + label.AppendFormatted(FontAwesomeIcon.ArrowsUpDownLeftRight.ToIconString()); + label.AppendLiteral("##drag"); + ImGui.Button(label); + ImGui.PopFont(); + if (ImGui.IsItemHovered()) + { + ImGui.SetMouseCursor(ImGuiMouseCursor.ResizeAll); + } + if (ImGui.BeginDragDropSource()) + { + draggedIndex = i; + ImGuiDragDrop.SetDragDropPayload("ALLIED_SOCIETY_PRIORITY", i); + ImGui.Text(name); + ImGui.EndDragDropSource(); + } + else if (draggedIndex == i && !ImGui.IsMouseDown(ImGuiMouseButton.Left)) + { + draggedIndex = null; + } + if (ImGui.BeginDragDropTarget()) + { + if (ImGuiDragDrop.AcceptDragDropPayload("ALLIED_SOCIETY_PRIORITY", out var sourceIndex) && sourceIndex != i) + { + AlliedSocietyPriority item = editingPriorities[sourceIndex]; + editingPriorities.RemoveAt(sourceIndex); + editingPriorities.Insert(i, item); + UpdateOrders(); + draggedIndex = i; + } + ImGui.EndDragDropTarget(); + } + ImGui.SameLine(); + bool enabled = priority.Enabled; + ImU8String label2 = new ImU8String(9, 0); + label2.AppendLiteral("##enabled"); + if (ImGui.Checkbox(label2, ref enabled)) + { + priority.Enabled = enabled; + } + ImGui.SameLine(); + ImGui.Text(name); + ImGui.PopID(); + } + ImGui.EndChild(); + ImGui.Separator(); + if (ImGui.Button("Save")) + { + Save(); + base.IsOpen = false; + } + ImGui.SameLine(); + if (ImGui.Button("Cancel")) + { + base.IsOpen = false; + } + } + + private void UpdateOrders() + { + for (int i = 0; i < editingPriorities.Count; i++) + { + editingPriorities[i].Order = i; + } + } + + private void Save() + { + UpdateOrders(); + configuration.AlliedSociety.RotationConfig.Priorities = editingPriorities; + database.SaveToConfig(); + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Windows/ConfigWindow.cs b/QuestionableCompanion/QuestionableCompanion.Windows/ConfigWindow.cs new file mode 100644 index 0000000..7cb7e59 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Windows/ConfigWindow.cs @@ -0,0 +1,564 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.Windowing; +using QuestionableCompanion.Services; + +namespace QuestionableCompanion.Windows; + +public class ConfigWindow : Window, IDisposable +{ + private readonly Configuration configuration; + + private readonly Plugin plugin; + + public ConfigWindow(Plugin plugin) + : base("Questionable Companion Settings###QCSettings") + { + base.Size = new Vector2(600f, 400f); + base.SizeCondition = ImGuiCond.FirstUseEver; + this.plugin = plugin; + configuration = plugin.Configuration; + } + + public void Dispose() + { + } + + public override void PreDraw() + { + if (configuration.IsConfigWindowMovable) + { + base.Flags &= ~ImGuiWindowFlags.NoMove; + } + else + { + base.Flags |= ImGuiWindowFlags.NoMove; + } + } + + public override void Draw() + { + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1f, 0.8f, 0.2f, 1f)); + ImGui.TextWrapped("Configuration Moved!"); + ImGui.PopStyleColor(); + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + ImGui.TextWrapped("The configuration interface has been moved to the new Main Window for a better user experience."); + ImGui.Spacing(); + ImGui.TextWrapped("All settings are now available in the Main Window with improved organization and features:"); + ImGui.Spacing(); + ImGui.BulletText("Quest Rotation Management"); + ImGui.BulletText("Event Quest Automation"); + ImGui.BulletText("MSQ Progress Tracking"); + ImGui.BulletText("DC Travel Configuration"); + ImGui.BulletText("Advanced Settings"); + ImGui.BulletText("And much more!"); + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + if (ImGui.Button(size: new Vector2(ImGui.GetContentRegionAvail().X, 50f), label: "Open Main Window (Settings Tab)")) + { + plugin.ToggleMainUi(); + base.IsOpen = false; + } + ImGui.Spacing(); + ImGui.Spacing(); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.7f, 0.7f, 0.7f, 1f)); + ImGui.TextWrapped("This legacy configuration window will be removed in a future update."); + ImGui.TextWrapped("Please use the Main Window for all configuration needs."); + ImGui.PopStyleColor(); + } + + private void DrawGeneralTab() + { + ImGui.Text("General Settings"); + ImGui.Separator(); + ImGui.Spacing(); + bool autoStart = configuration.AutoStartOnLogin; + if (ImGui.Checkbox("Auto-start on login", ref autoStart)) + { + configuration.AutoStartOnLogin = autoStart; + configuration.Save(); + } + bool dryRun = configuration.EnableDryRun; + if (ImGui.Checkbox("Enable Dry Run Mode (simulate without executing)", ref dryRun)) + { + configuration.EnableDryRun = dryRun; + configuration.Save(); + } + bool restoreState = configuration.RestoreStateOnLoad; + if (ImGui.Checkbox("Restore state on plugin load", ref restoreState)) + { + configuration.RestoreStateOnLoad = restoreState; + configuration.Save(); + } + ImGui.Spacing(); + ImGui.Text("Execution Settings"); + ImGui.Separator(); + ImGui.Spacing(); + int maxRetries = configuration.MaxRetryAttempts; + if (ImGui.SliderInt("Max retry attempts", ref maxRetries, 1, 10)) + { + configuration.MaxRetryAttempts = maxRetries; + configuration.Save(); + } + int switchDelay = configuration.CharacterSwitchDelay; + if (ImGui.SliderInt("Character switch delay (seconds)", ref switchDelay, 3, 15)) + { + configuration.CharacterSwitchDelay = switchDelay; + configuration.Save(); + } + ImGui.Spacing(); + ImGui.Text("Logging Settings"); + ImGui.Separator(); + ImGui.Spacing(); + int maxLogs = configuration.MaxLogEntries; + if (ImGui.SliderInt("Max log entries", ref maxLogs, 50, 500)) + { + configuration.MaxLogEntries = maxLogs; + configuration.Save(); + } + bool showDebug = configuration.ShowDebugLogs; + if (ImGui.Checkbox("Show debug logs", ref showDebug)) + { + configuration.ShowDebugLogs = showDebug; + configuration.Save(); + } + bool logToFile = configuration.LogToFile; + if (ImGui.Checkbox("Log to file", ref logToFile)) + { + configuration.LogToFile = logToFile; + configuration.Save(); + } + ImGui.Spacing(); + ImGui.Text("UI Settings"); + ImGui.Separator(); + ImGui.Spacing(); + bool movable = configuration.IsConfigWindowMovable; + if (ImGui.Checkbox("Movable config window", ref movable)) + { + configuration.IsConfigWindowMovable = movable; + configuration.Save(); + } + } + + private void DrawAdvancedFeaturesTab() + { + ImGui.TextColored(new Vector4(0.3f, 0.8f, 1f, 1f), "Submarine Management"); + ImGui.Separator(); + ImGui.Spacing(); + bool enableSubmarineCheck = configuration.EnableSubmarineCheck; + if (ImGui.Checkbox("Enable Submarine Monitoring", ref enableSubmarineCheck)) + { + configuration.EnableSubmarineCheck = enableSubmarineCheck; + configuration.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Automatically monitor submarines and pause quest rotation when submarines are ready"); + } + if (configuration.EnableSubmarineCheck) + { + ImGui.Indent(); + int submarineCheckInterval = configuration.SubmarineCheckInterval; + if (ImGui.SliderInt("Check Interval (seconds)", ref submarineCheckInterval, 30, 300)) + { + configuration.SubmarineCheckInterval = submarineCheckInterval; + configuration.Save(); + } + int submarineReloginCooldown = configuration.SubmarineReloginCooldown; + if (ImGui.SliderInt("Cooldown after Relog (seconds)", ref submarineReloginCooldown, 60, 300)) + { + configuration.SubmarineReloginCooldown = submarineReloginCooldown; + configuration.Save(); + } + int submarineWaitTime = configuration.SubmarineWaitTime; + if (ImGui.SliderInt("Wait time before submarine (seconds)", ref submarineWaitTime, 10, 120)) + { + configuration.SubmarineWaitTime = submarineWaitTime; + configuration.Save(); + } + ImGui.Unindent(); + } + ImGui.Spacing(); + ImGui.Spacing(); + ImGui.TextColored(new Vector4(0.3f, 0.8f, 1f, 1f), "Movement Monitor"); + ImGui.Separator(); + ImGui.Spacing(); + bool enableMovementMonitor = configuration.EnableMovementMonitor; + if (ImGui.Checkbox("Enable Movement Monitor", ref enableMovementMonitor)) + { + configuration.EnableMovementMonitor = enableMovementMonitor; + configuration.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Automatically detect if player is stuck and send /qst reload"); + } + if (configuration.EnableMovementMonitor) + { + ImGui.Indent(); + int movementCheckInterval = configuration.MovementCheckInterval; + if (ImGui.SliderInt("Check Interval (seconds)##movement", ref movementCheckInterval, 3, 30)) + { + configuration.MovementCheckInterval = movementCheckInterval; + configuration.Save(); + } + int movementStuckThreshold = configuration.MovementStuckThreshold; + if (ImGui.SliderInt("Stuck Threshold (seconds)", ref movementStuckThreshold, 15, 120)) + { + configuration.MovementStuckThreshold = movementStuckThreshold; + configuration.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Time without movement before sending /qst reload"); + } + ImGui.Unindent(); + } + ImGui.Spacing(); + ImGui.Spacing(); + ImGui.TextColored(new Vector4(1f, 0.5f, 0.3f, 1f), "Combat Handling"); + ImGui.Separator(); + ImGui.Spacing(); + bool enableCombatHandling = configuration.EnableCombatHandling; + if (ImGui.Checkbox("Enable Combat Handling", ref enableCombatHandling)) + { + configuration.EnableCombatHandling = enableCombatHandling; + configuration.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Automatically enable RSR/VBMAI/BMRAI when HP drops below threshold during combat"); + } + if (configuration.EnableCombatHandling) + { + ImGui.Indent(); + int combatHPThreshold = configuration.CombatHPThreshold; + if (ImGui.SliderInt("HP Threshold (%)", ref combatHPThreshold, 1, 99)) + { + configuration.CombatHPThreshold = combatHPThreshold; + configuration.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Enable combat automation when HP drops below this percentage\nCommands: /rsr manual, /vbmai on, /bmrai on"); + } + ImGui.TextWrapped("When HP drops below threshold:"); + ImGui.BulletText("/rsr manual"); + ImGui.BulletText("/vbmai on"); + ImGui.BulletText("/bmrai on"); + ImGui.Spacing(); + ImGui.TextWrapped("When combat ends:"); + ImGui.BulletText("/rsr off"); + ImGui.BulletText("/vbmai off"); + ImGui.BulletText("/bmrai off"); + ImGui.Unindent(); + } + ImGui.Spacing(); + ImGui.Spacing(); + ImGui.TextColored(new Vector4(1f, 0.3f, 0.3f, 1f), "Death Handling"); + ImGui.Separator(); + ImGui.Spacing(); + bool enableDeathHandling = configuration.EnableDeathHandling; + if (ImGui.Checkbox("Enable Death Handling", ref enableDeathHandling)) + { + configuration.EnableDeathHandling = enableDeathHandling; + configuration.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Automatically respawn when player dies"); + } + if (configuration.EnableDeathHandling) + { + ImGui.Indent(); + int deathRespawnDelay = configuration.DeathRespawnDelay; + if (ImGui.SliderInt("Teleport Delay (seconds)", ref deathRespawnDelay, 1, 30)) + { + configuration.DeathRespawnDelay = deathRespawnDelay; + configuration.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Time to wait after respawn before teleporting back to death location"); + } + ImGui.Spacing(); + ImGui.TextWrapped("On Death:"); + ImGui.BulletText("Detect 0% HP"); + ImGui.BulletText("Save position & territory"); + ImGui.BulletText("Auto-click SelectYesNo (respawn)"); + ImU8String text = new ImU8String(13, 1); + text.AppendLiteral("Wait "); + text.AppendFormatted(configuration.DeathRespawnDelay); + text.AppendLiteral(" seconds"); + ImGui.BulletText(text); + ImGui.BulletText("Teleport back to death location"); + ImGui.Unindent(); + } + ImGui.Spacing(); + ImGui.Spacing(); + ImGui.TextColored(new Vector4(0.3f, 0.8f, 1f, 1f), "Logging Settings"); + ImGui.Separator(); + ImGui.Spacing(); + bool logToDalamud = configuration.LogToDalamud; + if (ImGui.Checkbox("Log to Dalamud Log", ref logToDalamud)) + { + configuration.LogToDalamud = logToDalamud; + configuration.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Enable to also log to Dalamud log (can cause spam)"); + } + ImGui.Spacing(); + ImGui.Spacing(); + ImGui.TextColored(new Vector4(0.3f, 0.8f, 1f, 1f), "Dungeon Automation"); + ImGui.Separator(); + ImGui.Spacing(); + bool enableAutoDutyUnsynced = configuration.EnableAutoDutyUnsynced; + if (ImGui.Checkbox("Enable AutoDuty Unsynced", ref enableAutoDutyUnsynced)) + { + configuration.EnableAutoDutyUnsynced = enableAutoDutyUnsynced; + configuration.Save(); + plugin.GetDungeonAutomation()?.SetDutyModeBasedOnConfig(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Run dungeons unsynced for faster completion"); + } + if (configuration.EnableAutoDutyUnsynced) + { + ImGui.Indent(); + int autoDutyPartySize = configuration.AutoDutyPartySize; + if (ImGui.SliderInt("Party Size Check (members)", ref autoDutyPartySize, 1, 8)) + { + configuration.AutoDutyPartySize = autoDutyPartySize; + configuration.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Minimum party size required before starting dungeon"); + } + int autoDutyMaxWaitForParty = configuration.AutoDutyMaxWaitForParty; + if (ImGui.SliderInt("Max Wait for Party (seconds)", ref autoDutyMaxWaitForParty, 10, 120)) + { + configuration.AutoDutyMaxWaitForParty = autoDutyMaxWaitForParty; + configuration.Save(); + } + int autoDutyReInviteInterval = configuration.AutoDutyReInviteInterval; + if (ImGui.SliderInt("Re-invite Interval (seconds)", ref autoDutyReInviteInterval, 5, 60)) + { + configuration.AutoDutyReInviteInterval = autoDutyReInviteInterval; + configuration.Save(); + } + ImGui.Unindent(); + } + ImGui.Spacing(); + ImGui.Spacing(); + ImGui.TextColored(new Vector4(0.3f, 0.8f, 1f, 1f), "Quest Automation"); + ImGui.Separator(); + ImGui.Spacing(); + bool enableQSTReloadTracking = configuration.EnableQSTReloadTracking; + if (ImGui.Checkbox("Enable QST Reload Tracking", ref enableQSTReloadTracking)) + { + configuration.EnableQSTReloadTracking = enableQSTReloadTracking; + configuration.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Track Questionable reloads and switch character if too many reloads occur"); + } + if (configuration.EnableQSTReloadTracking) + { + ImGui.Indent(); + int maxQSTReloadsBeforeSwitch = configuration.MaxQSTReloadsBeforeSwitch; + if (ImGui.SliderInt("Max Reloads before switch", ref maxQSTReloadsBeforeSwitch, 1, 20)) + { + configuration.MaxQSTReloadsBeforeSwitch = maxQSTReloadsBeforeSwitch; + configuration.Save(); + } + ImGui.Unindent(); + } + ImGui.Spacing(); + ImGui.Spacing(); + ImGui.Spacing(); + ImGui.TextColored(new Vector4(0.3f, 0.8f, 1f, 1f), "Character Management"); + ImGui.Separator(); + ImGui.Spacing(); + bool enableMultiModeAfterRotation = configuration.EnableMultiModeAfterRotation; + if (ImGui.Checkbox("Enable Multi-Mode after Rotation", ref enableMultiModeAfterRotation)) + { + configuration.EnableMultiModeAfterRotation = enableMultiModeAfterRotation; + configuration.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Enable AutoRetainer Multi-Mode after completing character rotation"); + } + bool returnToHomeworldOnStopQuest = configuration.ReturnToHomeworldOnStopQuest; + if (ImGui.Checkbox("Return to Homeworld on Stop Quest", ref returnToHomeworldOnStopQuest)) + { + configuration.ReturnToHomeworldOnStopQuest = returnToHomeworldOnStopQuest; + configuration.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Automatically return to homeworld when stop quest is completed"); + } + ImGui.Spacing(); + ImGui.Spacing(); + ImGui.TextColored(new Vector4(0.3f, 0.8f, 1f, 1f), "Safe Wait Settings"); + ImGui.Separator(); + ImGui.Spacing(); + bool enableSafeWaitBefore = configuration.EnableSafeWaitBeforeCharacterSwitch; + if (ImGui.Checkbox("Enable Safe Wait Before Character Switch", ref enableSafeWaitBefore)) + { + configuration.EnableSafeWaitBeforeCharacterSwitch = enableSafeWaitBefore; + configuration.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Wait for character to stabilize (movement, actions) before switching"); + } + bool enableSafeWaitAfter = configuration.EnableSafeWaitAfterCharacterSwitch; + if (ImGui.Checkbox("Enable Safe Wait After Character Switch", ref enableSafeWaitAfter)) + { + configuration.EnableSafeWaitAfterCharacterSwitch = enableSafeWaitAfter; + configuration.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Wait for character to fully load after switching"); + } + ImGui.Spacing(); + ImGui.Spacing(); + ImGui.TextColored(new Vector4(0.3f, 0.8f, 1f, 1f), "Quest Pre-Check"); + ImGui.Separator(); + ImGui.Spacing(); + bool enableQuestPreCheck = configuration.EnableQuestPreCheck; + if (ImGui.Checkbox("Enable Quest Pre-Check", ref enableQuestPreCheck)) + { + configuration.EnableQuestPreCheck = enableQuestPreCheck; + configuration.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Check quest completion status before starting rotation to skip completed characters"); + } + ImGui.TextWrapped("Quest Pre-Check scans all characters for completed quests before rotation starts, preventing unnecessary character switches."); + ImGui.Spacing(); + ImGui.Spacing(); + ImGui.TextColored(new Vector4(0.3f, 0.8f, 1f, 1f), "DC Travel World Selector"); + ImGui.Separator(); + ImGui.Spacing(); + ImGui.TextWrapped("Configure world travel for Data Center travel quests. Requires Lifestream plugin."); + ImGui.Spacing(); + string[] datacenters = configuration.WorldsByDatacenter.Keys.ToArray(); + int currentDCIndex = Array.IndexOf(datacenters, configuration.SelectedDatacenter); + if (currentDCIndex < 0) + { + currentDCIndex = 0; + } + ImGui.Text("Select Datacenter:"); + if (ImGui.Combo((ImU8String)"##DCSelector", ref currentDCIndex, (ReadOnlySpan)datacenters, datacenters.Length)) + { + configuration.SelectedDatacenter = datacenters[currentDCIndex]; + if (configuration.WorldsByDatacenter.TryGetValue(configuration.SelectedDatacenter, out List newWorlds) && newWorlds.Count > 0) + { + configuration.DCTravelWorld = newWorlds[0]; + } + configuration.Save(); + } + ImGui.Spacing(); + if (configuration.WorldsByDatacenter.TryGetValue(configuration.SelectedDatacenter, out List worlds)) + { + string[] worldArray = worlds.ToArray(); + int currentWorldIndex = Array.IndexOf(worldArray, configuration.DCTravelWorld); + if (currentWorldIndex < 0) + { + currentWorldIndex = 0; + } + ImGui.Text("Select Target World:"); + if (ImGui.Combo((ImU8String)"##WorldSelector", ref currentWorldIndex, (ReadOnlySpan)worldArray, worldArray.Length)) + { + configuration.DCTravelWorld = worldArray[currentWorldIndex]; + configuration.EnableDCTravel = !string.IsNullOrEmpty(configuration.DCTravelWorld); + configuration.Save(); + } + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + LifestreamIPC lifestreamIPC = Plugin.Instance?.LifestreamIPC; + if (lifestreamIPC != null && !lifestreamIPC.IsAvailable) + { + lifestreamIPC.ForceCheckAvailability(); + } + bool lifestreamAvailable = lifestreamIPC?.IsAvailable ?? false; + if (!lifestreamAvailable) + { + ImGui.BeginDisabled(); + } + bool enableDCTravel = configuration.EnableDCTravel; + if (ImGui.Checkbox("Enable DC Travel", ref enableDCTravel)) + { + configuration.EnableDCTravel = enableDCTravel; + configuration.Save(); + } + if (!lifestreamAvailable) + { + ImGui.EndDisabled(); + } + if (ImGui.IsItemHovered()) + { + if (!lifestreamAvailable) + { + ImGui.SetTooltip("Lifestream plugin is required for DC Travel!\nPlease install and enable Lifestream to use this feature."); + } + else + { + ImGui.SetTooltip("Enable automatic DC travel when DC travel quests are detected"); + } + } + if (!lifestreamAvailable) + { + ImGui.Spacing(); + ImGui.TextColored(new Vector4(1f, 0.5f, 0f, 1f), "⚠\ufe0f Lifestream plugin not available!"); + ImGui.TextWrapped("DC Travel requires Lifestream to be installed and enabled."); + } + ImGui.Spacing(); + if (configuration.EnableDCTravel && !string.IsNullOrEmpty(configuration.DCTravelWorld)) + { + Vector4 col = new Vector4(0.2f, 1f, 0.2f, 1f); + ImU8String text2 = new ImU8String(21, 1); + text2.AppendLiteral("✓ DC Travel ACTIVE → "); + text2.AppendFormatted(configuration.DCTravelWorld); + ImGui.TextColored(in col, text2); + ImU8String text3 = new ImU8String(100, 1); + text3.AppendLiteral("Character will travel to "); + text3.AppendFormatted(configuration.DCTravelWorld); + text3.AppendLiteral(" immediately after login, then return to homeworld before character switch."); + ImGui.TextWrapped(text3); + } + else if (!configuration.EnableDCTravel) + { + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.7f, 1f), "○ DC Travel disabled"); + } + else + { + ImGui.TextColored(new Vector4(1f, 0.5f, 0f, 1f), "⚠ DC Travel enabled but no world selected!"); + } + } + else + { + ImGui.TextColored(new Vector4(1f, 0.5f, 0f, 1f), "No worlds available for selected datacenter"); + } + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Windows/DebugWindow.cs b/QuestionableCompanion/QuestionableCompanion.Windows/DebugWindow.cs new file mode 100644 index 0000000..2fd48be --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Windows/DebugWindow.cs @@ -0,0 +1,161 @@ +using System; +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.Windowing; +using QuestionableCompanion.Services; + +namespace QuestionableCompanion.Windows; + +public class DebugWindow : Window, IDisposable +{ + private readonly Plugin plugin; + + private readonly CombatDutyDetectionService? combatDutyDetection; + + private readonly DeathHandlerService? deathHandler; + + private readonly DungeonAutomationService? dungeonAutomation; + + public DebugWindow(Plugin plugin, CombatDutyDetectionService? combatDutyDetection, DeathHandlerService? deathHandler, DungeonAutomationService? dungeonAutomation) + : base("QST Companion Debug###QSTDebug", ImGuiWindowFlags.NoCollapse) + { + this.plugin = plugin; + this.combatDutyDetection = combatDutyDetection; + this.deathHandler = deathHandler; + this.dungeonAutomation = dungeonAutomation; + base.Size = new Vector2(500f, 400f); + base.SizeCondition = ImGuiCond.FirstUseEver; + } + + public void Dispose() + { + } + + public override void Draw() + { + ImGui.TextColored(new Vector4(1f, 0.5f, 0f, 1f), " DEBUG MENU - FOR TESTING ONLY "); + ImGui.Separator(); + ImGui.Spacing(); + ImGui.TextColored(new Vector4(1f, 0.3f, 0.3f, 1f), "Combat Handling"); + ImGui.Separator(); + if (combatDutyDetection != null) + { + ImU8String text = new ImU8String(11, 1); + text.AppendLiteral("In Combat: "); + text.AppendFormatted(combatDutyDetection.IsInCombat); + ImGui.Text(text); + ImU8String text2 = new ImU8String(9, 1); + text2.AppendLiteral("In Duty: "); + text2.AppendFormatted(combatDutyDetection.IsInDuty); + ImGui.Text(text2); + ImU8String text3 = new ImU8String(15, 1); + text3.AppendLiteral("In Duty Queue: "); + text3.AppendFormatted(combatDutyDetection.IsInDutyQueue); + ImGui.Text(text3); + ImU8String text4 = new ImU8String(14, 1); + text4.AppendLiteral("Should Pause: "); + text4.AppendFormatted(combatDutyDetection.ShouldPauseAutomation); + ImGui.Text(text4); + ImGui.Spacing(); + if (ImGui.Button("Test Combat Detection")) + { + combatDutyDetection.Update(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Manually trigger combat detection update"); + } + ImGui.SameLine(); + if (ImGui.Button("Reset Combat State")) + { + combatDutyDetection.Reset(); + } + } + else + { + ImGui.TextColored(new Vector4(1f, 0f, 0f, 1f), "Combat Detection Service not available"); + } + ImGui.Spacing(); + ImGui.Spacing(); + ImGui.TextColored(new Vector4(1f, 0.3f, 0.3f, 1f), "Death Handling"); + ImGui.Separator(); + if (deathHandler != null) + { + ImU8String text5 = new ImU8String(9, 1); + text5.AppendLiteral("Is Dead: "); + text5.AppendFormatted(deathHandler.IsDead); + ImGui.Text(text5); + ImU8String text6 = new ImU8String(19, 1); + text6.AppendLiteral("Time Since Death: "); + text6.AppendFormatted(deathHandler.TimeSinceDeath.TotalSeconds, "F1"); + text6.AppendLiteral("s"); + ImGui.Text(text6); + ImGui.Spacing(); + if (ImGui.Button("Test Death Detection")) + { + deathHandler.Update(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Manually trigger death detection update"); + } + ImGui.SameLine(); + if (ImGui.Button("Reset Death State")) + { + deathHandler.Reset(); + } + } + else + { + ImGui.TextColored(new Vector4(1f, 0f, 0f, 1f), "Death Handler Service not available"); + } + ImGui.Spacing(); + ImGui.Spacing(); + ImGui.TextColored(new Vector4(1f, 0.3f, 0.3f, 1f), "Dungeon Automation"); + ImGui.Separator(); + if (dungeonAutomation != null) + { + ImU8String text7 = new ImU8String(19, 1); + text7.AppendLiteral("Waiting for Party: "); + text7.AppendFormatted(dungeonAutomation.IsWaitingForParty); + ImGui.Text(text7); + ImU8String text8 = new ImU8String(20, 1); + text8.AppendLiteral("Current Party Size: "); + text8.AppendFormatted(dungeonAutomation.CurrentPartySize); + ImGui.Text(text8); + ImGui.Spacing(); + if (ImGui.Button("Test Party Invite")) + { + dungeonAutomation.StartDungeonAutomation(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Test BTB enable + invite + party wait"); + } + ImGui.SameLine(); + if (ImGui.Button("Test Party Disband")) + { + dungeonAutomation.DisbandParty(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Test party disband with /pcmd breakup"); + } + ImGui.SameLine(); + if (ImGui.Button("Reset Dungeon State")) + { + dungeonAutomation.Reset(); + } + } + else + { + ImGui.TextColored(new Vector4(1f, 0f, 0f, 1f), "Dungeon Automation Service not available"); + } + ImGui.Spacing(); + ImGui.Spacing(); + ImGui.Separator(); + ImGui.TextColored(new Vector4(0.5f, 0.8f, 1f, 1f), "Info"); + ImGui.Text("This debug menu allows testing of individual features."); + ImGui.Text("Use /qstcomp dbg to toggle this window."); + } +} diff --git a/QuestionableCompanion/QuestionableCompanion.Windows/NewMainWindow.cs b/QuestionableCompanion/QuestionableCompanion.Windows/NewMainWindow.cs new file mode 100644 index 0000000..f15c267 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion.Windows/NewMainWindow.cs @@ -0,0 +1,3368 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Colors; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Interface.Windowing; +using Dalamud.Plugin.Services; +using Lumina.Excel; +using Lumina.Excel.Sheets; +using QuestionableCompanion.Data; +using QuestionableCompanion.Models; +using QuestionableCompanion.Services; + +namespace QuestionableCompanion.Windows; + +public class NewMainWindow : Window, IDisposable +{ + private class Particle + { + public Vector2 Position; + + public Vector2 Velocity; + + public float Size; + + public float Alpha; + + public Vector4 Color; + } + + private readonly Plugin plugin; + + private readonly AutoRetainerIPC autoRetainerIpc; + + private readonly QuestTrackingService questTrackingService; + + private readonly QuestRotationExecutionService questRotationService; + + private readonly EventQuestExecutionService eventQuestService; + + private readonly AlliedSocietyRotationService alliedSocietyRotationService; + + private readonly AlliedSocietyPriorityWindow alliedSocietyPriorityWindow; + + private readonly DataCenterService dataCenterService; + + private readonly MSQProgressionService msqProgressionService; + + private readonly IPluginLog log; + + private readonly IUiBuilder uiBuilder; + + private readonly IDataManager dataManager; + + private readonly Vector4 colorPrimary = new Vector4(0.478f, 0.686f, 0.878f, 1f); + + private readonly Vector4 colorSecondary = new Vector4(0.949f, 0.769f, 0.388f, 1f); + + private readonly Vector4 colorAccent = new Vector4(0.729f, 0.294f, 0.184f, 1f); + + private readonly Vector4 colorDarkBg = new Vector4(0.12f, 0.12f, 0.12f, 1f); + + private readonly Vector4 colorSidebarBg = new Vector4(0.08f, 0.08f, 0.08f, 1f); + + private readonly Vector4 colorHover = new Vector4(0.2f, 0.2f, 0.2f, 1f); + + private float animTime; + + private float glowPulse; + + private float particleTime; + + private List particles = new List(); + + private int selectedTab; + + private int selectedDCFilter; + + private bool charactersExpanded = true; + + private bool menuExpanded = true; + + private bool isMinimized; + + private List registeredCharacters = new List(); + + private Dictionary characterSelection = new Dictionary(); + + private Dictionary characterProgressCache = new Dictionary(); + + private DateTime lastCharacterRefresh = DateTime.MinValue; + + private bool initialCharacterLoadComplete; + + private DateTime initialLoadStartTime = DateTime.MinValue; + + private int characterLoadAttempts; + + private const int MaxCharacterLoadAttempts = 5; + + private readonly int[] retryDelaysSeconds = new int[5] { 1, 2, 4, 8, 15 }; + + private bool warningMenuRetryCycleComplete; + + private DateTime warningMenuRetryStartTime = DateTime.MinValue; + + private int warningMenuRetryAttempts; + + private const int MaxWarningMenuRetryAttempts = 4; + + private readonly int[] warningMenuRetryDelaysSeconds = new int[4] { 1, 2, 4, 8 }; + + private Dictionary> charactersByDataCenter = new Dictionary>(); + + private List availableDataCenters = new List { "All", "EU", "NA", "JP", "OCE" }; + + private bool showSelectWorldDialog; + + private bool showDeselectWorldDialog; + + private string selectedWorldForBulkAction = ""; + + private List availableWorlds = new List(); + + private string selectedWorldFilter = "All"; + + private bool showWorldActionDropdown; + + private uint inputStopQuestId; + + private int inputStopSequence = -1; + + private string selectedEventQuestId = ""; + + private List<(string QuestId, string QuestName)> availableEventQuests = new List<(string, string)>(); + + private List resolvedPrerequisites = new List(); + + private int eventQuestViewMode; + + private DateTime lastEventQuestRefresh = DateTime.MinValue; + + private readonly Dictionary> dataCenterWorlds = new Dictionary> + { + { + "Aether", + new List { "Adamantoise", "Cactuar", "Faerie", "Gilgamesh", "Jenova", "Midgardsormr", "Sargatanas", "Siren" } + }, + { + "Primal", + new List { "Behemoth", "Excalibur", "Exodus", "Hyperion", "Lamia", "Leviathan", "Ultros" } + }, + { + "Crystal", + new List { "Balmung", "Brynhildr", "Coeurl", "Diabolos", "Goblin", "Malboro", "Mateus", "Zalera" } + }, + { + "Dynamis", + new List { "Halicarnassus", "Maduin", "Marilith", "Seraph" } + }, + { + "Chaos", + new List { "Cerberus", "Louisoix", "Moogle", "Omega", "Phantom", "Ragnarok", "Sagittarius", "Spriggan" } + }, + { + "Light", + new List { "Alpha", "Lich", "Odin", "Phoenix", "Raiden", "Shiva", "Twintania", "Zodiark" } + }, + { + "Materia", + new List { "Bismarck", "Ravana", "Sephirot", "Sophia", "Zurvan" } + }, + { + "Elemental", + new List { "Aegis", "Atomos", "Carbuncle", "Garuda", "Gungnir", "Kujata", "Tonberry", "Typhon" } + }, + { + "Gaia", + new List { "Alexander", "Bahamut", "Durandal", "Fenrir", "Ifrit", "Ridill", "Tiamat", "Ultima" } + }, + { + "Mana", + new List { "Anima", "Asura", "Chocobo", "Hades", "Ixion", "Masamune", "Pandaemonium", "Titan" } + }, + { + "Meteor", + new List { "Belias", "Mandragora", "Ramuh", "Shinryu", "Unicorn", "Valefor", "Yojimbo", "Zeromus" } + } + }; + + private string selectedDataCenter = ""; + + private string selectedWorld = ""; + + private void DrawAlliedSocietyTab() + { + ImGui.TextColored(in colorSecondary, "Allied Society Rotation"); + ImGui.Separator(); + if (ImGui.Button("Configure Priorities")) + { + alliedSocietyPriorityWindow.IsOpen = true; + } + ImGui.SameLine(); + ImGui.TextDisabled("(Use Up/Down buttons to reorder)"); + ImGui.Spacing(); + ImGui.Separator(); + ImGui.TextColored(in colorSecondary, "Quest Selection Mode"); + AlliedSocietyConfiguration config = plugin.Configuration.AlliedSociety.RotationConfig; + bool modeChanged = false; + if (ImGui.RadioButton("Only 3 Quests per Society", config.QuestMode == AlliedSocietyQuestMode.OnlyThreePerSociety)) + { + config.QuestMode = AlliedSocietyQuestMode.OnlyThreePerSociety; + modeChanged = true; + } + if (ImGui.RadioButton("All Available Quests (until 0 allowances)", config.QuestMode == AlliedSocietyQuestMode.AllAvailableQuests)) + { + config.QuestMode = AlliedSocietyQuestMode.AllAvailableQuests; + modeChanged = true; + } + if (modeChanged) + { + plugin.Configuration.Save(); + } + ImGui.Spacing(); + ImGui.Separator(); + List selectedCharacters = (from kvp in characterSelection + where kvp.Value + select kvp.Key).ToList(); + ImGui.TextColored(in colorSecondary, "Rotation Control"); + if (alliedSocietyRotationService.IsRotationActive) + { + if (ImGui.Button("Stop Rotation", new Vector2(150f, 30f))) + { + alliedSocietyRotationService.StopRotation(); + } + ImGui.SameLine(); + Vector4 col = ImGuiColors.DalamudYellow; + ImU8String text = new ImU8String(18, 1); + text.AppendLiteral("Running... Phase: "); + text.AppendFormatted(alliedSocietyRotationService.CurrentPhase); + ImGui.TextColored(in col, text); + ImU8String text2 = new ImU8String(19, 1); + text2.AppendLiteral("Current Character: "); + text2.AppendFormatted(alliedSocietyRotationService.CurrentCharacterId); + ImGui.Text(text2); + } + else + { + if (ImGui.Button("Start Rotation", new Vector2(150f, 30f))) + { + if (selectedCharacters.Count == 0) + { + ImGui.OpenPopup("NoCharactersSelected"); + } + else + { + alliedSocietyRotationService.StartRotation(selectedCharacters); + } + } + if (ImGui.BeginPopup("NoCharactersSelected")) + { + ImGui.Text("Please select at least one character from the Characters tab."); + if (ImGui.Button("OK", new Vector2(120f, 0f))) + { + ImGui.CloseCurrentPopup(); + } + ImGui.EndPopup(); + } + } + ImGui.Spacing(); + ImGui.Separator(); + ref readonly Vector4 col2 = ref colorSecondary; + ImU8String text3 = new ImU8String(28, 1); + text3.AppendLiteral("Character Status ("); + text3.AppendFormatted(selectedCharacters.Count); + text3.AppendLiteral(" selected)"); + ImGui.TextColored(in col2, text3); + if (selectedCharacters.Count > 0 && ImGui.BeginTable("AlliedSocietyStatusTable", 3, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg)) + { + ImGui.TableSetupColumn("Character", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Status", ImGuiTableColumnFlags.WidthFixed, 150f); + ImGui.TableSetupColumn("Allowances", ImGuiTableColumnFlags.WidthFixed, 100f); + ImGui.TableHeadersRow(); + foreach (string character in selectedCharacters) + { + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + if (character == alliedSocietyRotationService.CurrentCharacterId) + { + ImGui.TextColored(ImGuiColors.DalamudYellow, character); + } + else + { + ImGui.Text(character); + } + ImGui.TableNextColumn(); + AlliedSocietyCharacterStatus status = (plugin.Configuration.AlliedSociety.CharacterStatuses.ContainsKey(character) ? plugin.Configuration.AlliedSociety.CharacterStatuses[character] : new AlliedSocietyCharacterStatus + { + CharacterId = character + }); + ImGui.TextColored((status.Status == AlliedSocietyRotationStatus.Complete) ? ImGuiColors.HealerGreen : ImGuiColors.DalamudWhite, status.Status.ToString()); + ImGui.TableNextColumn(); + ImGui.Text("-"); + } + ImGui.EndTable(); + } + else if (selectedCharacters.Count == 0) + { + ImGui.TextColored(ImGuiColors.DalamudGrey, "No characters selected. Please select characters in the Characters tab."); + } + } + + public NewMainWindow(Plugin plugin, AutoRetainerIPC autoRetainerIpc, QuestTrackingService questTrackingService, QuestRotationExecutionService questRotationService, EventQuestExecutionService eventQuestService, AlliedSocietyRotationService alliedSocietyRotationService, AlliedSocietyPriorityWindow alliedSocietyPriorityWindow, DataCenterService dataCenterService, MSQProgressionService msqProgressionService, IPluginLog log, IUiBuilder uiBuilder, IDataManager dataManager) + : base("Questionable Companion##NewMainWindow", ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse | ImGuiWindowFlags.NoBackground) + { + this.plugin = plugin; + this.autoRetainerIpc = autoRetainerIpc; + this.questTrackingService = questTrackingService; + this.questRotationService = questRotationService; + this.eventQuestService = eventQuestService; + this.alliedSocietyRotationService = alliedSocietyRotationService; + this.alliedSocietyPriorityWindow = alliedSocietyPriorityWindow; + this.dataCenterService = dataCenterService; + this.msqProgressionService = msqProgressionService; + this.log = log; + this.uiBuilder = uiBuilder; + this.dataManager = dataManager; + base.SizeConstraints = new WindowSizeConstraints + { + MinimumSize = new Vector2(900f, 600f), + MaximumSize = new Vector2(float.MaxValue, float.MaxValue) + }; + base.BgAlpha = 0f; + try + { + dataCenterService.InitializeWorldMapping(); + } + catch (Exception ex) + { + log.Error("[NewMainWindow] Failed to initialize data center mapping: " + ex.Message); + } + initialLoadStartTime = DateTime.Now; + log.Information("[NewMainWindow] Delayed character loading started (will retry with exponential backoff)"); + Random random = new Random(); + for (int i = 0; i < 80; i++) + { + Vector4 particleColor = random.Next(3) switch + { + 0 => colorPrimary, + 1 => colorSecondary, + _ => colorAccent, + }; + particles.Add(new Particle + { + Position = new Vector2(random.Next(0, 900), random.Next(0, 600)), + Velocity = new Vector2((float)(random.NextDouble() - 0.5) * 25f, (float)(random.NextDouble() - 0.5) * 25f), + Size = (float)random.NextDouble() * 3f + 1f, + Alpha = (float)random.NextDouble() * 0.5f + 0.2f, + Color = particleColor + }); + } + } + + public void Dispose() + { + } + + public override void Draw() + { + float deltaTime = ImGui.GetIO().DeltaTime; + animTime += deltaTime; + particleTime += deltaTime * 0.5f; + glowPulse = (MathF.Sin(animTime * 2f) + 1f) * 0.5f; + ImDrawListPtr drawList = ImGui.GetWindowDrawList(); + Vector2 windowSize = ImGui.GetWindowSize(); + Vector2 windowPos = ImGui.GetWindowPos(); + float sidebarWidth = 200f; + float titleBarHeight = 30f; + if (isMinimized) + { + float minHeight = titleBarHeight + 5f; + base.SizeConstraints = new WindowSizeConstraints + { + MinimumSize = new Vector2(400f, minHeight), + MaximumSize = new Vector2(float.MaxValue, minHeight) + }; + DrawCustomTitleBar(drawList, windowPos, windowSize, titleBarHeight); + ImGui.SetWindowSize(new Vector2(windowSize.X, minHeight)); + return; + } + base.SizeConstraints = new WindowSizeConstraints + { + MinimumSize = new Vector2(900f, 600f), + MaximumSize = new Vector2(float.MaxValue, float.MaxValue) + }; + TryInitialCharacterLoad(); + DrawGradientBackground(); + DrawAnimatedParticles(drawList, windowPos, windowSize, deltaTime); + DrawScanningLine(drawList, windowPos, windowSize); + DrawCustomTitleBar(drawList, windowPos, windowSize, titleBarHeight); + DrawSemiTransparentBackgrounds(windowPos + new Vector2(0f, titleBarHeight), windowSize - new Vector2(0f, titleBarHeight), sidebarWidth); + ImGui.SetCursorPos(new Vector2(0f, titleBarHeight)); + DrawSidebar(sidebarWidth, windowSize.Y - titleBarHeight); + ImGui.SameLine(); + DrawContentArea(windowSize.X - sidebarWidth - 20f, windowSize.Y - titleBarHeight); + DrawWorldSelectionDialogs(); + } + + private void RefreshCharacterList(bool forceIpcCheck = false) + { + try + { + log.Debug($"[NewMainWindow] RefreshCharacterList called (forceIpcCheck: {forceIpcCheck})"); + if (forceIpcCheck) + { + log.Debug("[NewMainWindow] Forcing IPC availability check..."); + autoRetainerIpc.TryReinitialize(); + } + if (!autoRetainerIpc.IsAvailable) + { + log.Warning("[NewMainWindow] AutoRetainer IPC not available during character refresh"); + return; + } + registeredCharacters = autoRetainerIpc.GetRegisteredCharacters(); + lastCharacterRefresh = DateTime.Now; + log.Information($"[NewMainWindow] Loaded {registeredCharacters.Count} characters from AutoRetainer"); + if (registeredCharacters.Count > 0) + { + initialCharacterLoadComplete = true; + log.Information("[NewMainWindow] ✅ Initial character load successful"); + } + charactersByDataCenter = dataCenterService.GroupCharactersByDataCenter(registeredCharacters); + availableWorlds = (from w in (from c in registeredCharacters + select c.Split('@') into parts + where parts.Length > 1 + select parts[1]).Distinct() + orderby w + select w).ToList(); + foreach (string character in registeredCharacters) + { + if (!characterSelection.ContainsKey(character)) + { + characterSelection[character] = false; + } + } + } + catch (Exception ex) + { + log.Error("[NewMainWindow] RefreshCharacterList failed: " + ex.Message); + log.Error("[NewMainWindow] Stack trace: " + ex.StackTrace); + } + } + + private void TryInitialCharacterLoad() + { + if (initialCharacterLoadComplete || characterLoadAttempts >= 5) + { + return; + } + double elapsedSeconds = (DateTime.Now - initialLoadStartTime).TotalSeconds; + int requiredDelay = ((characterLoadAttempts < retryDelaysSeconds.Length) ? retryDelaysSeconds[characterLoadAttempts] : retryDelaysSeconds[^1]); + if (!(elapsedSeconds < (double)requiredDelay)) + { + characterLoadAttempts++; + log.Information($"[NewMainWindow] Character load attempt {characterLoadAttempts}/{5} (after {elapsedSeconds:F1}s)"); + RefreshCharacterList(forceIpcCheck: true); + if (!initialCharacterLoadComplete) + { + initialLoadStartTime = DateTime.Now; + log.Debug($"[NewMainWindow] Next retry in {((characterLoadAttempts < retryDelaysSeconds.Length) ? retryDelaysSeconds[characterLoadAttempts] : retryDelaysSeconds[^1])}s"); + } + } + } + + private void TryWarningMenuQuestionableCheck() + { + if (warningMenuRetryCycleComplete) + { + return; + } + if (plugin.QuestionableIPC.TryEnsureAvailableSilent() && plugin.QuestionableIPC.ValidateFeatureCompatibility()) + { + warningMenuRetryCycleComplete = true; + selectedTab = 5; + log.Information("[NewMainWindow] Warning Menu retry cycle complete - Questionable IPC is now available"); + } + else if (warningMenuRetryAttempts < 4) + { + if (warningMenuRetryAttempts == 0) + { + warningMenuRetryStartTime = DateTime.Now; + } + double elapsedSeconds = (DateTime.Now - warningMenuRetryStartTime).TotalSeconds; + int requiredDelay = ((warningMenuRetryAttempts < warningMenuRetryDelaysSeconds.Length) ? warningMenuRetryDelaysSeconds[warningMenuRetryAttempts] : warningMenuRetryDelaysSeconds[^1]); + if (!(elapsedSeconds < (double)requiredDelay)) + { + warningMenuRetryAttempts++; + log.Debug($"[NewMainWindow] Warning Menu retry attempt {warningMenuRetryAttempts}/{4} (after {elapsedSeconds:F1}s)"); + warningMenuRetryStartTime = DateTime.Now; + } + } + } + + private void DrawSidebar(float width, float height) + { + using ImRaii.IEndObject child = ImRaii.Child("Sidebar", new Vector2(width, height - 10f), border: false); + if (!child.Success) + { + return; + } + DrawSidebarCategory("CHARACTERS", ref charactersExpanded, delegate + { + DrawSidebarItem("All", 0, registeredCharacters.Count); + DrawSidebarItem("EU", 1, GetCharacterCountForDC("EU")); + DrawSidebarItem("NA", 2, GetCharacterCountForDC("NA")); + DrawSidebarItem("JP", 3, GetCharacterCountForDC("JP")); + DrawSidebarItem("OCE", 4, GetCharacterCountForDC("OCE")); + }); + ImGuiHelpers.ScaledDummy(5f); + DrawSidebarCategory("MENU", ref menuExpanded, delegate + { + if (plugin.QuestionableIPC.ValidateFeatureCompatibility()) + { + DrawSidebarItem("Stop Points", 5, 0); + DrawSidebarItem("Allied Society", 10, 0); + DrawSidebarItem("Event Quest", 6, 0); + DrawSidebarItem("MSQ Progression", 7, 0); + DrawSidebarItem("Data Center Travel", 8, 0); + DrawSidebarItem("Settings", 9, 0); + } + else + { + DrawSidebarItem("Warning", 11, 0); + } + }); + } + + private void DrawSidebarCategory(string name, ref bool expanded, System.Action drawItems) + { + ImDrawListPtr drawList = ImGui.GetWindowDrawList(); + Vector2 cursorScreenPos = ImGui.GetCursorScreenPos(); + float itemWidth = ImGui.GetContentRegionAvail().X - 10f; + float itemHeight = 30f; + Vector4 headerBg = new Vector4(colorPrimary.X * 0.25f, colorPrimary.Y * 0.25f, colorPrimary.Z * 0.25f, 0.7f); + uint borderColor = ImGui.ColorConvertFloat4ToU32(new Vector4(colorPrimary.X, colorPrimary.Y, colorPrimary.Z, 0.6f)); + Vector2 boxStart = cursorScreenPos + new Vector2(5f, 0f); + drawList.AddRectFilled(boxStart, boxStart + new Vector2(itemWidth, itemHeight), ImGui.ColorConvertFloat4ToU32(headerBg), 6f); + drawList.AddRect(boxStart, boxStart + new Vector2(itemWidth, itemHeight), borderColor, 6f, ImDrawFlags.None, 1.5f); + string obj = (expanded ? "v" : ">"); + ImGui.SetCursorScreenPos(boxStart + new Vector2(10f, 8f)); + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImGui.Text(obj); + ImGui.PopStyleColor(); + ImGui.SameLine(); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + 1f); + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImGui.Text(name); + ImGui.PopStyleColor(); + if (ImGui.IsMouseHoveringRect(boxStart, boxStart + new Vector2(itemWidth, itemHeight)) && ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + { + expanded = !expanded; + } + ImGui.Dummy(new Vector2(itemWidth + 10f, itemHeight)); + ImGui.Spacing(); + if (expanded) + { + ImGui.Indent(10f); + drawItems(); + ImGui.Unindent(10f); + ImGui.Spacing(); + } + } + + private void DrawSidebarItem(string label, int tabIndex, int count) + { + bool isSelected = selectedTab == tabIndex; + string obj = ((count > 0) ? $"{label} ({count})" : label); + if (isSelected) + { + ImGui.PushStyleColor(ImGuiCol.Header, colorPrimary); + } + else + { + ImGui.PushStyleColor(ImGuiCol.HeaderHovered, colorHover); + } + if (ImGui.Selectable(obj, isSelected, ImGuiSelectableFlags.None, new Vector2(0f, 22f))) + { + selectedTab = tabIndex; + if (tabIndex <= 4) + { + selectedDCFilter = tabIndex; + } + } + ImGui.PopStyleColor(); + } + + private int GetCharacterCountForDC(string dcName) + { + if (charactersByDataCenter.TryGetValue(dcName, out List chars)) + { + return chars.Count; + } + return 0; + } + + private void DrawCustomTitleBar(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, float height) + { + uint leftColor = ImGui.ColorConvertFloat4ToU32(new Vector4(colorPrimary.X * 0.4f, colorPrimary.Y * 0.4f, colorPrimary.Z * 0.4f, 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); + Vector2 titlePos = windowPos + new Vector2(10f, 7f); + drawList.AddText(titlePos, ImGui.ColorConvertFloat4ToU32(new Vector4(1f, 1f, 1f, 0.9f)), "Questionable Companion V1.0.1"); + Vector2 minimizeButtonPos = windowPos + new Vector2(windowSize.X - 60f, 3f); + Vector2 minimizeButtonSize = new Vector2(24f, 24f); + if (ImGui.IsMouseHoveringRect(minimizeButtonPos, minimizeButtonPos + minimizeButtonSize)) + { + drawList.AddRectFilled(minimizeButtonPos, minimizeButtonPos + minimizeButtonSize, ImGui.ColorConvertFloat4ToU32(new Vector4(0.4f, 0.4f, 0.4f, 0.8f)), 4f); + if (ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + { + isMinimized = !isMinimized; + } + } + drawList.AddText(minimizeButtonPos + new Vector2(8f, 2f), ImGui.ColorConvertFloat4ToU32(new Vector4(1f, 1f, 1f, 1f)), "_"); + Vector2 closeButtonPos = windowPos + new Vector2(windowSize.X - 30f, 3f); + Vector2 closeButtonSize = new Vector2(24f, 24f); + if (ImGui.IsMouseHoveringRect(closeButtonPos, closeButtonPos + closeButtonSize)) + { + drawList.AddRectFilled(closeButtonPos, closeButtonPos + closeButtonSize, ImGui.ColorConvertFloat4ToU32(new Vector4(0.8f, 0.2f, 0.2f, 0.8f)), 4f); + if (ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + { + base.IsOpen = false; + } + } + drawList.AddText(closeButtonPos + new Vector2(7f, 2f), ImGui.ColorConvertFloat4ToU32(new Vector4(1f, 1f, 1f, 1f)), "X"); + } + + private void DrawSemiTransparentBackgrounds(Vector2 windowPos, Vector2 windowSize, float sidebarWidth) + { + ImDrawListPtr drawList = ImGui.GetWindowDrawList(); + drawList.AddRectFilled(col: ImGui.ColorConvertFloat4ToU32(new Vector4(0.12f, 0.12f, 0.15f, 0.85f)), pMin: windowPos, pMax: windowPos + new Vector2(sidebarWidth, windowSize.Y)); + float gradientWidth = 20f; + for (int i = 0; i < 20; i++) + { + float alpha = (float)i / 20f; + drawList.AddRectFilled(col: ImGui.ColorConvertFloat4ToU32(new Vector4(0.12f + 0.030000009f * alpha, 0.12f + 0.030000009f * alpha, 0.15f + 0.030000001f * alpha, 0.85f - 0.05f * alpha)), pMin: windowPos + new Vector2(sidebarWidth + (float)i, 0f), pMax: windowPos + new Vector2(sidebarWidth + (float)i + 1f, windowSize.Y)); + } + drawList.AddRectFilled(col: ImGui.ColorConvertFloat4ToU32(new Vector4(0.15f, 0.15f, 0.18f, 0.8f)), pMin: windowPos + new Vector2(sidebarWidth + gradientWidth, 0f), pMax: windowPos + windowSize); + } + + private void DrawGradientBackground() + { + ImDrawListPtr drawList = ImGui.GetWindowDrawList(); + Vector2 windowPos = ImGui.GetWindowPos(); + Vector2 windowSize = ImGui.GetWindowSize(); + float extend = 2f; + float colorShift = MathF.Sin(animTime * 0.3f) * 0.15f; + float colorShift2 = MathF.Cos(animTime * 0.25f) * 0.12f; + uint blue = ImGui.ColorConvertFloat4ToU32(new Vector4(0.47843137f + colorShift, 35f / 51f + colorShift2, 0.8784314f - colorShift * 0.5f, 1f)); + uint gold = ImGui.ColorConvertFloat4ToU32(new Vector4(0.9490196f - colorShift2 * 0.5f, 0.76862746f + colorShift, 33f / 85f + colorShift2, 1f)); + uint brown = ImGui.ColorConvertFloat4ToU32(new Vector4(62f / 85f + colorShift2, 0.29411766f - colorShift, 0.18431373f + colorShift * 0.8f, 1f)); + drawList.AddRectFilledMultiColor(windowPos - new Vector2(extend, extend), windowPos + windowSize + new Vector2(extend, extend), blue, gold, brown, blue); + } + + private void DrawContentArea(float width, float height) + { + using ImRaii.IEndObject child = ImRaii.Child("ContentArea", new Vector2(width, height - 10f), border: false); + if (child.Success) + { + switch (selectedTab) + { + case 0: + case 1: + case 2: + case 3: + case 4: + DrawCharactersTab(); + break; + case 5: + DrawStopPointsTab(); + break; + case 6: + DrawEventQuestTab(); + break; + case 7: + DrawMSQProgressionTab(); + break; + case 8: + DrawDCTravelTab(); + break; + case 9: + DrawSettingsTab(); + break; + case 10: + DrawAlliedSocietyTab(); + break; + case 11: + DrawWarningTab(); + break; + } + } + } + + private void DrawAnimatedParticles(ImDrawListPtr drawList, Vector2 pos, Vector2 size, float deltaTime) + { + foreach (Particle p in particles) + { + if (p.Position.X < pos.X) + { + p.Position.X = pos.X + size.X; + } + if (p.Position.X > pos.X + size.X) + { + p.Position.X = pos.X; + } + if (p.Position.Y < pos.Y) + { + p.Position.Y = pos.Y + size.Y; + } + if (p.Position.Y > pos.Y + size.Y) + { + p.Position.Y = pos.Y; + } + float colorPulse = 0.8f + glowPulse * 0.2f; + float alpha = p.Alpha * (0.6f + glowPulse * 0.4f); + uint color = ImGui.ColorConvertFloat4ToU32(new Vector4(p.Color.X * colorPulse, p.Color.Y * colorPulse, p.Color.Z * colorPulse, alpha)); + float glowAlpha = alpha * 0.3f; + uint glowColor = ImGui.ColorConvertFloat4ToU32(new Vector4(p.Color.X * colorPulse, p.Color.Y * colorPulse, p.Color.Z * colorPulse, glowAlpha)); + drawList.AddCircleFilled(p.Position, p.Size * 2f, glowColor, 12); + drawList.AddCircleFilled(p.Position, p.Size, color, 8); + } + } + + private void DrawScanningLine(ImDrawListPtr drawList, Vector2 pos, Vector2 size) + { + float scan1Y = pos.Y + animTime * 0.3f % 1f * size.Y; + float scan2Y = pos.Y + (animTime * 0.25f + 0.33f) % 1f * size.Y; + float scan3Y = pos.Y + (animTime * 0.2f + 0.66f) % 1f * size.Y; + uint blueColor = ImGui.ColorConvertFloat4ToU32(new Vector4(colorPrimary.X, colorPrimary.Y, colorPrimary.Z, 0.15f * glowPulse)); + uint goldColor = ImGui.ColorConvertFloat4ToU32(new Vector4(colorSecondary.X, colorSecondary.Y, colorSecondary.Z, 0.15f * glowPulse)); + uint redColor = ImGui.ColorConvertFloat4ToU32(new Vector4(colorAccent.X, colorAccent.Y, colorAccent.Z, 0.15f * glowPulse)); + drawList.AddLine(new Vector2(pos.X, scan1Y), new Vector2(pos.X + size.X, scan1Y), blueColor, 2f); + drawList.AddLine(new Vector2(pos.X, scan2Y), new Vector2(pos.X + size.X, scan2Y), goldColor, 2f); + drawList.AddLine(new Vector2(pos.X, scan3Y), new Vector2(pos.X + size.X, scan3Y), redColor, 2f); + } + + private void DrawDCTravelTab() + { + ImGui.PushStyleColor(ImGuiCol.Text, colorPrimary); + ImGui.TextUnformatted("Data Center Travel Configuration"); + ImGui.PopStyleColor(); + ImGuiHelpers.ScaledDummy(10f); + Configuration config = plugin.Configuration; + if (string.IsNullOrEmpty(selectedDataCenter) && !string.IsNullOrEmpty(config.DCTravelDataCenter)) + { + selectedDataCenter = config.DCTravelDataCenter; + } + if (string.IsNullOrEmpty(selectedWorld) && !string.IsNullOrEmpty(config.DCTravelTargetWorld)) + { + selectedWorld = config.DCTravelTargetWorld; + } + if (string.IsNullOrEmpty(selectedDataCenter)) + { + selectedDataCenter = dataCenterWorlds.Keys.First(); + } + if (string.IsNullOrEmpty(selectedWorld)) + { + List worlds = dataCenterWorlds.GetValueOrDefault(selectedDataCenter) ?? new List(); + if (worlds.Count > 0) + { + selectedWorld = worlds[0]; + } + } + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImGui.TextWrapped("Configure automatic Data Center travel for quest rotation. The plugin will travel to the specified Data Center and World before starting quests."); + ImGui.PopStyleColor(); + ImGuiHelpers.ScaledDummy(15f); + ImGui.PushStyleColor(ImGuiCol.Text, colorPrimary); + ImGui.TextUnformatted("Select Data Center:"); + ImGui.PopStyleColor(); + ImGuiHelpers.ScaledDummy(5f); + ImGui.SetNextItemWidth(350f); + ImGui.PushStyleColor(ImGuiCol.FrameBg, new Vector4(0.15f, 0.15f, 0.18f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.FrameBgHovered, new Vector4(colorPrimary.X * 0.3f, colorPrimary.Y * 0.3f, colorPrimary.Z * 0.3f, 0.9f)); + if (ImGui.BeginCombo("##DataCenterCombo", selectedDataCenter)) + { + foreach (string dc in dataCenterWorlds.Keys.OrderBy((string k) => k)) + { + bool isSelected = selectedDataCenter == dc; + if (ImGui.Selectable(dc, isSelected)) + { + selectedDataCenter = dc; + List worlds2 = dataCenterWorlds.GetValueOrDefault(selectedDataCenter) ?? new List(); + if (worlds2.Count > 0) + { + selectedWorld = worlds2[0]; + } + } + if (isSelected) + { + ImGui.SetItemDefaultFocus(); + } + } + ImGui.EndCombo(); + } + ImGuiHelpers.ScaledDummy(15f); + ImGui.PushStyleColor(ImGuiCol.Text, colorPrimary); + ImGui.TextUnformatted("Select World:"); + ImGui.PopStyleColor(); + ImGuiHelpers.ScaledDummy(5f); + List availableWorlds = dataCenterWorlds.GetValueOrDefault(selectedDataCenter) ?? new List(); + ImGui.SetNextItemWidth(350f); + ImGui.PushStyleColor(ImGuiCol.FrameBg, new Vector4(0.15f, 0.15f, 0.18f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.FrameBgHovered, new Vector4(colorPrimary.X * 0.3f, colorPrimary.Y * 0.3f, colorPrimary.Z * 0.3f, 0.9f)); + if (ImGui.BeginCombo("##WorldCombo", selectedWorld)) + { + foreach (string world in availableWorlds) + { + bool isSelected2 = selectedWorld == world; + if (ImGui.Selectable(world, isSelected2)) + { + selectedWorld = world; + } + if (isSelected2) + { + ImGui.SetItemDefaultFocus(); + } + } + ImGui.EndCombo(); + } + ImGui.PopStyleColor(2); + ImGuiHelpers.ScaledDummy(15f); + LifestreamIPC lifestreamIPC = Plugin.Instance?.LifestreamIPC; + if (lifestreamIPC != null && !lifestreamIPC.IsAvailable) + { + lifestreamIPC.ForceCheckAvailability(); + } + int num; + if (lifestreamIPC == null) + { + num = 0; + } + else + { + num = (lifestreamIPC.IsAvailable ? 1 : 0); + if (num != 0) + { + goto IL_0421; + } + } + ImGui.BeginDisabled(); + goto IL_0421; + IL_0421: + bool enableDCTravel = config.EnableDCTravel; + if (ImGui.Checkbox("Enable Data Center Travel", ref enableDCTravel)) + { + config.EnableDCTravel = enableDCTravel; + config.Save(); + } + if (num == 0) + { + ImGui.EndDisabled(); + } + ImGui.SameLine(); + DrawInfoIcon("Automatically travels to the specified Data Center and World before starting quest rotation.\nRequires Lifestream plugin to be installed and configured.\nImpact: Characters will travel to target DC/World at rotation start."); + if (num == 0) + { + ImGuiHelpers.ScaledDummy(5f); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1f, 0.5f, 0f, 1f)); + ImGui.TextWrapped("Lifestream plugin is not available! DC Travel requires Lifestream to be installed and enabled."); + ImGui.PopStyleColor(); + ImGuiHelpers.ScaledDummy(5f); + if (ImGui.Button("Check Lifestream Again") && lifestreamIPC != null) + { + bool result = lifestreamIPC.ForceCheckAvailability(); + log.Information($"[DCTravel UI] Manual Lifestream check result: {result}"); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Manually check if Lifestream is available.\nCheck the logs for detailed information."); + } + } + ImGuiHelpers.ScaledDummy(20f); + ImGui.Separator(); + ImGuiHelpers.ScaledDummy(15f); + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImGui.TextUnformatted("Current Configuration:"); + ImGui.PopStyleColor(); + ImGuiHelpers.ScaledDummy(5f); + ImGui.Indent(10f); + ImU8String text = new ImU8String(13, 0); + text.AppendLiteral("Data Center: "); + ImGui.TextUnformatted(text); + ImGui.SameLine(); + ImGui.PushStyleColor(ImGuiCol.Text, colorPrimary); + ImGui.TextUnformatted((config.DCTravelDataCenter.Length > 0) ? config.DCTravelDataCenter : "Not Set"); + ImGui.PopStyleColor(); + ImU8String text2 = new ImU8String(14, 0); + text2.AppendLiteral("Target World: "); + ImGui.TextUnformatted(text2); + ImGui.SameLine(); + ImGui.PushStyleColor(ImGuiCol.Text, colorPrimary); + ImGui.TextUnformatted((config.DCTravelTargetWorld.Length > 0) ? config.DCTravelTargetWorld : "Not Set"); + ImGui.PopStyleColor(); + ImU8String text3 = new ImU8String(8, 0); + text3.AppendLiteral("Status: "); + ImGui.TextUnformatted(text3); + ImGui.SameLine(); + if (config.EnableDCTravel) + { + ImGui.PushStyleColor(ImGuiCol.Text, colorPrimary); + ImGui.TextUnformatted("Enabled"); + ImGui.PopStyleColor(); + } + else + { + ImGui.PushStyleColor(ImGuiCol.Text, colorAccent); + ImGui.TextUnformatted("Disabled"); + ImGui.PopStyleColor(); + } + ImGui.Unindent(10f); + ImGuiHelpers.ScaledDummy(20f); + ImGui.PushStyleColor(ImGuiCol.Button, colorPrimary); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, colorSecondary); + if (ImGui.Button("Apply", new Vector2(120f, 30f))) + { + config.DCTravelDataCenter = selectedDataCenter; + config.DCTravelTargetWorld = selectedWorld; + config.Save(); + log.Information("[DCTravel] Configuration saved: " + selectedDataCenter + " -> " + selectedWorld); + } + ImGui.PopStyleColor(2); + ImGui.SameLine(); + if (ImGui.Button("Cancel", new Vector2(120f, 30f))) + { + selectedDataCenter = config.DCTravelDataCenter; + selectedWorld = config.DCTravelTargetWorld; + if (string.IsNullOrEmpty(selectedDataCenter)) + { + selectedDataCenter = dataCenterWorlds.Keys.First(); + } + if (string.IsNullOrEmpty(selectedWorld)) + { + List worlds3 = dataCenterWorlds.GetValueOrDefault(selectedDataCenter) ?? new List(); + if (worlds3.Count > 0) + { + selectedWorld = worlds3[0]; + } + } + } + ImGuiHelpers.ScaledDummy(10f); + if (!config.EnableDCTravel) + { + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImGui.TextWrapped("Note: Data Center Travel is currently disabled. Enable it above to use this feature."); + ImGui.PopStyleColor(); + } + } + + 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)", 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 + { + bool enableAutoDutyUnsynced = config.EnableAutoDutyUnsynced; + config.EnableAutoDutyUnsynced = DrawSettingWithInfo("Enable Auto Duty (Unsynced)", config.EnableAutoDutyUnsynced, "Automatically handles dungeon entries and party formation.\nUses AutoDuty plugin for unsynced dungeon runs.\nImpact: Dungeons will be automated during quest rotation."); + if (config.EnableAutoDutyUnsynced != enableAutoDutyUnsynced) + { + config.Save(); + plugin.GetDungeonAutomation()?.SetDutyModeBasedOnConfig(); + } + if (config.EnableAutoDutyUnsynced) + { + ImGui.Indent(); + int v = config.AutoDutyPartySize; + if (ImGui.SliderInt("Minimum Party Size", ref v, 1, 4)) + { + config.AutoDutyPartySize = v; + config.Save(); + } + DrawInfoIcon("Minimum number of party members required before entering dungeon."); + int v2 = config.AutoDutyMaxWaitForParty; + if (ImGui.SliderInt("Max Wait for Party (seconds)", ref v2, 10, 120)) + { + config.AutoDutyMaxWaitForParty = v2; + config.Save(); + } + DrawInfoIcon("Maximum time to wait for party members before timing out."); + int v3 = config.AutoDutyReInviteInterval; + if (ImGui.SliderInt("Re-Invite Interval (seconds)", ref v3, 5, 60)) + { + config.AutoDutyReInviteInterval = v3; + config.Save(); + } + DrawInfoIcon("How often to re-send party invites if members don't join."); + ImGui.Unindent(); + } + }, config.EnableAutoDutyUnsynced); + ImGuiHelpers.ScaledDummy(10f); + DrawSettingSection("Multi-Client Role", delegate + { + ImGui.TextWrapped("Select your role for multi-client features (party management, chauffeur mode):"); + ImGuiHelpers.ScaledDummy(5f); + int num = 0; + if (config.IsQuester) + { + num = 1; + } + else if (config.IsHighLevelHelper) + { + num = 2; + } + if (ImGui.RadioButton("None", num == 0)) + { + config.IsQuester = false; + config.IsHighLevelHelper = false; + config.Save(); + } + ImGui.SameLine(); + DrawInfoIcon("No multi-client features enabled"); + if (ImGui.RadioButton("Quester", num == 1)) + { + config.IsQuester = true; + config.IsHighLevelHelper = false; + config.Save(); + } + ImGui.SameLine(); + DrawInfoIcon("This client will quest and invite helpers for dungeons"); + if (ImGui.RadioButton("High-Level Helper", num == 2)) + { + config.IsQuester = false; + config.IsHighLevelHelper = true; + Plugin.Framework.RunOnFrameworkThread(delegate + { + Plugin.Instance?.GetHelperManager()?.AnnounceIfHelper(); + }); + config.Save(); + } + ImGui.SameLine(); + DrawInfoIcon("This client will help with dungeons.\nAutoDuty starts/stops automatically on duty enter/leave"); + ImGuiHelpers.ScaledDummy(10f); + if (config.IsQuester) + { + ImGui.Separator(); + ImGuiHelpers.ScaledDummy(5f); + ImGui.TextColored(in colorPrimary, "Auto-Discovered Helpers"); + ImGui.TextWrapped("Helpers are automatically discovered via IPC when they have 'I'm a High-Level Helper' enabled:"); + ImGuiHelpers.ScaledDummy(5f); + List<(string, ushort)> availableHelpers = plugin.GetAvailableHelpers(); + if (availableHelpers.Count != 0) + { + Vector4 col = new Vector4(0.2f, 1f, 0.2f, 1f); + ImU8String text = new ImU8String(21, 1); + text.AppendFormatted(availableHelpers.Count); + text.AppendLiteral(" helper(s) available:"); + ImGui.TextColored(in col, text); + ImGuiHelpers.ScaledDummy(5f); + ImGui.TextUnformatted("Preferred Helper for Chauffeur:"); + ImGui.SetNextItemWidth(250f); + List list = new List { "Auto (First Available)" }; + foreach (var item5 in availableHelpers) + { + string item = item5.Item1; + ushort item2 = item5.Item2; + ExcelSheet excelSheet = Plugin.DataManager.GetExcelSheet(); + string text2 = "Unknown"; + if (excelSheet != null) + { + foreach (World current2 in excelSheet) + { + if (current2.RowId == item2) + { + text2 = current2.Name.ExtractText(); + break; + } + } + } + list.Add(item + "@" + text2); + } + string text3 = (string.IsNullOrEmpty(config.PreferredHelper) ? "Auto (First Available)" : config.PreferredHelper); + if (ImGui.BeginCombo("##PreferredHelper", text3)) + { + foreach (string current3 in list) + { + bool flag = text3 == current3; + if (ImGui.Selectable(current3, flag)) + { + config.PreferredHelper = ((current3 == "Auto (First Available)") ? "" : current3); + config.Save(); + } + if (flag) + { + ImGui.SetItemDefaultFocus(); + } + } + 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)) + { + ImGuiHelpers.ScaledDummy(3f); + string text4 = (Plugin.Instance?.GetChauffeurMode())?.GetHelperStatus(config.PreferredHelper); + Vector4 col2; + ImU8String text5; + switch (text4) + { + case "Available": + col = new Vector4(0.2f, 1f, 0.2f, 1f); + goto IL_04f7; + case "Transporting": + col = new Vector4(1f, 0.8f, 0f, 1f); + goto IL_04f7; + case "InDungeon": + col = new Vector4(1f, 0.3f, 0.3f, 1f); + goto IL_04f7; + default: + col = colorSecondary; + goto IL_04f7; + case null: + { + ImGui.TextColored(in colorSecondary, "Helper Status: Unknown (waiting for update...)"); + break; + } + IL_04f7: + col2 = col; + text5 = new ImU8String(15, 1); + text5.AppendLiteral("Helper Status: "); + text5.AppendFormatted(text4); + ImGui.TextColored(in col2, text5); + break; + } + } + ImGuiHelpers.ScaledDummy(5f); + ImGui.TextUnformatted("Available Helpers:"); + ChauffeurModeService chauffeurModeService = Plugin.Instance?.GetChauffeurMode(); + { + foreach (var item6 in availableHelpers) + { + string item3 = item6.Item1; + ushort item4 = item6.Item2; + ExcelSheet excelSheet2 = Plugin.DataManager.GetExcelSheet(); + string text6 = "Unknown"; + if (excelSheet2 != null) + { + foreach (World current5 in excelSheet2) + { + if (current5.RowId == item4) + { + text6 = current5.Name.ExtractText(); + break; + } + } + } + string text7 = item3 + "@" + text6; + string text8 = chauffeurModeService?.GetHelperStatus(text7); + ImU8String text9 = new ImU8String(4, 1); + text9.AppendLiteral(" • "); + text9.AppendFormatted(text7); + ImGui.TextUnformatted(text9); + if (text8 != null) + { + ImGui.SameLine(); + Vector4 col3 = text8 switch + { + "Available" => new Vector4(0.2f, 1f, 0.2f, 1f), + "Transporting" => new Vector4(1f, 0.8f, 0f, 1f), + "InDungeon" => new Vector4(1f, 0.3f, 0.3f, 1f), + _ => colorSecondary, + }; + ImU8String text10 = new ImU8String(2, 1); + text10.AppendLiteral("["); + text10.AppendFormatted(text8); + text10.AppendLiteral("]"); + ImGui.TextColored(in col3, text10); + } + } + 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); + ImGuiHelpers.ScaledDummy(10f); + DrawSettingSection("Chauffeur Mode", delegate + { + ImGui.TextWrapped("Multi-character transport system. Helper transports Quester to quest objectives using multi-seater mounts."); + ImGuiHelpers.ScaledDummy(5f); + if (!config.IsQuester && !config.IsHighLevelHelper) + { + ImGui.TextColored(new Vector4(1f, 0.8f, 0.2f, 1f), "Please select a role above to configure Chauffeur Mode"); + } + else + { + bool v = config.ChauffeurModeEnabled; + if (ImGui.Checkbox("Enable Chauffeur Mode", ref v)) + { + config.ChauffeurModeEnabled = v; + config.Save(); + } + DrawInfoIcon("Enable automatic helper summoning for long-distance travel in non-flying zones"); + if (config.ChauffeurModeEnabled) + { + ImGuiHelpers.ScaledDummy(5f); + ImGui.Separator(); + ImGuiHelpers.ScaledDummy(5f); + if (config.IsQuester) + { + ImGui.TextColored(in colorPrimary, "Quester Settings"); + ImGuiHelpers.ScaledDummy(3f); + float v2 = config.ChauffeurDistanceThreshold; + ImGui.SetNextItemWidth(200f); + if (ImGui.SliderFloat("Distance Threshold (yalms)", ref v2, 50f, 300f, "%.0f")) + { + config.ChauffeurDistanceThreshold = v2; + config.Save(); + } + DrawInfoIcon("Helper will be summoned when task is further than this distance\nand flying is not available in the zone"); + ImU8String text = new ImU8String(15, 1); + text.AppendLiteral("Current: "); + text.AppendFormatted(config.ChauffeurDistanceThreshold, "F0"); + text.AppendLiteral(" yalms"); + ImGui.TextWrapped(text); + } + if (config.IsHighLevelHelper) + { + ImGui.TextColored(in colorPrimary, "Helper Settings"); + ImGuiHelpers.ScaledDummy(3f); + Vector4 col = config.CurrentHelperStatus switch + { + HelperStatus.Available => new Vector4(0.2f, 1f, 0.2f, 1f), + HelperStatus.Transporting => new Vector4(1f, 0.8f, 0f, 1f), + HelperStatus.InDungeon => new Vector4(1f, 0.3f, 0.3f, 1f), + _ => colorSecondary, + }; + string value = config.CurrentHelperStatus switch + { + HelperStatus.Available => "Available", + HelperStatus.Transporting => "Transporting", + HelperStatus.InDungeon => "In Dungeon", + _ => "Unknown", + }; + ImU8String text2 = new ImU8String(8, 1); + text2.AppendLiteral("Status: "); + text2.AppendFormatted(value); + ImGui.TextColored(in col, text2); + ImGuiHelpers.ScaledDummy(3f); + if (!string.IsNullOrEmpty(config.AssignedQuester)) + { + Vector4 col2 = new Vector4(0.2f, 1f, 0.2f, 1f); + ImU8String text3 = new ImU8String(18, 1); + text3.AppendLiteral("Assigned Quester: "); + text3.AppendFormatted(config.AssignedQuester); + ImGui.TextColored(in col2, text3); + ImGuiHelpers.ScaledDummy(3f); + } + else + { + ImGui.TextColored(in colorSecondary, "Assigned Quester: None"); + ImGuiHelpers.ScaledDummy(3f); + } + float v3 = config.ChauffeurStopDistance; + ImGui.SetNextItemWidth(200f); + if (ImGui.SliderFloat("Stop Distance (yalms)", ref v3, 2f, 15f, "%.1f")) + { + config.ChauffeurStopDistance = v3; + config.Save(); + } + DrawInfoIcon("How close you bring the quester to their destination\n(2-15 yalms, default: 5)"); + ImU8String text4 = new ImU8String(15, 1); + text4.AppendLiteral("Current: "); + text4.AppendFormatted(config.ChauffeurStopDistance, "F1"); + text4.AppendLiteral(" yalms"); + ImGui.TextWrapped(text4); + ImGuiHelpers.ScaledDummy(5f); + List<(uint, string, byte)> list = (Plugin.Instance?.GetChauffeurMode())?.GetMultiSeaterMounts() ?? new List<(uint, string, byte)>(); + if (list.Count == 0) + { + ImGui.TextColored(new Vector4(1f, 0.3f, 0.3f, 1f), "No multi-seater mounts found!"); + ImGui.TextWrapped("Make sure you have unlocked at least one multi-seater mount."); + } + else + { + ImGui.TextWrapped("Select Multi-Seater Mount:"); + ImGuiHelpers.ScaledDummy(3f); + int num = 0; + List list2 = new List(); + for (int i = 0; i < list.Count; i++) + { + var (num2, value2, value3) = list[i]; + list2.Add($"{value2} (Passengers: {value3})"); + if (num2 == config.ChauffeurMountId) + { + num = i; + } + } + list2.Insert(0, "-- Not Selected --"); + num = ((config.ChauffeurMountId != 0) ? (num + 1) : 0); + ImGui.SetNextItemWidth(300f); + if (ImGui.Combo((ImU8String)"##MountSelect", ref num, (ReadOnlySpan)list2.ToArray(), list2.Count)) + { + if (num == 0) + { + config.ChauffeurMountId = 0u; + } + else + { + (uint, string, byte) tuple2 = list[num - 1]; + config.ChauffeurMountId = tuple2.Item1; + } + config.Save(); + } + DrawInfoIcon("This mount will be used to transport the Quester"); + if (config.ChauffeurMountId == 0) + { + ImGuiHelpers.ScaledDummy(3f); + ImGui.TextColored(new Vector4(1f, 0.8f, 0.2f, 1f), "Please select a mount to enable Chauffeur Mode"); + } + } + } + } + } + }, config.ChauffeurModeEnabled); + ImGuiHelpers.ScaledDummy(10f); + DrawSettingSection("Helper Following", delegate + { + ImGui.TextWrapped("Helper passively follows Quester and maintains a configurable distance. Automatically navigates when too far away."); + ImGuiHelpers.ScaledDummy(5f); + if (config.IsQuester) + { + ImGui.TextColored(new Vector4(0.7f, 0.9f, 1f, 1f), "Quester Settings:"); + ImGui.TextWrapped("Select which Helper should follow you. Your position will be broadcasted to this Helper."); + ImGuiHelpers.ScaledDummy(3f); + string assignedHelperForFollowing = config.AssignedHelperForFollowing; + ImGui.Text("Assigned Helper:"); + ImGui.SameLine(); + if (string.IsNullOrEmpty(assignedHelperForFollowing)) + { + ImGui.TextColored(new Vector4(1f, 0.5f, 0f, 1f), "None"); + } + else + { + ImGui.TextColored(new Vector4(0.2f, 1f, 0.2f, 1f), assignedHelperForFollowing); + } + ImGuiHelpers.ScaledDummy(3f); + ImGui.Text("Auto-Discovered Helpers:"); + ImGui.SetNextItemWidth(300f); + List<(string, ushort)> availableHelpers = plugin.GetAvailableHelpers(); + if (ImGui.BeginCombo("##HelperDropdown", string.IsNullOrEmpty(assignedHelperForFollowing) ? "Select Helper..." : assignedHelperForFollowing)) + { + if (availableHelpers.Count == 0) + { + ImGui.TextColored(new Vector4(1f, 0.5f, 0f, 1f), "No helpers discovered yet"); + ImGui.TextWrapped("Helpers are auto-discovered via IPC when they have 'I'm a High-Level Helper' enabled."); + } + else + { + foreach (var item7 in availableHelpers) + { + string item = item7.Item1; + ushort item2 = item7.Item2; + ExcelSheet excelSheet = Plugin.DataManager.GetExcelSheet(); + string text = "Unknown"; + if (excelSheet != null) + { + foreach (World current2 in excelSheet) + { + if (current2.RowId == item2) + { + text = current2.Name.ToString(); + break; + } + } + } + string text2 = item + "@" + text; + bool selected = assignedHelperForFollowing == text2; + if (ImGui.Selectable(text2, selected)) + { + config.AssignedHelperForFollowing = text2; + config.Save(); + } + } + } + ImGui.EndCombo(); + } + DrawInfoIcon("Select the Helper from auto-discovered helpers.\nHelpers are discovered via IPC when they broadcast their status."); + ImGuiHelpers.ScaledDummy(5f); + bool v = config.EnableHelperFollowing; + if (string.IsNullOrEmpty(config.AssignedHelperForFollowing)) + { + ImGui.BeginDisabled(); + } + if (ImGui.Checkbox("Enable Position Broadcasting", ref v)) + { + config.EnableHelperFollowing = v; + config.Save(); + } + if (string.IsNullOrEmpty(config.AssignedHelperForFollowing)) + { + ImGui.EndDisabled(); + } + DrawInfoIcon("Enable to broadcast your position to the assigned Helper.\nThe Helper can then follow you automatically."); + ImGuiHelpers.ScaledDummy(3f); + if (config.EnableHelperFollowing && !string.IsNullOrEmpty(config.AssignedHelperForFollowing)) + { + ImGui.TextColored(new Vector4(0.2f, 1f, 0.2f, 1f), "✓ Broadcasting position to Helper"); + } + else if (!string.IsNullOrEmpty(config.AssignedHelperForFollowing)) + { + ImGui.TextColored(new Vector4(1f, 0.5f, 0f, 1f), "⚠ Enable broadcasting to start"); + } + else + { + ImGui.TextColored(new Vector4(1f, 0.5f, 0f, 1f), "⚠ Select a Helper first"); + } + } + else if (!config.IsHighLevelHelper) + { + ImGui.TextColored(new Vector4(1f, 0.8f, 0.2f, 1f), "Please select a role (Quester or Helper) above"); + } + else + { + ImGui.TextColored(new Vector4(0.7f, 0.9f, 1f, 1f), "Helper Settings:"); + ImGui.TextWrapped("Select which Quester to follow. You will only follow this specific Quester."); + ImGuiHelpers.ScaledDummy(3f); + string assignedQuesterForFollowing = config.AssignedQuesterForFollowing; + ImGui.Text("Assigned Quester:"); + ImGui.SameLine(); + if (string.IsNullOrEmpty(assignedQuesterForFollowing)) + { + ImGui.TextColored(new Vector4(1f, 0.3f, 0.3f, 1f), "⚠ None - Helper Following disabled!"); + } + else + { + ImGui.TextColored(new Vector4(0.2f, 1f, 0.2f, 1f), assignedQuesterForFollowing); + } + ImGuiHelpers.ScaledDummy(3f); + ImGui.Text("Auto-Discovered Questers:"); + ImGui.SetNextItemWidth(300f); + List list = (Plugin.Instance?.GetChauffeurMode())?.GetDiscoveredQuesters() ?? new List(); + if (ImGui.BeginCombo("##QuesterDropdown", string.IsNullOrEmpty(assignedQuesterForFollowing) ? "Select Quester..." : assignedQuesterForFollowing)) + { + if (list.Count == 0) + { + ImGui.TextColored(new Vector4(1f, 0.5f, 0f, 1f), "No questers discovered yet"); + ImGui.TextWrapped("Questers are auto-discovered when they broadcast position."); + ImGui.TextWrapped("Make sure the Quester has Helper Following enabled and has assigned you as Helper."); + } + else + { + foreach (string current3 in list) + { + bool selected2 = assignedQuesterForFollowing == current3; + if (ImGui.Selectable(current3, selected2)) + { + config.AssignedQuesterForFollowing = current3; + config.Save(); + } + } + } + ImGui.EndCombo(); + } + DrawInfoIcon("Questers are automatically discovered via IPC when they broadcast position.\nSelect the Quester you want to follow from the list."); + ImGuiHelpers.ScaledDummy(5f); + bool v2 = config.EnableHelperFollowing; + if (string.IsNullOrEmpty(config.AssignedQuesterForFollowing)) + { + ImGui.BeginDisabled(); + } + if (ImGui.Checkbox("Enable Helper Following", ref v2)) + { + config.EnableHelperFollowing = v2; + config.Save(); + } + if (string.IsNullOrEmpty(config.AssignedQuesterForFollowing)) + { + ImGui.EndDisabled(); + } + DrawInfoIcon("Helper will automatically follow the assigned Quester and maintain distance.\nStops in restricted zones (Main Cities) and when Chauffeur Mode is active."); + if (config.EnableHelperFollowing) + { + ImGuiHelpers.ScaledDummy(5f); + ImGui.Indent(); + float v3 = config.HelperFollowDistance; + if (ImGui.SliderFloat("Follow Distance (yalms)", ref v3, 50f, 200f, "%.0f")) + { + config.HelperFollowDistance = v3; + config.Save(); + } + DrawInfoIcon("Distance to maintain from Quester.\nHelper will navigate when further than this distance.\nRecommended: 80-120 yalms"); + ImGuiHelpers.ScaledDummy(3f); + int v4 = config.HelperFollowCheckInterval; + if (ImGui.SliderInt("Check Interval (seconds)", ref v4, 3, 15)) + { + config.HelperFollowCheckInterval = v4; + config.Save(); + } + DrawInfoIcon("How often to check distance to Quester.\nLower values = more responsive but more CPU usage.\nRecommended: 5 seconds"); + ImGui.Unindent(); + ImGuiHelpers.ScaledDummy(5f); + ImGui.Separator(); + ImGuiHelpers.ScaledDummy(3f); + ImGui.TextColored(new Vector4(0.7f, 0.9f, 1f, 1f), "Status:"); + if (Plugin.Instance?.GetChauffeurMode() != null) + { + ImU8String text3 = new ImU8String(23, 1); + text3.AppendLiteral("Follow Distance: "); + text3.AppendFormatted(config.HelperFollowDistance, "F0"); + text3.AppendLiteral(" yalms"); + ImGui.TextWrapped(text3); + ImU8String text4 = new ImU8String(17, 1); + text4.AppendLiteral("Check Interval: "); + text4.AppendFormatted(config.HelperFollowCheckInterval); + text4.AppendLiteral("s"); + ImGui.TextWrapped(text4); + } + ImGuiHelpers.ScaledDummy(3f); + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.8f, 1f), "Note: Helper Following automatically stops when:"); + ImGui.BulletText("Entering restricted zones (Main Cities)"); + ImGui.BulletText("Chauffeur Mode summon is active"); + ImGui.BulletText("Quester leaves party"); + ImGui.BulletText("Quester changes to different zone"); + } + } + }, config.EnableHelperFollowing); + ImGuiHelpers.ScaledDummy(10f); + DrawSettingSection("Movement Monitor", delegate + { + config.EnableMovementMonitor = DrawSettingWithInfo("Enable Movement Monitor", config.EnableMovementMonitor, "Automatically detects if player is stuck and sends /qst reload.\nMonitors player position and detects lack of movement.\nImpact: Quest will auto-reload if stuck for too long."); + if (ImGui.IsItemDeactivatedAfterEdit()) + { + config.Save(); + } + if (config.EnableMovementMonitor) + { + ImGui.Indent(); + int v = config.MovementCheckInterval; + if (ImGui.SliderInt("Check Interval (seconds)", ref v, 3, 30)) + { + config.MovementCheckInterval = v; + config.Save(); + } + DrawInfoIcon("How often to check player position.\nLower values = faster stuck detection."); + int v2 = config.MovementStuckThreshold; + if (ImGui.SliderInt("Stuck Threshold (seconds)", ref v2, 15, 120)) + { + config.MovementStuckThreshold = v2; + config.Save(); + } + DrawInfoIcon("Time without movement before considering player stuck.\nHigher values = less false positives."); + ImGui.Unindent(); + } + }, config.EnableMovementMonitor); + ImGuiHelpers.ScaledDummy(10f); + DrawSettingSection("Combat Handling", delegate + { + config.EnableCombatHandling = DrawSettingWithInfo("Enable Combat Handling", config.EnableCombatHandling, "Automatically enables combat automation when HP drops below threshold.\nActivates RSR/VBMAI/BMRAI during dangerous situations.\nImpact: Combat plugins will auto-enable when HP is low."); + if (ImGui.IsItemDeactivatedAfterEdit()) + { + config.Save(); + } + if (config.EnableCombatHandling) + { + ImGui.Indent(); + int v = config.CombatHPThreshold; + if (ImGui.SliderInt("HP Threshold (%)", ref v, 1, 99)) + { + config.CombatHPThreshold = v; + config.Save(); + } + DrawInfoIcon("Enable combat automation when HP drops below this percentage.\nCommands: /rsr manual, /vbmai on, /bmrai on"); + ImGui.Unindent(); + } + }, config.EnableCombatHandling); + ImGuiHelpers.ScaledDummy(10f); + DrawSettingSection("Death Handling", delegate + { + config.EnableDeathHandling = DrawSettingWithInfo("Enable Death Handling", config.EnableDeathHandling, "Automatically respawns and teleports back to death location.\nSaves position before death and returns after respawn.\nImpact: Deaths will be handled automatically during rotation."); + if (ImGui.IsItemDeactivatedAfterEdit()) + { + config.Save(); + } + if (config.EnableDeathHandling) + { + ImGui.Indent(); + int v = config.DeathRespawnDelay; + if (ImGui.SliderInt("Teleport Delay (seconds)", ref v, 1, 30)) + { + config.DeathRespawnDelay = v; + config.Save(); + } + DrawInfoIcon("Time to wait after respawn before teleporting back to death location.\nAllows time for loading and stabilization."); + ImGui.Unindent(); + } + }, config.EnableDeathHandling); + ImGuiHelpers.ScaledDummy(10f); + DrawSettingSection("Quest Automation", delegate + { + config.EnableQSTReloadTracking = DrawSettingWithInfo("Enable QST Reload Tracking", config.EnableQSTReloadTracking, "Tracks how many times /qst reload is called.\nSwitches character if reload count exceeds threshold.\nImpact: Prevents infinite reload loops by switching characters."); + if (ImGui.IsItemDeactivatedAfterEdit()) + { + config.Save(); + } + if (config.EnableQSTReloadTracking) + { + ImGui.Indent(); + int v = config.MaxQSTReloadsBeforeSwitch; + if (ImGui.SliderInt("Max Reloads Before Switch", ref v, 3, 20)) + { + config.MaxQSTReloadsBeforeSwitch = v; + config.Save(); + } + DrawInfoIcon("Maximum number of /qst reload commands before switching to next character.\nPrevents getting stuck on problematic quests."); + ImGui.Unindent(); + } + }, config.EnableQSTReloadTracking); + ImGuiHelpers.ScaledDummy(10f); + DrawSettingSection("Character Management", delegate + { + config.EnableMultiModeAfterRotation = DrawSettingWithInfo("Enable Multi-Mode After Rotation", config.EnableMultiModeAfterRotation, "Automatically enables AutoRetainer multi-mode after rotation completes.\nAllows retainer/submarine management after quest rotation.\nImpact: Multi-mode will activate when all quests are done."); + if (ImGui.IsItemDeactivatedAfterEdit()) + { + config.Save(); + } + config.ReturnToHomeworldOnStopQuest = DrawSettingWithInfo("Return to Homeworld on Stop Quest", config.ReturnToHomeworldOnStopQuest, "Automatically returns character to home world when rotation stops.\nUses /li command to return home.\nImpact: Characters will be sent home after completing their quests."); + if (ImGui.IsItemDeactivatedAfterEdit()) + { + config.Save(); + } + }, config.EnableMultiModeAfterRotation); + ImGuiHelpers.ScaledDummy(10f); + DrawSettingSection("Safe Wait Settings", delegate + { + config.EnableSafeWaitBeforeCharacterSwitch = DrawSettingWithInfo("Enable Safe Wait Before Character Switch", config.EnableSafeWaitBeforeCharacterSwitch, "Waits for safe conditions before switching characters.\nChecks for combat, cutscenes, and loading screens.\nImpact: Character switches will be delayed until safe."); + if (ImGui.IsItemDeactivatedAfterEdit()) + { + config.Save(); + } + config.EnableSafeWaitAfterCharacterSwitch = DrawSettingWithInfo("Enable Safe Wait After Character Switch", config.EnableSafeWaitAfterCharacterSwitch, "Waits for safe conditions after logging in new character.\nEnsures character is fully loaded before starting quests.\nImpact: Quest start will be delayed until character is ready."); + if (ImGui.IsItemDeactivatedAfterEdit()) + { + config.Save(); + } + }, config.EnableSafeWaitBeforeCharacterSwitch); + ImGuiHelpers.ScaledDummy(10f); + DrawSettingSection("Quest Pre-Check", delegate + { + config.EnableQuestPreCheck = DrawSettingWithInfo("Enable Quest Pre-Check", config.EnableQuestPreCheck, "Scans completed quests before starting rotation.\nSkips characters who already completed target quests.\nImpact: Saves time by not processing already-completed quests."); + if (ImGui.IsItemDeactivatedAfterEdit()) + { + config.Save(); + } + }, config.EnableQuestPreCheck); + ImGuiHelpers.ScaledDummy(20f); + } + + private void DrawSettingSection(string title, System.Action drawContent, bool isEnabled = false) + { + Vector2 cursorScreenPos = ImGui.GetCursorScreenPos(); + float availWidth = ImGui.GetContentRegionAvail().X; + ImDrawListPtr drawList = ImGui.GetWindowDrawList(); + ImGui.ColorConvertFloat4ToU32(new Vector4(0.12f, 0.12f, 0.15f, 0.8f)); + Vector4 borderColor; + if (isEnabled) + { + float pulse = (MathF.Sin((float)ImGui.GetTime() * 2f) + 1f) / 2f; + borderColor = new Vector4(0.47f, 0.69f, 0.88f, 0.5f + pulse * 0.5f); + } + else + { + borderColor = new Vector4(colorPrimary.X * 0.5f, colorPrimary.Y * 0.5f, colorPrimary.Z * 0.5f, 0.6f); + } + uint borderColorU32 = ImGui.ColorConvertFloat4ToU32(borderColor); + Vector2 boxStart = cursorScreenPos; + ImGui.PushStyleColor(ImGuiCol.Header, new Vector4(colorPrimary.X * 0.3f, colorPrimary.Y * 0.3f, colorPrimary.Z * 0.3f, 0.5f)); + ImGui.PushStyleColor(ImGuiCol.HeaderHovered, new Vector4(colorPrimary.X * 0.4f, colorPrimary.Y * 0.4f, colorPrimary.Z * 0.4f, 0.6f)); + ImGui.PushStyleColor(ImGuiCol.HeaderActive, new Vector4(colorPrimary.X * 0.5f, colorPrimary.Y * 0.5f, colorPrimary.Z * 0.5f, 0.7f)); + ImGui.PushStyleColor(ImGuiCol.Text, colorPrimary); + bool num = ImGui.CollapsingHeader(title, ImGuiTreeNodeFlags.DefaultOpen); + ImGui.PopStyleColor(4); + if (num) + { + ImGui.Indent(10f); + drawContent(); + ImGui.Unindent(10f); + ImGuiHelpers.ScaledDummy(5f); + } + Vector2 boxEnd = ImGui.GetCursorScreenPos(); + drawList.AddRect(boxStart, boxEnd + new Vector2(availWidth, 0f), borderColorU32, 4f, ImDrawFlags.None, isEnabled ? 2.5f : 1.5f); + } + + private bool DrawSettingWithInfo(string label, bool value, string infoText) + { + ImGui.Checkbox(label, ref value); + ImGui.SameLine(); + DrawInfoIcon(infoText); + return value; + } + + private void DrawInfoIcon(string tooltipText) + { + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImGui.TextUnformatted("[i]"); + ImGui.PopStyleColor(); + if (ImGui.IsItemHovered()) + { + ImGui.PushStyleColor(ImGuiCol.PopupBg, new Vector4(0.1f, 0.1f, 0.1f, 0.95f)); + ImGui.PushStyleColor(ImGuiCol.Border, colorPrimary); + ImGui.BeginTooltip(); + ImGui.PushTextWrapPos(400f); + ImGui.TextUnformatted(tooltipText); + ImGui.PopTextWrapPos(); + ImGui.EndTooltip(); + ImGui.PopStyleColor(2); + } + } + + private void DrawCharactersTab() + { + object obj = selectedDCFilter switch + { + 0 => "All Characters", + 1 => "EU Characters", + 2 => "NA Characters", + 3 => "JP Characters", + 4 => "OCE Characters", + _ => "Characters", + }; + ImGui.PushStyleColor(ImGuiCol.Text, colorPrimary); + ImGui.TextUnformatted((string?)obj); + ImGui.PopStyleColor(); + ImGuiHelpers.ScaledDummy(10f); + List filteredChars = GetFilteredCharacters(); + if (!initialCharacterLoadComplete) + { + if (characterLoadAttempts < 5) + { + ImGui.PushStyleColor(ImGuiCol.Text, colorPrimary); + double elapsedSeconds = (DateTime.Now - initialLoadStartTime).TotalSeconds; + int nextRetryIn = ((characterLoadAttempts < retryDelaysSeconds.Length) ? (retryDelaysSeconds[characterLoadAttempts] - (int)elapsedSeconds) : 0); + if (nextRetryIn > 0) + { + ImU8String text = new ImU8String(36, 3); + text.AppendLiteral("Loading characters... (Retry "); + text.AppendFormatted(characterLoadAttempts); + text.AppendLiteral("/"); + text.AppendFormatted(5); + text.AppendLiteral(" in "); + text.AppendFormatted(nextRetryIn); + text.AppendLiteral("s)"); + ImGui.TextUnformatted(text); + } + else + { + ImU8String text2 = new ImU8String(33, 2); + text2.AppendLiteral("Loading characters... (Attempt "); + text2.AppendFormatted(characterLoadAttempts + 1); + text2.AppendLiteral("/"); + text2.AppendFormatted(5); + text2.AppendLiteral(")"); + ImGui.TextUnformatted(text2); + } + ImGui.PopStyleColor(); + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImGui.TextUnformatted("Waiting for AutoRetainer to initialize..."); + ImGui.PopStyleColor(); + ImGuiHelpers.ScaledDummy(10f); + if (ImGui.Button("Retry Now")) + { + characterLoadAttempts = 0; + initialLoadStartTime = DateTime.MinValue; + autoRetainerIpc.TryReinitialize(); + RefreshCharacterList(); + } + } + else + { + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1f, 0.3f, 0.3f, 1f)); + ImGui.TextUnformatted("AutoRetainer not available"); + ImGui.PopStyleColor(); + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImGui.TextUnformatted("Please ensure AutoRetainer plugin is installed and enabled."); + ImU8String text3 = new ImU8String(29, 1); + text3.AppendLiteral("Tried "); + text3.AppendFormatted(5); + text3.AppendLiteral(" times without success."); + ImGui.TextUnformatted(text3); + ImGui.PopStyleColor(); + ImGuiHelpers.ScaledDummy(10f); + if (ImGui.Button("Retry Connection")) + { + characterLoadAttempts = 0; + initialLoadStartTime = DateTime.Now; + initialCharacterLoadComplete = false; + autoRetainerIpc.TryReinitialize(); + RefreshCharacterList(); + } + } + return; + } + if (!autoRetainerIpc.IsAvailable) + { + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1f, 0.8f, 0f, 1f)); + ImGui.TextUnformatted("AutoRetainer connection lost"); + ImGui.PopStyleColor(); + ImGui.TextUnformatted("The connection to AutoRetainer was lost."); + if (ImGui.Button("Reconnect")) + { + autoRetainerIpc.TryReinitialize(); + RefreshCharacterList(); + } + return; + } + if (filteredChars.Count == 0) + { + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImGui.TextUnformatted("No characters found"); + ImGui.PopStyleColor(); + return; + } + ImGui.PushStyleColor(ImGuiCol.Button, colorPrimary); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, colorSecondary); + if (ImGui.Button("Refresh")) + { + RefreshCharacterList(); + characterProgressCache.Clear(); + } + ImGui.PopStyleColor(2); + ImGui.SameLine(); + if (ImGui.Button("Select All")) + { + foreach (string character in filteredChars) + { + characterSelection[character] = true; + } + } + ImGui.SameLine(); + if (ImGui.Button("Deselect All")) + { + foreach (string character2 in filteredChars) + { + characterSelection[character2] = false; + } + } + ImGui.SameLine(); + ImGui.SetNextItemWidth(150f); + List currentDCWorlds = GetWorldsForCurrentDatacenter(); + if (ImGui.BeginCombo("##WorldFilter", selectedWorldFilter)) + { + if (ImGui.Selectable("All", selectedWorldFilter == "All")) + { + selectedWorldFilter = "All"; + } + foreach (string world in currentDCWorlds.OrderBy((string w) => w)) + { + if (ImGui.Selectable(world, selectedWorldFilter == world)) + { + selectedWorldFilter = world; + } + } + ImGui.EndCombo(); + } + ImGuiHelpers.ScaledDummy(10f); + float cardWidth = (ImGui.GetContentRegionAvail().X - 20f) / 2f; + using ImRaii.IEndObject child = ImRaii.Child("CharacterCards", new Vector2(0f, 0f), border: false); + if (!child.Success) + { + return; + } + int cardIndex = 0; + foreach (string character3 in filteredChars) + { + string[] parts = character3.Split('@'); + string charName = ((parts.Length != 0) ? parts[0] : character3); + string world2 = ((parts.Length > 1) ? parts[1] : "Unknown"); + if (cardIndex % 2 == 1) + { + ImGui.SameLine(); + } + ImDrawListPtr drawList = ImGui.GetWindowDrawList(); + Vector2 cursorPos = ImGui.GetCursorScreenPos(); + float cardHeight = 90f; + Vector4 cardBg = new Vector4(0.15f, 0.15f, 0.18f, 0.9f); + uint borderColor = (characterSelection.GetValueOrDefault(character3, defaultValue: false) ? ImGui.ColorConvertFloat4ToU32(new Vector4(colorPrimary.X, colorPrimary.Y, colorPrimary.Z, 0.8f)) : ImGui.ColorConvertFloat4ToU32(new Vector4(colorPrimary.X * 0.3f, colorPrimary.Y * 0.3f, colorPrimary.Z * 0.3f, 0.5f))); + drawList.AddRectFilled(cursorPos, cursorPos + new Vector2(cardWidth, cardHeight), ImGui.ColorConvertFloat4ToU32(cardBg), 6f); + drawList.AddRect(cursorPos, cursorPos + new Vector2(cardWidth, cardHeight), borderColor, 6f, ImDrawFlags.None, 2f); + ImGui.SetCursorScreenPos(cursorPos + new Vector2(10f, 10f)); + using (ImRaii.PushId(character3)) + { + bool isSelected = characterSelection.GetValueOrDefault(character3, defaultValue: false); + if (ImGui.Checkbox("##Select", ref isSelected)) + { + characterSelection[character3] = isSelected; + } + } + ImGui.SetCursorScreenPos(cursorPos + new Vector2(40f, 8f)); + ImGui.PushStyleColor(ImGuiCol.Text, colorPrimary); + ImGui.Text(charName); + ImGui.PopStyleColor(); + ImGui.SetCursorScreenPos(cursorPos + new Vector2(40f, 26f)); + ImGui.PushStyleColor(ImGuiCol.Text, colorAccent); + ImGui.Text(world2); + ImGui.PopStyleColor(); + if (characterProgressCache.TryGetValue(character3, out CharacterProgressInfo progressInfo)) + { + ImGui.SetCursorScreenPos(cursorPos + new Vector2(10f, 50f)); + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImU8String text4 = new ImU8String(7, 1); + text4.AppendFormatted(progressInfo.CompletedQuestCount); + text4.AppendLiteral(" Quests"); + ImGui.Text(text4); + ImGui.PopStyleColor(); + ImGui.SetCursorScreenPos(cursorPos + new Vector2(10f, 68f)); + float barWidth = cardWidth - 20f; + float progress = progressInfo.MSQCompletionPercentage / 100f; + uint barBg = ImGui.ColorConvertFloat4ToU32(new Vector4(0.2f, 0.2f, 0.2f, 0.8f)); + uint barFg = ImGui.ColorConvertFloat4ToU32(new Vector4(colorSecondary.X, colorSecondary.Y, colorSecondary.Z, 0.9f)); + drawList.AddRectFilled(cursorPos + new Vector2(10f, 68f), cursorPos + new Vector2(10f + barWidth, 82f), barBg, 3f); + drawList.AddRectFilled(cursorPos + new Vector2(10f, 68f), cursorPos + new Vector2(10f + barWidth * progress, 82f), barFg, 3f); + string progressText = $"{progressInfo.MSQCompletionPercentage:F0}%"; + Vector2 textSize = ImGui.CalcTextSize(progressText); + drawList.AddText(cursorPos + new Vector2(10f + barWidth / 2f - textSize.X / 2f, 69f), ImGui.ColorConvertFloat4ToU32(new Vector4(1f, 1f, 1f, 1f)), progressText); + } + else + { + ImGui.SetCursorScreenPos(cursorPos + new Vector2(10f, 50f)); + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImGui.Text("Loading..."); + ImGui.PopStyleColor(); + GetCharacterProgress(character3); + } + ImGui.SetCursorScreenPos(cursorPos); + ImGui.Dummy(new Vector2(cardWidth, cardHeight)); + cardIndex++; + } + } + + private void DrawStopPointsTab() + { + ImGui.PushStyleColor(ImGuiCol.Text, colorPrimary); + ImGui.TextUnformatted("Quest Rotation System"); + ImGui.PopStyleColor(); + ImGuiHelpers.ScaledDummy(10f); + List selectedCharacters = (from kvp in characterSelection + where kvp.Value + select kvp.Key).ToList(); + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImGui.TextWrapped("Rotates your selected Characters depending on the Stop Configurations you have enabled in Questionable. Please Configure a Quest and / or Level for a Rotation to be able to start."); + ImGui.PopStyleColor(); + ImGuiHelpers.ScaledDummy(10f); + ImGui.PushStyleColor(ImGuiCol.Button, colorAccent); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, colorPrimary); + if (ImGui.Button("Import from Questionable")) + { + questRotationService.ImportStopPointsFromQuestionable(); + log.Information("[StopPoints] Imported stop points from Questionable"); + } + ImGui.PopStyleColor(2); + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Pull stop quests and sequences from Questionable configuration"); + } + ImGui.SameLine(); + ImGui.PushStyleColor(ImGuiCol.Button, colorPrimary); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, colorSecondary); + if (ImGui.Button("Open Questionable Settings")) + { + Plugin.CommandManager.ProcessCommand("/qst config"); + log.Information("[StopPoints] Opened Questionable settings"); + } + ImGui.PopStyleColor(2); + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Open Questionable plugin settings window"); + } + ImGuiHelpers.ScaledDummy(10f); + ImGui.Separator(); + ImGuiHelpers.ScaledDummy(10f); + ImGui.PushStyleColor(ImGuiCol.Text, colorPrimary); + ImGui.TextUnformatted("Level Stop Condition:"); + ImGui.PopStyleColor(); + StopConditionData levelStopCondition = plugin.QuestionableIPC.GetLevelStopCondition(); + if (levelStopCondition != null && levelStopCondition.Enabled) + { + ImGui.SetWindowFontScale(1.2f); + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImU8String text = new ImU8String(15, 1); + text.AppendLiteral("Stop at Level: "); + text.AppendFormatted(levelStopCondition.TargetValue); + ImGui.TextUnformatted(text); + ImGui.PopStyleColor(); + ImGui.SetWindowFontScale(1f); + } + else + { + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.6f, 0.6f, 0.6f, 1f)); + ImGui.TextUnformatted("Not configured"); + ImGui.PopStyleColor(); + } + ImGuiHelpers.ScaledDummy(10f); + ImGui.Separator(); + ImGuiHelpers.ScaledDummy(10f); + ImGui.TextUnformatted("Active Stop Points:"); + ImGuiHelpers.ScaledDummy(5f); + List stopPoints = questRotationService.GetAllStopPoints(); + if (stopPoints.Count == 0) + { + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImGui.TextUnformatted("No stop points configured."); + ImGui.PopStyleColor(); + } + else + { + using ImRaii.IEndObject table = ImRaii.Table("StopPointsTable", 5, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg); + if (table.Success) + { + ImGui.TableSetupColumn("Stop Point", ImGuiTableColumnFlags.WidthFixed, 150f); + ImGui.TableSetupColumn("Status", ImGuiTableColumnFlags.WidthFixed, 80f); + ImGui.TableSetupColumn("Remaining", ImGuiTableColumnFlags.WidthFixed, 100f); + ImGui.TableSetupColumn("Progress", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Actions", ImGuiTableColumnFlags.WidthFixed, 60f); + ImGui.TableHeadersRow(); + for (int i = 0; i < stopPoints.Count; i++) + { + StopPoint stopPoint = stopPoints[i]; + (int completed, int total) rotationProgress = questRotationService.GetRotationProgress(stopPoint.QuestId); + int completed = rotationProgress.completed; + int total = rotationProgress.total; + int num = total - completed; + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(stopPoint.DisplayName); + ImGui.TableNextColumn(); + if (stopPoint.IsActive) + { + ImGui.PushStyleColor(ImGuiCol.Text, colorPrimary); + ImGui.TextUnformatted("Active"); + ImGui.PopStyleColor(); + } + else + { + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImGui.TextUnformatted("Queued"); + ImGui.PopStyleColor(); + } + ImGui.TableNextColumn(); + Vector4 remainingColor = ((num == 0) ? colorPrimary : colorSecondary); + ImGui.PushStyleColor(ImGuiCol.Text, remainingColor); + ImU8String text2 = new ImU8String(1, 2); + text2.AppendFormatted(completed); + text2.AppendLiteral("/"); + text2.AppendFormatted(total); + ImGui.TextUnformatted(text2); + ImGui.PopStyleColor(); + ImGui.TableNextColumn(); + float progress = ((total > 0) ? ((float)completed / (float)total) : 0f); + ImDrawListPtr drawList = ImGui.GetWindowDrawList(); + Vector2 cursorPos = ImGui.GetCursorScreenPos(); + float barWidth = ImGui.GetContentRegionAvail().X; + float barHeight = 20f; + uint barBg = ImGui.ColorConvertFloat4ToU32(new Vector4(0.2f, 0.2f, 0.2f, 0.8f)); + drawList.AddRectFilled(cursorPos, cursorPos + new Vector2(barWidth, barHeight), barBg, 4f); + Vector4 barColor = ((progress >= 0.85f) ? colorPrimary : ((progress >= 0.5f) ? colorSecondary : colorAccent)); + uint barFg = ImGui.ColorConvertFloat4ToU32(new Vector4(barColor.X, barColor.Y, barColor.Z, 0.9f)); + drawList.AddRectFilled(cursorPos, cursorPos + new Vector2(barWidth * progress, barHeight), barFg, 4f); + string progressText = $"{(int)(progress * 100f)}%"; + Vector2 textSize = ImGui.CalcTextSize(progressText); + drawList.AddText(cursorPos + new Vector2(barWidth / 2f - textSize.X / 2f, 2f), ImGui.ColorConvertFloat4ToU32(new Vector4(1f, 1f, 1f, 1f)), progressText); + ImGui.Dummy(new Vector2(barWidth, barHeight)); + ImGui.TableNextColumn(); + using (ImRaii.PushId(i)) + { + ImGui.PushStyleColor(ImGuiCol.Button, colorAccent); + if (ImGui.Button("X")) + { + questRotationService.RemoveStopPoint(stopPoint.QuestId); + log.Information($"[StopPoints] Removed stop quest {stopPoint.QuestId}"); + } + ImGui.PopStyleColor(); + } + } + } + } + ImGuiHelpers.ScaledDummy(15f); + ImGui.Separator(); + ImGuiHelpers.ScaledDummy(10f); + ImGui.PushStyleColor(ImGuiCol.Text, colorPrimary); + ImGui.TextUnformatted("Current Status:"); + ImGui.PopStyleColor(); + ImGuiHelpers.ScaledDummy(5f); + RotationState state = questRotationService.GetCurrentState(); + ImU8String text3 = new ImU8String(7, 0); + text3.AppendLiteral("Phase: "); + ImGui.TextUnformatted(text3); + ImGui.SameLine(); + ImGui.PushStyleColor(ImGuiCol.Text, state.Phase switch + { + RotationPhase.Idle => colorSecondary, + RotationPhase.Questing => colorPrimary, + RotationPhase.QuestActive => colorPrimary, + RotationPhase.InCombat => new Vector4(1f, 0.5f, 0.2f, 1f), + RotationPhase.InDungeon => new Vector4(0.8f, 0.4f, 1f, 1f), + RotationPhase.HandlingSubmarines => new Vector4(0.2f, 0.8f, 1f, 1f), + RotationPhase.WaitingForChauffeur => new Vector4(1f, 1f, 0.4f, 1f), + RotationPhase.TravellingWithChauffeur => new Vector4(0.4f, 1f, 0.4f, 1f), + RotationPhase.DCTraveling => new Vector4(0.5f, 0.5f, 1f, 1f), + RotationPhase.Completed => colorPrimary, + RotationPhase.Error => colorAccent, + _ => colorSecondary, + }); + ImGui.TextUnformatted(state.Phase.ToString()); + ImGui.PopStyleColor(); + if (!string.IsNullOrEmpty(state.CurrentCharacter)) + { + ImU8String text4 = new ImU8String(11, 0); + text4.AppendLiteral("Logged In: "); + ImGui.TextUnformatted(text4); + ImGui.SameLine(); + ImGui.PushStyleColor(ImGuiCol.Text, colorPrimary); + ImGui.TextUnformatted(state.CurrentCharacter); + ImGui.PopStyleColor(); + } + if (state.CurrentStopQuestId != 0) + { + ImU8String text5 = new ImU8String(14, 0); + text5.AppendLiteral("Target Quest: "); + ImGui.TextUnformatted(text5); + ImGui.SameLine(); + ImGui.PushStyleColor(ImGuiCol.Text, colorPrimary); + ImGui.TextUnformatted(state.CurrentStopQuestId.ToString()); + ImGui.PopStyleColor(); + } + if (!string.IsNullOrEmpty(state.NextCharacter)) + { + ImU8String text6 = new ImU8String(16, 0); + text6.AppendLiteral("Next Character: "); + ImGui.TextUnformatted(text6); + ImGui.SameLine(); + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImGui.TextUnformatted(state.NextCharacter); + ImGui.PopStyleColor(); + } + if (state.RemainingCharacters.Count > 0) + { + ImU8String text7 = new ImU8String(11, 1); + text7.AppendLiteral("Remaining: "); + text7.AppendFormatted(string.Join(", ", state.RemainingCharacters)); + ImGui.TextUnformatted(text7); + } + if (state.Phase == RotationPhase.Error && !string.IsNullOrEmpty(state.ErrorMessage)) + { + ImGui.PushStyleColor(ImGuiCol.Text, colorAccent); + ImU8String text8 = new ImU8String(7, 1); + text8.AppendLiteral("Error: "); + text8.AppendFormatted(state.ErrorMessage); + ImGui.TextUnformatted(text8); + ImGui.PopStyleColor(); + } + if (state.SelectedCharacters.Count > 0) + { + ImGuiHelpers.ScaledDummy(5f); + float fraction = (float)state.CompletedCharacters.Count / (float)state.SelectedCharacters.Count; + Vector2 sizeArg = new Vector2(-1f, 0f); + ImU8String overlay = new ImU8String(11, 2); + overlay.AppendFormatted(state.CompletedCharacters.Count); + overlay.AppendLiteral("/"); + overlay.AppendFormatted(state.SelectedCharacters.Count); + overlay.AppendLiteral(" completed"); + ImGui.ProgressBar(fraction, sizeArg, overlay); + } + ImGuiHelpers.ScaledDummy(10f); + if (selectedCharacters.Count == 0) + { + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImGui.TextUnformatted("Select characters in the Characters tab to start rotation"); + ImGui.PopStyleColor(); + } + else if (stopPoints.Count == 0 && (levelStopCondition == null || !levelStopCondition.Enabled)) + { + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImGui.TextUnformatted("Configure a Quest or Level stop condition above to start rotation"); + ImGui.PopStyleColor(); + } + else if (state.Phase == RotationPhase.Idle || state.Phase == RotationPhase.Completed || state.Phase == RotationPhase.Error) + { + ImGui.PushStyleColor(ImGuiCol.Button, colorPrimary); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, colorSecondary); + if (ImGui.Button("Start Rotation", new Vector2(200f, 30f))) + { + log.Information("[StopPoints] Start Rotation button clicked!"); + log.Information($"[StopPoints] Selected characters: {selectedCharacters.Count}"); + log.Information($"[StopPoints] Stop points: {stopPoints.Count}"); + if (stopPoints.Count > 0) + { + foreach (StopPoint item in stopPoints) + { + item.IsActive = false; + } + bool foundValidStopPoint = false; + for (int i2 = 0; i2 < stopPoints.Count; i2++) + { + StopPoint stopPoint2 = stopPoints[i2]; + var (completed2, total2) = questRotationService.GetRotationProgress(stopPoint2.QuestId, selectedCharacters); + if (completed2 < total2) + { + log.Information("[StopPoints] Starting rotation with: " + stopPoint2.DisplayName); + log.Information($"[StopPoints] Progress: {completed2}/{total2} completed"); + log.Information($"[StopPoints] Total stop points in queue: {stopPoints.Count - i2}"); + for (int j = i2; j < stopPoints.Count; j++) + { + stopPoints[j].IsActive = j == i2; + } + if (questRotationService.StartRotation(stopPoint2.QuestId, selectedCharacters)) + { + log.Information("[StopPoints] Rotation started successfully!"); + foundValidStopPoint = true; + } + else + { + log.Error("[StopPoints] Failed to start rotation"); + } + break; + } + log.Information($"[StopPoints] Skipping {stopPoint2.DisplayName} - all characters completed ({completed2}/{total2})"); + } + if (!foundValidStopPoint) + { + log.Warning("[StopPoints] All stop points already completed by all characters!"); + } + } + else if (levelStopCondition != null && levelStopCondition.Enabled) + { + log.Information($"[StopPoints] Starting level-only rotation (target level: {levelStopCondition.TargetValue})"); + if (questRotationService.StartRotationLevelOnly(selectedCharacters)) + { + log.Information("[StopPoints] Level-only rotation started successfully!"); + } + else + { + log.Error("[StopPoints] Failed to start level-only rotation"); + } + } + else + { + log.Warning("[StopPoints] No stop points or level condition configured!"); + } + } + ImGui.PopStyleColor(2); + } + else + { + ImGui.PushStyleColor(ImGuiCol.Button, colorAccent); + if (ImGui.Button("Stop Rotation", new Vector2(200f, 30f))) + { + questRotationService.AbortRotation(); + log.Information("[StopPoints] Stopped rotation"); + } + ImGui.PopStyleColor(); + } + } + + private void DrawMSQProgressionTab() + { + ImGui.PushStyleColor(ImGuiCol.Text, colorPrimary); + ImGui.TextUnformatted("Main Scenario Quest Progression"); + ImGui.PopStyleColor(); + ImGuiHelpers.ScaledDummy(10f); + List filteredChars = GetFilteredCharacters(); + Configuration config = plugin.Configuration; + if (filteredChars.Count == 0) + { + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImGui.TextUnformatted("No characters to display"); + ImGui.PopStyleColor(); + return; + } + ImGui.PushStyleColor(ImGuiCol.Button, colorPrimary); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, colorSecondary); + if (ImGui.Button("Refresh Progress")) + { + characterProgressCache.Clear(); + log.Information("[MSQProgression] Manual progress refresh requested"); + } + ImGui.PopStyleColor(2); + ImGui.SameLine(); + RotationState state = questRotationService.GetCurrentState(); + if (state.Phase == RotationPhase.Idle || state.Phase == RotationPhase.Completed || state.Phase == RotationPhase.Error || state.CurrentStopQuestId != 0) + { + ImGui.PushStyleColor(ImGuiCol.Button, colorSecondary); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, colorPrimary); + if (ImGui.Button("First Time Sync")) + { + log.Information("[MSQProgression] First Time Sync requested"); + if (questRotationService.StartSyncRotation(filteredChars)) + { + log.Information("[MSQProgression] Sync rotation started - will go through all characters without data"); + } + else + { + log.Information("[MSQProgression] No characters need sync or failed to start"); + } + } + ImGui.PopStyleColor(2); + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Automatically sync MSQ data for all characters without existing data"); + } + } + else + { + ImGui.PushStyleColor(ImGuiCol.Button, colorAccent); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, colorPrimary); + if (ImGui.Button("Stop Syncing")) + { + log.Information("[MSQProgression] Stop Syncing requested"); + questRotationService.AbortRotation(); + characterProgressCache.Clear(); + } + ImGui.PopStyleColor(2); + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Stop the sync rotation"); + } + } + ImGui.SameLine(); + ImGui.TextUnformatted("Display Mode:"); + ImGui.SameLine(); + int displayMode = (int)config.MSQDisplayMode; + ImGui.SetNextItemWidth(200f); + if (ImGui.Combo("##MSQDisplayMode", ref displayMode, "Current Expansion\0Overall Progress\0Expansion Breakdown\0")) + { + config.MSQDisplayMode = (MSQDisplayMode)displayMode; + config.Save(); + } + ImGuiHelpers.ScaledDummy(10f); + switch (config.MSQDisplayMode) + { + case MSQDisplayMode.CurrentExpansion: + DrawMSQCurrentExpansion(filteredChars); + break; + case MSQDisplayMode.Overall: + DrawMSQOverall(filteredChars); + break; + case MSQDisplayMode.ExpansionBreakdown: + DrawMSQExpansionBreakdown(filteredChars); + break; + } + } + + private void DrawMSQCurrentExpansion(List characters) + { + using ImRaii.IEndObject table = ImRaii.Table("MSQCurrentExpTable", 4, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY); + if (!table.Success) + { + return; + } + ImGui.TableSetupColumn("Character", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Current Expansion", ImGuiTableColumnFlags.WidthFixed, 150f); + ImGui.TableSetupColumn("Progress", ImGuiTableColumnFlags.WidthFixed, 100f); + ImGui.TableSetupColumn("Completion", ImGuiTableColumnFlags.WidthFixed, 120f); + ImGui.TableSetupScrollFreeze(0, 1); + ImGui.TableHeadersRow(); + foreach (string character in characters) + { + if (!characterProgressCache.TryGetValue(character, out CharacterProgressInfo _)) + { + GetCharacterProgress(character); + continue; + } + List completedQuestsList = questRotationService.GetCompletedQuestsByCharacter(character); + MSQExpansionData.Expansion currentExpansion = MSQExpansionData.GetCurrentExpansion(completedQuestsList); + ExpansionInfo currentExp = new ExpansionInfo + { + Name = MSQExpansionData.GetExpansionName(currentExpansion), + ShortName = MSQExpansionData.GetExpansionShortName(currentExpansion), + MinQuestId = 0u, + MaxQuestId = 0u, + ExpectedQuestCount = MSQExpansionData.GetExpectedQuestCount(currentExpansion) + }; + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + string[] parts = character.Split('@'); + ImGui.TextUnformatted((parts.Length != 0) ? parts[0] : character); + ImGui.TableNextColumn(); + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImGui.TextUnformatted(currentExp?.Name ?? "A Realm Reborn"); + ImGui.PopStyleColor(); + ImGui.TableNextColumn(); + if (currentExp != null) + { + (int completed, int total) valueOrDefault = msqProgressionService.GetExpansionProgressForCharacter(completedQuestsList).GetValueOrDefault(currentExp.ShortName, (0, 0)); + int completed = valueOrDefault.completed; + int total = valueOrDefault.total; + ImU8String text = new ImU8String(1, 2); + text.AppendFormatted(completed); + text.AppendLiteral("/"); + text.AppendFormatted(total); + ImGui.TextUnformatted(text); + } + else + { + ImGui.TextUnformatted("0/0"); + } + ImGui.TableNextColumn(); + if (currentExp != null) + { + (int completed, int total) valueOrDefault2 = msqProgressionService.GetExpansionProgressForCharacter(completedQuestsList).GetValueOrDefault(currentExp.ShortName, (0, 0)); + int completed2 = valueOrDefault2.completed; + int total2 = valueOrDefault2.total; + float percentage = ((total2 > 0) ? ((float)completed2 / (float)total2) : 0f); + Vector2 sizeArg = new Vector2(-1f, 0f); + ImU8String overlay = new ImU8String(1, 1); + overlay.AppendFormatted((int)(percentage * 100f)); + overlay.AppendLiteral("%"); + ImGui.ProgressBar(percentage, sizeArg, overlay); + } + else + { + ImGui.ProgressBar(0f, new Vector2(-1f, 0f), "0%"); + } + } + } + + private void DrawMSQOverall(List characters) + { + using ImRaii.IEndObject table = ImRaii.Table("MSQOverallTable", 4, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY); + if (!table.Success) + { + return; + } + ImGui.TableSetupColumn("Character", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("MSQ Progress", ImGuiTableColumnFlags.WidthFixed, 120f); + ImGui.TableSetupColumn("Current MSQ", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Completion %", ImGuiTableColumnFlags.WidthFixed, 100f); + ImGui.TableSetupScrollFreeze(0, 1); + ImGui.TableHeadersRow(); + foreach (string character in characters) + { + if (!characterProgressCache.TryGetValue(character, out CharacterProgressInfo progressInfo)) + { + GetCharacterProgress(character); + continue; + } + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + string[] parts = character.Split('@'); + ImGui.TextUnformatted((parts.Length != 0) ? parts[0] : character); + ImGui.TableNextColumn(); + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImU8String text = new ImU8String(3, 2); + text.AppendFormatted(progressInfo.CompletedMSQCount); + text.AppendLiteral(" / "); + text.AppendFormatted(msqProgressionService.GetTotalMSQCount()); + ImGui.TextUnformatted(text); + ImGui.PopStyleColor(); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(progressInfo.LastCompletedMSQName); + ImGui.TableNextColumn(); + float percentage = progressInfo.MSQCompletionPercentage; + float fraction = percentage / 100f; + Vector2 sizeArg = new Vector2(-1f, 0f); + ImU8String overlay = new ImU8String(1, 1); + overlay.AppendFormatted(percentage, "F1"); + overlay.AppendLiteral("%"); + ImGui.ProgressBar(fraction, sizeArg, overlay); + } + } + + private void DrawMSQExpansionBreakdown(List characters) + { + List expansions = msqProgressionService.GetExpansions(); + foreach (string character in characters) + { + string[] parts = character.Split('@'); + string obj = ((parts.Length != 0) ? parts[0] : character); + string worldName = ((parts.Length > 1) ? parts[1] : "Unknown"); + string displayName = obj + " @ " + worldName; + ImGui.PushStyleColor(ImGuiCol.Text, colorPrimary); + ImU8String label = new ImU8String(15, 2); + label.AppendFormatted(displayName); + label.AppendLiteral("##MSQBreakdown_"); + label.AppendFormatted(character); + if (ImGui.CollapsingHeader(label)) + { + ImGui.PopStyleColor(); + ImGui.Indent(15f); + List completedQuests = questRotationService.GetCompletedQuestsByCharacter(character); + int currentExpansionIndex = (int)MSQExpansionData.GetCurrentExpansion(completedQuests); + Dictionary expProgress = msqProgressionService.GetExpansionProgressForCharacter(completedQuests); + int totalCompleted = 0; + int totalQuests = 0; + foreach (ExpansionInfo exp in expansions) + { + var (completed, total) = expProgress.GetValueOrDefault(exp.ShortName, (0, 0)); + if ((int)MSQExpansionData.GetAllExpansions().FirstOrDefault((MSQExpansionData.Expansion e) => MSQExpansionData.GetExpansionShortName(e) == exp.ShortName) < currentExpansionIndex) + { + completed = total; + } + totalCompleted += completed; + totalQuests += total; + } + ImU8String text = new ImU8String(13, 0); + text.AppendLiteral("Overall MSQ: "); + ImGui.TextUnformatted(text); + ImGui.SameLine(); + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImU8String text2 = new ImU8String(1, 2); + text2.AppendFormatted(totalCompleted); + text2.AppendLiteral("/"); + text2.AppendFormatted(totalQuests); + ImGui.TextUnformatted(text2); + ImGui.PopStyleColor(); + float overallPercentage = ((totalQuests > 0) ? ((float)totalCompleted / (float)totalQuests) : 0f); + Vector2 sizeArg = new Vector2(-1f, 0f); + ImU8String overlay = new ImU8String(1, 1); + overlay.AppendFormatted((int)(overallPercentage * 100f)); + overlay.AppendLiteral("%"); + ImGui.ProgressBar(overallPercentage, sizeArg, overlay); + ImGuiHelpers.ScaledDummy(10f); + ImGui.TextUnformatted("Expansion Breakdown:"); + ImGuiHelpers.ScaledDummy(5f); + foreach (ExpansionInfo exp2 in expansions) + { + var (completed2, total2) = expProgress.GetValueOrDefault(exp2.ShortName, (0, 0)); + if ((int)MSQExpansionData.GetAllExpansions().FirstOrDefault((MSQExpansionData.Expansion e) => MSQExpansionData.GetExpansionShortName(e) == exp2.ShortName) < currentExpansionIndex) + { + completed2 = total2; + } + float percentage = ((total2 > 0) ? ((float)completed2 / (float)total2) : 0f); + bool num = completed2 == total2 && total2 > 0; + ImU8String text3 = new ImU8String(6, 2); + text3.AppendLiteral(" "); + text3.AppendFormatted(exp2.Name); + text3.AppendLiteral(" ("); + text3.AppendFormatted(exp2.ShortName); + text3.AppendLiteral("):"); + ImGui.TextUnformatted(text3); + ImGui.SameLine(); + if (num) + { + ImGui.PushStyleColor(ImGuiCol.Text, colorPrimary); + ImU8String text4 = new ImU8String(10, 2); + text4.AppendFormatted(completed2); + text4.AppendLiteral("/"); + text4.AppendFormatted(total2); + text4.AppendLiteral(" Complete"); + ImGui.TextUnformatted(text4); + ImGui.PopStyleColor(); + } + else if (completed2 == 0) + { + ImGui.PushStyleColor(ImGuiCol.Text, colorAccent); + ImU8String text5 = new ImU8String(13, 2); + text5.AppendFormatted(completed2); + text5.AppendLiteral("/"); + text5.AppendFormatted(total2); + text5.AppendLiteral(" Not Started"); + ImGui.TextUnformatted(text5); + ImGui.PopStyleColor(); + } + else + { + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImU8String text6 = new ImU8String(1, 2); + text6.AppendFormatted(completed2); + text6.AppendLiteral("/"); + text6.AppendFormatted(total2); + ImGui.TextUnformatted(text6); + ImGui.PopStyleColor(); + } + ImGui.Indent(20f); + Vector2 sizeArg2 = new Vector2(-1f, 0f); + ImU8String overlay2 = new ImU8String(1, 1); + overlay2.AppendFormatted((int)(percentage * 100f)); + overlay2.AppendLiteral("%"); + ImGui.ProgressBar(percentage, sizeArg2, overlay2); + ImGui.Unindent(20f); + } + ImGui.Unindent(15f); + } + else + { + ImGui.PopStyleColor(); + } + ImGuiHelpers.ScaledDummy(5f); + } + } + + private void DrawSettingsTab() + { + DrawSettingsTabFull(); + } + + private List GetWorldsForCurrentDatacenter() + { + if (selectedDCFilter == 0) + { + return availableWorlds; + } + string dcName = selectedDCFilter switch + { + 1 => "EU", + 2 => "NA", + 3 => "JP", + 4 => "OCE", + _ => "All", + }; + List charactersForDataCenter = dataCenterService.GetCharactersForDataCenter(registeredCharacters, dcName, charactersByDataCenter); + HashSet worlds = new HashSet(); + foreach (string item in charactersForDataCenter) + { + string[] parts = item.Split('@'); + if (parts.Length > 1) + { + worlds.Add(parts[1]); + } + } + return worlds.ToList(); + } + + private List GetFilteredCharacters() + { + List dcFiltered; + if (selectedDCFilter == 0) + { + dcFiltered = registeredCharacters; + } + else + { + string dcName = selectedDCFilter switch + { + 1 => "EU", + 2 => "NA", + 3 => "JP", + 4 => "OCE", + _ => "All", + }; + dcFiltered = dataCenterService.GetCharactersForDataCenter(registeredCharacters, dcName, charactersByDataCenter); + } + if (selectedWorldFilter != "All") + { + return dcFiltered.Where((string c) => c.EndsWith("@" + selectedWorldFilter)).ToList(); + } + return dcFiltered; + } + + private CharacterProgressInfo GetCharacterProgress(string characterName) + { + if (characterProgressCache.TryGetValue(characterName, out CharacterProgressInfo cached) && (DateTime.Now - cached.LastUpdatedUtc).TotalSeconds < 300.0) + { + return cached; + } + string[] parts = characterName.Split('@'); + string world = ((parts.Length > 1) ? parts[1] : "Unknown"); + List completedQuests = questRotationService.GetCompletedQuestsByCharacter(characterName); + uint lastQuestId = 0u; + string lastQuestName = "—"; + if (completedQuests.Count > 0) + { + lastQuestId = completedQuests.Max(); + lastQuestName = msqProgressionService.GetQuestName(lastQuestId); + if (lastQuestName == "Unknown Quest") + { + lastQuestName = $"Quest {lastQuestId}"; + } + } + List completedMSQs = completedQuests.Where((uint q) => msqProgressionService.IsMSQ(q)).ToList(); + uint lastMSQId = 0u; + string lastMSQName = "—"; + MSQExpansionData.Expansion currentExpansion = MSQExpansionData.GetCurrentExpansion(completedQuests); + string currentExpansionName = MSQExpansionData.GetExpansionName(currentExpansion); + List completedMSQsInCurrentExpansion = completedMSQs.Where((uint q) => MSQExpansionData.GetExpansionForQuest(q) == currentExpansion).ToList(); + if (completedMSQsInCurrentExpansion.Count > 0) + { + lastMSQId = completedMSQsInCurrentExpansion.Max(); + lastMSQName = msqProgressionService.GetQuestName(lastMSQId); + if (lastMSQName == "Unknown Quest") + { + lastMSQName = $"Quest {lastMSQId}"; + } + lastMSQName = "[" + currentExpansionName + "] " + lastMSQName; + } + else if (completedMSQs.Count > 0) + { + lastMSQId = completedMSQs.Max(); + lastMSQName = msqProgressionService.GetQuestName(lastMSQId); + if (lastMSQName == "Unknown Quest") + { + lastMSQName = $"Quest {lastMSQId}"; + } + } + int totalMSQCount = msqProgressionService.GetTotalMSQCount(); + float msqPercentage = ((totalMSQCount > 0) ? ((float)completedMSQs.Count / (float)totalMSQCount * 100f) : 0f); + CharacterProgressInfo progressInfo = new CharacterProgressInfo + { + World = world, + CompletedQuestCount = completedQuests.Count, + LastQuestId = lastQuestId, + LastQuestName = lastQuestName, + LastCompletedMSQId = lastMSQId, + LastCompletedMSQName = lastMSQName, + CompletedMSQCount = completedMSQs.Count, + MSQCompletionPercentage = msqPercentage, + LastUpdatedUtc = DateTime.UtcNow + }; + characterProgressCache[characterName] = progressInfo; + return progressInfo; + } + + public void DrawWorldSelectionDialogs() + { + if (showSelectWorldDialog) + { + ImGui.OpenPopup("Select World##SelectWorldDialog"); + } + if (ImGui.BeginPopupModal("Select World##SelectWorldDialog", ref showSelectWorldDialog, ImGuiWindowFlags.AlwaysAutoResize)) + { + ImGui.TextUnformatted("Select a world to check all characters:"); + ImGuiHelpers.ScaledDummy(10f); + ImGui.SetNextItemWidth(200f); + if (ImGui.BeginCombo("##WorldSelect", selectedWorldForBulkAction)) + { + foreach (string world in availableWorlds) + { + bool isSelected = selectedWorldForBulkAction == world; + if (ImGui.Selectable(world, isSelected)) + { + selectedWorldForBulkAction = world; + } + if (isSelected) + { + ImGui.SetItemDefaultFocus(); + } + } + ImGui.EndCombo(); + } + ImGuiHelpers.ScaledDummy(10f); + if (ImGui.Button("Cancel", new Vector2(100f, 0f))) + { + showSelectWorldDialog = false; + } + ImGui.SameLine(); + ImGui.PushStyleColor(ImGuiCol.Button, colorPrimary); + if (ImGui.Button("Select", new Vector2(100f, 0f))) + { + foreach (string character in registeredCharacters) + { + if (character.EndsWith("@" + selectedWorldForBulkAction)) + { + characterSelection[character] = true; + } + } + showSelectWorldDialog = false; + log.Information("[NewMainWindow] Selected all characters from " + selectedWorldForBulkAction); + } + ImGui.PopStyleColor(); + ImGui.EndPopup(); + } + if (showDeselectWorldDialog) + { + ImGui.OpenPopup("Deselect World##DeselectWorldDialog"); + } + if (!ImGui.BeginPopupModal("Deselect World##DeselectWorldDialog", ref showDeselectWorldDialog, ImGuiWindowFlags.AlwaysAutoResize)) + { + return; + } + ImGui.TextUnformatted("Select a world to uncheck all characters:"); + ImGuiHelpers.ScaledDummy(10f); + ImGui.SetNextItemWidth(200f); + if (ImGui.BeginCombo("##WorldDeselect", selectedWorldForBulkAction)) + { + foreach (string world2 in availableWorlds) + { + bool isSelected2 = selectedWorldForBulkAction == world2; + if (ImGui.Selectable(world2, isSelected2)) + { + selectedWorldForBulkAction = world2; + } + if (isSelected2) + { + ImGui.SetItemDefaultFocus(); + } + } + ImGui.EndCombo(); + } + ImGuiHelpers.ScaledDummy(10f); + if (ImGui.Button("Cancel", new Vector2(100f, 0f))) + { + showDeselectWorldDialog = false; + } + ImGui.SameLine(); + ImGui.PushStyleColor(ImGuiCol.Button, colorAccent); + if (ImGui.Button("Deselect", new Vector2(100f, 0f))) + { + foreach (string character2 in registeredCharacters) + { + if (character2.EndsWith("@" + selectedWorldForBulkAction)) + { + characterSelection[character2] = false; + } + } + showDeselectWorldDialog = false; + log.Information("[NewMainWindow] Deselected all characters from " + selectedWorldForBulkAction); + } + ImGui.PopStyleColor(); + ImGui.EndPopup(); + } + + private void DrawEventQuestTab() + { + Vector4 eventQuestColor = new Vector4(0.949f, 0.769f, 0.388f, 1f); + Vector4 eventQuestAccent = new Vector4(1f, 0.6f, 0.2f, 1f); + ImGui.PushStyleColor(ImGuiCol.Text, eventQuestColor); + ImGui.TextUnformatted("Event Quest System"); + ImGui.PopStyleColor(); + ImGuiHelpers.ScaledDummy(10f); + List selectedCharacters = (from keyValuePair in characterSelection + where keyValuePair.Value + select keyValuePair.Key).ToList(); + if ((DateTime.Now - lastEventQuestRefresh).TotalSeconds > 5.0 || availableEventQuests.Count == 0) + { + List currentlyActiveEventQuests = plugin.QuestionableIPC.GetCurrentlyActiveEventQuests(); + EventQuestResolver resolver = new EventQuestResolver(dataManager, log); + availableEventQuests.Clear(); + foreach (string questId in currentlyActiveEventQuests) + { + log.Information($"[EventQuest] Questionable returned quest ID: '{questId}' (Length: {questId.Length})"); + string questName = resolver.GetQuestName(questId); + availableEventQuests.Add((questId, questName)); + } + lastEventQuestRefresh = DateTime.Now; + log.Debug($"[EventQuest] Loaded {availableEventQuests.Count} active event quests from Questionable"); + } + ImGui.TextUnformatted("Active Event Quests:"); + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImGui.TextUnformatted("Select an active Event Quest. Prerequisites will be automatically resolved."); + ImU8String text = new ImU8String(54, 1); + text.AppendLiteral("Currently "); + text.AppendFormatted(availableEventQuests.Count); + text.AppendLiteral(" event quest(s) available from Questionable."); + ImGui.TextUnformatted(text); + ImGui.PopStyleColor(); + ImGuiHelpers.ScaledDummy(5f); + ImGui.TextUnformatted("Event Quest:"); + ImGui.SameLine(); + ImGui.SetNextItemWidth(400f); + string currentQuestDisplay = (string.IsNullOrEmpty(selectedEventQuestId) ? "Select Event Quest..." : (availableEventQuests.FirstOrDefault<(string, string)>(((string QuestId, string QuestName) q) => q.QuestId == selectedEventQuestId).Item2 ?? selectedEventQuestId)); + if (ImGui.BeginCombo("##EventQuestCombo", currentQuestDisplay)) + { + foreach (var availableEventQuest in availableEventQuests) + { + string questId2 = availableEventQuest.QuestId; + string questName2 = availableEventQuest.QuestName; + bool isSelected = selectedEventQuestId == questId2; + ImU8String label = new ImU8String(3, 2); + label.AppendFormatted(questName2); + label.AppendLiteral(" ("); + label.AppendFormatted(questId2); + label.AppendLiteral(")"); + if (ImGui.Selectable(label, isSelected)) + { + selectedEventQuestId = questId2; + EventQuestResolver resolver2 = new EventQuestResolver(dataManager, log); + resolvedPrerequisites = resolver2.ResolveEventQuestDependencies(questId2); + log.Information($"[EventQuest] Selected quest {questId2}, resolved {resolvedPrerequisites.Count} prerequisites"); + } + if (isSelected) + { + ImGui.SetItemDefaultFocus(); + } + } + ImGui.EndCombo(); + } + ImGui.SameLine(); + ImGui.PushStyleColor(ImGuiCol.Button, eventQuestColor); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, eventQuestAccent); + if (ImGui.Button("Refresh from Questionable")) + { + List currentlyActiveEventQuests2 = plugin.QuestionableIPC.GetCurrentlyActiveEventQuests(); + EventQuestResolver resolver3 = new EventQuestResolver(dataManager, log); + availableEventQuests.Clear(); + foreach (string questId3 in currentlyActiveEventQuests2) + { + string questName3 = resolver3.GetQuestName(questId3); + availableEventQuests.Add((questId3, questName3)); + } + lastEventQuestRefresh = DateTime.Now; + log.Information($"[EventQuest] Refreshed event quest list from Questionable: {availableEventQuests.Count} quests found"); + } + ImGui.PopStyleColor(2); + ImGuiHelpers.ScaledDummy(10f); + if (!string.IsNullOrEmpty(selectedEventQuestId)) + { + string questName4 = availableEventQuests.FirstOrDefault<(string, string)>(((string QuestId, string QuestName) q) => q.QuestId == selectedEventQuestId).Item2 ?? "Unknown"; + ImGui.PushStyleColor(ImGuiCol.Text, eventQuestColor); + ImU8String text2 = new ImU8String(16, 1); + text2.AppendLiteral("Selected Quest: "); + text2.AppendFormatted(questName4); + ImGui.TextUnformatted(text2); + ImGui.PopStyleColor(); + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImU8String text3 = new ImU8String(10, 1); + text3.AppendLiteral("Quest ID: "); + text3.AppendFormatted(selectedEventQuestId); + ImGui.TextUnformatted(text3); + ImGui.PopStyleColor(); + ImGuiHelpers.ScaledDummy(5f); + } + if (!string.IsNullOrEmpty(selectedEventQuestId) && resolvedPrerequisites.Count > 0) + { + ImGui.Separator(); + ImGuiHelpers.ScaledDummy(5f); + ImGui.PushStyleColor(ImGuiCol.Text, eventQuestAccent); + ImU8String text4 = new ImU8String(17, 1); + text4.AppendLiteral("Prerequisites ("); + text4.AppendFormatted(resolvedPrerequisites.Count); + text4.AppendLiteral("):"); + ImGui.TextUnformatted(text4); + ImGui.PopStyleColor(); + ImGuiHelpers.ScaledDummy(3f); + EventQuestResolver resolver4 = new EventQuestResolver(dataManager, log); + using (ImRaii.IEndObject table = ImRaii.Table("PrerequisitesTable", 2, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg)) + { + if (table.Success) + { + ImGui.TableSetupColumn("Quest Name", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Quest ID", ImGuiTableColumnFlags.WidthFixed, 80f); + ImGui.TableHeadersRow(); + foreach (string prereqId in resolvedPrerequisites) + { + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(resolver4.GetQuestName(prereqId)); + ImGui.TableNextColumn(); + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImGui.TextUnformatted(prereqId); + ImGui.PopStyleColor(); + } + } + } + ImGuiHelpers.ScaledDummy(5f); + } + ImGuiHelpers.ScaledDummy(10f); + ImGui.Separator(); + ImGuiHelpers.ScaledDummy(10f); + ImGui.PushStyleColor(ImGuiCol.Text, eventQuestColor); + ImGui.TextUnformatted("Current Status:"); + ImGui.PopStyleColor(); + ImGuiHelpers.ScaledDummy(5f); + EventQuestState state = eventQuestService.GetCurrentState(); + ImU8String text5 = new ImU8String(7, 0); + text5.AppendLiteral("Phase: "); + ImGui.TextUnformatted(text5); + ImGui.SameLine(); + ImGui.PushStyleColor(ImGuiCol.Text, state.Phase switch + { + EventQuestPhase.Idle => colorSecondary, + EventQuestPhase.QuestActive => eventQuestColor, + EventQuestPhase.Completed => colorPrimary, + EventQuestPhase.Error => colorAccent, + _ => colorSecondary, + }); + ImGui.TextUnformatted(state.Phase.ToString()); + ImGui.PopStyleColor(); + if (!string.IsNullOrEmpty(state.CurrentCharacter)) + { + ImU8String text6 = new ImU8String(19, 0); + text6.AppendLiteral("Current Character: "); + ImGui.TextUnformatted(text6); + ImGui.SameLine(); + ImGui.PushStyleColor(ImGuiCol.Text, eventQuestColor); + ImGui.TextUnformatted(state.CurrentCharacter); + ImGui.PopStyleColor(); + } + if (!string.IsNullOrEmpty(state.EventQuestName)) + { + ImU8String text7 = new ImU8String(13, 0); + text7.AppendLiteral("Event Quest: "); + ImGui.TextUnformatted(text7); + ImGui.SameLine(); + ImGui.PushStyleColor(ImGuiCol.Text, eventQuestColor); + ImU8String text8 = new ImU8String(3, 2); + text8.AppendFormatted(state.EventQuestName); + text8.AppendLiteral(" ("); + text8.AppendFormatted(state.EventQuestId); + text8.AppendLiteral(")"); + ImGui.TextUnformatted(text8); + ImGui.PopStyleColor(); + } + if (state.DependencyQuests.Count > 0) + { + ImU8String text9 = new ImU8String(15, 2); + text9.AppendLiteral("Dependencies: "); + text9.AppendFormatted(state.DependencyIndex + 1); + text9.AppendLiteral("/"); + text9.AppendFormatted(state.DependencyQuests.Count); + ImGui.TextUnformatted(text9); + } + if (!string.IsNullOrEmpty(state.NextCharacter)) + { + ImU8String text10 = new ImU8String(16, 0); + text10.AppendLiteral("Next Character: "); + ImGui.TextUnformatted(text10); + ImGui.SameLine(); + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImGui.TextUnformatted(state.NextCharacter); + ImGui.PopStyleColor(); + } + if (state.Phase == EventQuestPhase.Error && !string.IsNullOrEmpty(state.ErrorMessage)) + { + ImGui.PushStyleColor(ImGuiCol.Text, colorAccent); + ImU8String text11 = new ImU8String(7, 1); + text11.AppendLiteral("Error: "); + text11.AppendFormatted(state.ErrorMessage); + ImGui.TextUnformatted(text11); + ImGui.PopStyleColor(); + } + if (state.SelectedCharacters.Count > 0) + { + ImGuiHelpers.ScaledDummy(5f); + float progress = (float)state.CompletedCharacters.Count / (float)state.SelectedCharacters.Count; + ImDrawListPtr drawList = ImGui.GetWindowDrawList(); + Vector2 cursorPos = ImGui.GetCursorScreenPos(); + float barWidth = ImGui.GetContentRegionAvail().X; + float barHeight = 25f; + uint barBg = ImGui.ColorConvertFloat4ToU32(new Vector4(0.2f, 0.2f, 0.2f, 0.8f)); + drawList.AddRectFilled(cursorPos, cursorPos + new Vector2(barWidth, barHeight), barBg, 4f); + uint barFg = ImGui.ColorConvertFloat4ToU32(new Vector4(eventQuestColor.X, eventQuestColor.Y, eventQuestColor.Z, 0.9f)); + drawList.AddRectFilled(cursorPos, cursorPos + new Vector2(barWidth * progress, barHeight), barFg, 4f); + string progressText = $"{state.CompletedCharacters.Count}/{state.SelectedCharacters.Count} completed"; + Vector2 textSize = ImGui.CalcTextSize(progressText); + drawList.AddText(cursorPos + new Vector2(barWidth / 2f - textSize.X / 2f, 4f), ImGui.ColorConvertFloat4ToU32(new Vector4(1f, 1f, 1f, 1f)), progressText); + ImGui.Dummy(new Vector2(barWidth, barHeight)); + } + ImGuiHelpers.ScaledDummy(10f); + if (state.SelectedCharacters.Count > 0) + { + ImGui.Separator(); + ImGuiHelpers.ScaledDummy(5f); + ImGui.PushStyleColor(ImGuiCol.Button, (eventQuestViewMode == 0) ? eventQuestColor : colorDarkBg); + ImU8String label2 = new ImU8String(12, 1); + label2.AppendLiteral("Remaining ("); + label2.AppendFormatted(state.RemainingCharacters.Count); + label2.AppendLiteral(")"); + if (ImGui.Button(label2)) + { + eventQuestViewMode = 0; + } + ImGui.PopStyleColor(); + ImGui.SameLine(); + ImGui.PushStyleColor(ImGuiCol.Button, (eventQuestViewMode == 1) ? eventQuestColor : colorDarkBg); + ImU8String label3 = new ImU8String(12, 1); + label3.AppendLiteral("Completed ("); + label3.AppendFormatted(state.CompletedCharacters.Count); + label3.AppendLiteral(")"); + if (ImGui.Button(label3)) + { + eventQuestViewMode = 1; + } + ImGui.PopStyleColor(); + ImGuiHelpers.ScaledDummy(5f); + List displayList = ((eventQuestViewMode == 0) ? state.RemainingCharacters : state.CompletedCharacters); + if (displayList.Count > 0) + { + using ImRaii.IEndObject child = ImRaii.Child("CharacterList", new Vector2(0f, 150f), border: true); + if (child.Success) + { + foreach (string character in displayList) + { + string[] parts = character.Split('@'); + string charName = ((parts.Length != 0) ? parts[0] : character); + string world = ((parts.Length > 1) ? parts[1] : "Unknown"); + ImGui.PushStyleColor(ImGuiCol.Text, (eventQuestViewMode == 0) ? colorSecondary : eventQuestColor); + ImU8String text12 = new ImU8String(2, 1); + text12.AppendLiteral("• "); + text12.AppendFormatted(charName); + ImGui.TextUnformatted(text12); + ImGui.PopStyleColor(); + ImGui.SameLine(); + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImU8String text13 = new ImU8String(2, 1); + text13.AppendLiteral("@ "); + text13.AppendFormatted(world); + ImGui.TextUnformatted(text13); + ImGui.PopStyleColor(); + } + } + } + else + { + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImGui.TextUnformatted((eventQuestViewMode == 0) ? "No remaining characters" : "No completed characters"); + ImGui.PopStyleColor(); + } + ImGuiHelpers.ScaledDummy(10f); + } + ImGui.Separator(); + ImGuiHelpers.ScaledDummy(10f); + if (selectedCharacters.Count == 0) + { + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImGui.TextUnformatted("Select characters in the Characters tab to start Event Quest rotation"); + ImGui.PopStyleColor(); + } + else if (string.IsNullOrEmpty(selectedEventQuestId)) + { + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImGui.TextUnformatted("Select an Event Quest from the dropdown above to start"); + ImGui.PopStyleColor(); + } + else if (!eventQuestService.IsRotationActive) + { + ImGui.PushStyleColor(ImGuiCol.Button, eventQuestColor); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, eventQuestAccent); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, eventQuestAccent); + if (ImGui.Button("▶ Start Event Quest Rotation", new Vector2(250f, 35f))) + { + log.Information("[EventQuest] Start button clicked!"); + log.Information("[EventQuest] Event Quest ID: " + selectedEventQuestId); + log.Information($"[EventQuest] Selected characters: {selectedCharacters.Count}"); + log.Information($"[EventQuest] Prerequisites: {resolvedPrerequisites.Count}"); + if (eventQuestService.StartEventQuestRotation(selectedEventQuestId, selectedCharacters)) + { + log.Information("[EventQuest] Rotation started successfully!"); + } + else + { + log.Error("[EventQuest] Failed to start rotation"); + } + } + ImGui.PopStyleColor(3); + } + else + { + ImGui.PushStyleColor(ImGuiCol.Button, colorAccent); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, colorAccent); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, colorAccent); + if (ImGui.Button("⏹ Abort Rotation", new Vector2(200f, 30f))) + { + eventQuestService.AbortRotation(); + log.Information("[EventQuest] Rotation aborted"); + } + ImGui.PopStyleColor(3); + } + ImGuiHelpers.ScaledDummy(5f); + ImGui.PushStyleColor(ImGuiCol.Button, colorPrimary); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, colorSecondary); + if (ImGui.Button("Refresh Characters")) + { + RefreshCharacterList(); + log.Information("[EventQuest] Character list refreshed"); + } + ImGui.PopStyleColor(2); + ImGuiHelpers.ScaledDummy(15f); + ImGui.Separator(); + ImGuiHelpers.ScaledDummy(10f); + ImGui.PushStyleColor(ImGuiCol.Text, eventQuestColor); + ImGui.TextUnformatted("Completion Data:"); + ImGui.PopStyleColor(); + ImGuiHelpers.ScaledDummy(5f); + Dictionary> completionData = plugin.Configuration.EventQuestCompletionByCharacter; + if (completionData.Count == 0) + { + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImGui.TextUnformatted("No completion data recorded yet."); + ImGui.PopStyleColor(); + } + else + { + using ImRaii.IEndObject table2 = ImRaii.Table("CompletionDataTable", 3, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY, new Vector2(0f, 150f)); + if (table2.Success) + { + ImGui.TableSetupColumn("Event Quest ID", ImGuiTableColumnFlags.WidthFixed, 120f); + ImGui.TableSetupColumn("Completed", ImGuiTableColumnFlags.WidthFixed, 100f); + ImGui.TableSetupColumn("Actions", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupScrollFreeze(0, 1); + ImGui.TableHeadersRow(); + EventQuestResolver resolver5 = new EventQuestResolver(dataManager, log); + foreach (KeyValuePair> kvp in completionData) + { + string questId4 = kvp.Key; + List completedChars = kvp.Value; + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(resolver5.GetQuestName(questId4)); + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImU8String text14 = new ImU8String(4, 1); + text14.AppendLiteral("ID: "); + text14.AppendFormatted(questId4); + ImGui.TextUnformatted(text14); + ImGui.PopStyleColor(); + ImGui.TableNextColumn(); + ImGui.PushStyleColor(ImGuiCol.Text, eventQuestColor); + ImU8String text15 = new ImU8String(0, 1); + text15.AppendFormatted(completedChars.Count); + ImGui.TextUnformatted(text15); + ImGui.PopStyleColor(); + ImGui.TableNextColumn(); + using (ImRaii.PushId(questId4)) + { + ImGui.PushStyleColor(ImGuiCol.Button, colorAccent); + if (ImGui.Button("Clear")) + { + completionData.Remove(questId4); + plugin.Configuration.Save(); + log.Information("[EventQuest] Cleared completion data for quest " + questId4); + } + ImGui.PopStyleColor(); + } + } + } + } + ImGuiHelpers.ScaledDummy(10f); + if (completionData.Count > 0) + { + ImGui.PushStyleColor(ImGuiCol.Button, colorAccent); + if (ImGui.Button("\ud83d\uddd1 Clear All Completion Data")) + { + completionData.Clear(); + plugin.Configuration.Save(); + log.Information("[EventQuest] Cleared all completion data"); + } + ImGui.PopStyleColor(); + } + } + + private void DrawWarningTab() + { + TryWarningMenuQuestionableCheck(); + ImGuiHelpers.ScaledDummy(50f); + float windowWidth = ImGui.GetContentRegionAvail().X; + string text2 = "Please ensure you are using Questionable from WigglyMuffin!"; + ImGui.SetWindowFontScale(2f); + Vector2 textSize = ImGui.CalcTextSize("Incorrect Questionable Version."); + Vector2 textSize2 = ImGui.CalcTextSize(text2); + ImGui.SetCursorPosX((windowWidth - textSize.X) * 0.5f); + ImGui.PushStyleColor(ImGuiCol.Text, colorAccent); + ImGui.TextUnformatted("Incorrect Questionable Version."); + ImGui.PopStyleColor(); + ImGuiHelpers.ScaledDummy(10f); + float flashAlpha = (MathF.Sin((float)ImGui.GetTime() * 5f) + 1f) * 0.5f; + Vector4 flashColor = new Vector4(1f, 0f, 0f, flashAlpha); + ImGui.SetCursorPosX((windowWidth - textSize2.X) * 0.5f); + ImGui.TextColored(in flashColor, text2); + ImGuiHelpers.ScaledDummy(30f); + ImGui.SetWindowFontScale(1f); + if (warningMenuRetryAttempts < 4) + { + double elapsedSeconds = (DateTime.Now - warningMenuRetryStartTime).TotalSeconds; + int nextRetryIn = ((warningMenuRetryAttempts < warningMenuRetryDelaysSeconds.Length) ? (warningMenuRetryDelaysSeconds[warningMenuRetryAttempts] - (int)elapsedSeconds) : 0); + ImGui.PushStyleColor(ImGuiCol.Text, colorPrimary); + if (nextRetryIn > 0) + { + string obj = $"Checking for Questionable... (Retry {warningMenuRetryAttempts}/{4} in {nextRetryIn}s)"; + ImGui.SetCursorPosX((windowWidth - ImGui.CalcTextSize(obj).X) * 0.5f); + ImGui.TextUnformatted(obj); + } + else + { + string obj2 = $"Checking for Questionable... (Attempt {warningMenuRetryAttempts + 1}/{4})"; + ImGui.SetCursorPosX((windowWidth - ImGui.CalcTextSize(obj2).X) * 0.5f); + ImGui.TextUnformatted(obj2); + } + ImGui.PopStyleColor(); + ImGui.SetCursorPosX((windowWidth - ImGui.CalcTextSize("Waiting for Questionable IPC to become available...").X) * 0.5f); + ImGui.PushStyleColor(ImGuiCol.Text, colorSecondary); + ImGui.TextUnformatted("Waiting for Questionable IPC to become available..."); + ImGui.PopStyleColor(); + return; + } + float buttonWidth = 120f; + float buttonHeight = 40f; + ImGui.SetCursorPosX((windowWidth - buttonWidth) * 0.5f); + ImGui.PushStyleColor(ImGuiCol.Button, colorPrimary); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, colorSecondary); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, colorSecondary); + if (ImGui.Button("Refresh", new Vector2(buttonWidth, buttonHeight))) + { + if (plugin.QuestionableIPC.TryEnsureAvailableSilent() && plugin.QuestionableIPC.ValidateFeatureCompatibility()) + { + selectedTab = 5; + } + else + { + warningMenuRetryAttempts = 0; + warningMenuRetryStartTime = DateTime.MinValue; + warningMenuRetryCycleComplete = false; + } + } + ImGui.PopStyleColor(3); + } +} diff --git a/QuestionableCompanion/QuestionableCompanion/AlliedSocietySettings.cs b/QuestionableCompanion/QuestionableCompanion/AlliedSocietySettings.cs new file mode 100644 index 0000000..0e5d643 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion/AlliedSocietySettings.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using QuestionableCompanion.Models; + +namespace QuestionableCompanion; + +[Serializable] +public class AlliedSocietySettings +{ + public AlliedSocietyConfiguration RotationConfig { get; set; } = new AlliedSocietyConfiguration(); + + public Dictionary CharacterStatuses { get; set; } = new Dictionary(); + + public Dictionary> CharacterProgress { get; set; } = new Dictionary>(); + + public DateTime LastResetDate { get; set; } = DateTime.MinValue; +} diff --git a/QuestionableCompanion/QuestionableCompanion/Configuration.cs b/QuestionableCompanion/QuestionableCompanion/Configuration.cs new file mode 100644 index 0000000..4140381 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion/Configuration.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using Dalamud.Configuration; +using QuestionableCompanion.Models; + +namespace QuestionableCompanion; + +[Serializable] +public class Configuration : IPluginConfiguration +{ + public int Version { get; set; } = 1; + + public bool IsConfigWindowMovable { get; set; } = true; + + public bool ShowDebugLogs { get; set; } + + public List Profiles { get; set; } = new List(); + + public string ActiveProfileName { get; set; } = string.Empty; + + public AlliedSocietySettings AlliedSociety { get; set; } = new AlliedSocietySettings(); + + public bool AutoStartOnLogin { get; set; } + + public bool EnableDryRun { get; set; } + + public int MaxRetryAttempts { get; set; } = 3; + + public int CharacterSwitchDelay { get; set; } = 5; + + public int MaxLogEntries { get; set; } = 100; + + public bool LogToFile { get; set; } + + public ExecutionState LastExecutionState { get; set; } = new ExecutionState(); + + public bool RestoreStateOnLoad { get; set; } + + public List StopPoints { get; set; } = new List(); + + public RotationState LastRotationState { get; set; } = new RotationState(); + + public List SelectedCharactersForRotation { get; set; } = new List(); + + public Dictionary> QuestCompletionByCharacter { get; set; } = new Dictionary>(); + + public Dictionary> EventQuestCompletionByCharacter { get; set; } = new Dictionary>(); + + public string CurrentEventQuestId { get; set; } = string.Empty; + + public List SelectedCharactersForEventQuest { get; set; } = new List(); + + public bool RunEventQuestsOnARPostProcess { get; set; } + + public List EventQuestsToRunOnPostProcess { get; set; } = new List(); + + public int EventQuestPostProcessTimeoutMinutes { get; set; } = 30; + + public bool EnableSubmarineCheck { get; set; } + + public int SubmarineCheckInterval { get; set; } = 90; + + public int SubmarineReloginCooldown { get; set; } = 120; + + public int SubmarineWaitTime { get; set; } = 30; + + public bool EnableAutoDutyUnsynced { get; set; } + + public int AutoDutyPartySize { get; set; } = 2; + + public int AutoDutyMaxWaitForParty { get; set; } = 30; + + public int AutoDutyReInviteInterval { get; set; } = 10; + + public bool EnableQSTReloadTracking { get; set; } + + public int MaxQSTReloadsBeforeSwitch { get; set; } = 5; + + public bool EnableDCTravel { get; set; } + + public string DCTravelWorld { get; set; } = ""; + + public bool EnableMovementMonitor { get; set; } + + public int MovementCheckInterval { get; set; } = 5; + + public int MovementStuckThreshold { get; set; } = 30; + + public bool EnableCombatHandling { get; set; } + + public int CombatHPThreshold { get; set; } = 50; + + public bool EnableDeathHandling { get; set; } + + public int DeathRespawnDelay { get; set; } = 5; + + public bool LogToDalamud { get; set; } + + public MSQDisplayMode MSQDisplayMode { get; set; } = MSQDisplayMode.Overall; + + public bool ShowPatchVersion { get; set; } + + public string DCTravelDataCenter { get; set; } = ""; + + public string DCTravelTargetWorld { get; set; } = ""; + + public bool EnableDCTravelFeature { get; set; } + + public bool EnableMultiModeAfterRotation { get; set; } + + public bool ReturnToHomeworldOnStopQuest { get; set; } + + public bool IsHighLevelHelper { get; set; } + + public bool IsQuester { get; set; } + + public List HighLevelHelpers { get; set; } = new List(); + + public bool ChauffeurModeEnabled { get; set; } + + public float ChauffeurDistanceThreshold { get; set; } = 100f; + + public float ChauffeurStopDistance { get; set; } = 5f; + + public uint ChauffeurMountId { get; set; } + + public string PreferredHelper { get; set; } = ""; + + public string AssignedQuester { get; set; } = ""; + + public HelperStatus CurrentHelperStatus { get; set; } + + public bool EnableHelperFollowing { get; set; } + + public float HelperFollowDistance { get; set; } = 100f; + + public int HelperFollowCheckInterval { get; set; } = 5; + + public string AssignedQuesterForFollowing { get; set; } = ""; + + public string AssignedHelperForFollowing { get; set; } = ""; + + public bool EnableSafeWaitBeforeCharacterSwitch { get; set; } + + public bool EnableSafeWaitAfterCharacterSwitch { get; set; } + + public bool EnableQuestPreCheck { get; set; } + + public List? QuestPreCheckRange { get; set; } + + public string SelectedDatacenter { get; set; } = "NA"; + + public Dictionary> WorldsByDatacenter { get; set; } = new Dictionary> + { + { + "NA", + new List + { + "Adamantoise", "Cactuar", "Faerie", "Gilgamesh", "Jenova", "Midgardsormr", "Sargatanas", "Siren", "Behemoth", "Excalibur", + "Exodus", "Famfrit", "Hyperion", "Lamia", "Leviathan", "Ultros", "Balmung", "Brynhildr", "Coeurl", "Diabolos", + "Goblin", "Malboro", "Mateus", "Zalera", "Halicarnassus", "Maduin", "Marilith", "Seraph" + } + }, + { + "EU", + new List + { + "Cerberus", "Louisoix", "Moogle", "Omega", "Phantom", "Ragnarok", "Sagittarius", "Spriggan", "Alpha", "Lich", + "Odin", "Phoenix", "Raiden", "Shiva", "Twintania", "Zodiark" + } + }, + { + "JP", + new List + { + "Aegis", "Atomos", "Carbuncle", "Garuda", "Gungnir", "Kujata", "Tonberry", "Typhon", "Alexander", "Bahamut", + "Durandal", "Fenrir", "Ifrit", "Ridill", "Tiamat", "Ultima", "Anima", "Asura", "Chocobo", "Hades", + "Ixion", "Masamune", "Pandaemonium", "Titan", "Gaia", "Belias", "Mandragora", "Ramuh", "Shinryu", "Unicorn", + "Valefor", "Yojimbo", "Zeromus" + } + }, + { + "OCE", + new List { "Bismarck", "Ravana", "Sephirot", "Sophia", "Zurvan" } + } + }; + + public void Save() + { + Plugin.PluginInterface.SavePluginConfig(this); + } + + public QuestProfile? GetActiveProfile() + { + return Profiles.Find((QuestProfile p) => p.Name == ActiveProfileName); + } + + public void EnsureDefaultProfile() + { + if (Profiles.Count == 0) + { + QuestProfile defaultProfile = new QuestProfile + { + Name = "Default Profile", + IsActive = true + }; + Profiles.Add(defaultProfile); + ActiveProfileName = defaultProfile.Name; + } + } +} diff --git a/QuestionableCompanion/QuestionableCompanion/HelperStatus.cs b/QuestionableCompanion/QuestionableCompanion/HelperStatus.cs new file mode 100644 index 0000000..bce05ae --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion/HelperStatus.cs @@ -0,0 +1,8 @@ +namespace QuestionableCompanion; + +public enum HelperStatus +{ + Available, + Transporting, + InDungeon +} diff --git a/QuestionableCompanion/QuestionableCompanion/HighLevelHelperConfig.cs b/QuestionableCompanion/QuestionableCompanion/HighLevelHelperConfig.cs new file mode 100644 index 0000000..d21f186 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion/HighLevelHelperConfig.cs @@ -0,0 +1,13 @@ +using System; + +namespace QuestionableCompanion; + +[Serializable] +public class HighLevelHelperConfig +{ + public string CharacterName { get; set; } = string.Empty; + + public ushort WorldId { get; set; } + + public string WorldName { get; set; } = string.Empty; +} diff --git a/QuestionableCompanion/QuestionableCompanion/MSQDisplayMode.cs b/QuestionableCompanion/QuestionableCompanion/MSQDisplayMode.cs new file mode 100644 index 0000000..f302c92 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion/MSQDisplayMode.cs @@ -0,0 +1,8 @@ +namespace QuestionableCompanion; + +public enum MSQDisplayMode +{ + CurrentExpansion, + Overall, + ExpansionBreakdown +} diff --git a/QuestionableCompanion/QuestionableCompanion/Plugin.cs b/QuestionableCompanion/QuestionableCompanion/Plugin.cs new file mode 100644 index 0000000..0ff8279 --- /dev/null +++ b/QuestionableCompanion/QuestionableCompanion/Plugin.cs @@ -0,0 +1,1020 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.Command; +using Dalamud.Interface.Windowing; +using Dalamud.IoC; +using Dalamud.Plugin; +using Dalamud.Plugin.Services; +using Lumina.Excel; +using Lumina.Excel.Sheets; +using QuestionableCompanion.Models; +using QuestionableCompanion.Services; +using QuestionableCompanion.Windows; + +namespace QuestionableCompanion; + +public sealed class Plugin : IDalamudPlugin, IDisposable +{ + private const string CommandName = "/qstcomp"; + + private static Plugin? instance; + + public readonly WindowSystem WindowSystem = new WindowSystem("QuestionableCompanion"); + + private DateTime lastChauffeurCheck = DateTime.MinValue; + + [PluginService] + internal static IDalamudPluginInterface PluginInterface { get; private set; } + + [PluginService] + internal static ITextureProvider TextureProvider { get; private set; } + + [PluginService] + internal static ICommandManager CommandManager { get; private set; } + + [PluginService] + internal static IClientState ClientState { get; private set; } + + [PluginService] + internal static IDataManager DataManager { get; private set; } + + [PluginService] + internal static IPluginLog Log { get; private set; } + + [PluginService] + internal static IFramework Framework { get; private set; } + + [PluginService] + internal static IGameGui GameGui { get; private set; } + + [PluginService] + internal static ICondition Condition { get; private set; } + + [PluginService] + internal static IPartyList PartyList { get; private set; } + + [PluginService] + internal static IGameInteropProvider GameInterop { get; private set; } + + [PluginService] + internal static IObjectTable ObjectTable { get; private set; } + + [PluginService] + internal static IChatGui ChatGui { get; private set; } + + public static Plugin? Instance => instance; + + public Configuration Configuration { get; init; } + + public QuestionableIPC QuestionableIPC { get; init; } + + private AutoRetainerIPC AutoRetainerIPC { get; init; } + + public LifestreamIPC LifestreamIPC { get; init; } + + private PartyInviteService PartyInviteService { get; init; } + + private MultiClientIPC MultiClientIPC { get; init; } + + private CrossProcessIPC CrossProcessIPC { get; init; } + + private PartyInviteAutoAccept PartyInviteAutoAccept { get; init; } + + private HelperManager HelperManager { get; init; } + + private QuestDetectionService QuestDetection { get; init; } + + private EventQuestExecutionService EventQuestService { get; init; } + + private QuestTrackingService QuestTrackingService { get; init; } + + private QuestRotationExecutionService QuestRotationService { get; init; } + + private SubmarineManager SubmarineManager { get; init; } + + private CharacterSafeWaitService SafeWaitService { get; init; } + + private QuestPreCheckService PreCheckService { get; init; } + + private DCTravelService DCTravelService { get; init; } + + private MovementMonitorService MovementMonitor { get; init; } + + private CombatDutyDetectionService CombatDutyDetection { get; init; } + + private DeathHandlerService DeathHandler { get; init; } + + private DungeonAutomationService DungeonAutomation { get; init; } + + private StepsOfFaithHandler StepsOfFaithHandler { get; init; } + + private MSQProgressionService MSQProgressionService { get; init; } + + private MemoryHelper MemoryHelper { get; init; } + + private ChauffeurModeService ChauffeurMode { get; init; } + + private ARPostProcessEventQuestService ARPostProcessService { get; init; } + + private AlliedSocietyDatabase AlliedSocietyDatabase { get; init; } + + private AlliedSocietyQuestSelector AlliedSocietyQuestSelector { get; init; } + + private AlliedSocietyRotationService AlliedSocietyRotationService { get; init; } + + private AlliedSocietyPriorityWindow AlliedSocietyPriorityWindow { get; init; } + + private ConfigWindow ConfigWindow { get; init; } + + private NewMainWindow NewMainWindow { get; init; } + + private DebugWindow DebugWindow { get; init; } + + public Plugin() + { + instance = this; + try + { + Configuration = (PluginInterface.GetPluginConfig() as Configuration) ?? new Configuration(); + Configuration.EnsureDefaultProfile(); + Log.Debug("[Plugin] Initializing services..."); + QuestionableIPC = new QuestionableIPC(PluginInterface, Log); + AutoRetainerIPC = new AutoRetainerIPC(PluginInterface, Log, ClientState, CommandManager, Framework); + LifestreamIPC = new LifestreamIPC(Log, PluginInterface); + PartyInviteService = new PartyInviteService(Log, ObjectTable, ClientState); + MultiClientIPC = new MultiClientIPC(PluginInterface, Log); + CrossProcessIPC = new CrossProcessIPC(Log, Framework, Configuration); + PartyInviteAutoAccept = new PartyInviteAutoAccept(Log, Framework, GameGui, PartyList, Configuration); + QuestDetection = new QuestDetectionService(Framework, Log, ClientState); + EventQuestService = new EventQuestExecutionService(AutoRetainerIPC, QuestionableIPC, Log, Framework, CommandManager, Condition, Configuration, DataManager, delegate + { + SaveEventQuestCompletionData(); + }); + QuestTrackingService = new QuestTrackingService(Log); + Log.Debug("[Plugin] Initializing SubmarineManager..."); + SubmarineManager = new SubmarineManager(Log, AutoRetainerIPC, Configuration, CommandManager, Framework); + Log.Debug("[Plugin] Initializing QuestRotationService..."); + QuestRotationService = new QuestRotationExecutionService(AutoRetainerIPC, QuestTrackingService, SubmarineManager, QuestionableIPC, Configuration, Log, Framework, CommandManager, Condition, ClientState, delegate + { + SaveQuestCompletionData(); + }); + Log.Debug("[Plugin] Initializing SafeWaitService..."); + SafeWaitService = new CharacterSafeWaitService(ClientState, Log, Framework, Condition, GameGui); + Log.Debug("[Plugin] Initializing QuestPreCheckService..."); + PreCheckService = new QuestPreCheckService(Log, ClientState, Configuration, AutoRetainerIPC); + Log.Debug("[Plugin] Initializing DCTravelService..."); + DCTravelService = new DCTravelService(Log, Configuration, LifestreamIPC, QuestionableIPC, SafeWaitService, ClientState, CommandManager, Framework); + Log.Debug("[Plugin] Initializing MovementMonitor..."); + MovementMonitor = new MovementMonitorService(ClientState, Log, CommandManager, Framework, Configuration); + Log.Debug("[Plugin] Movement monitor initialized (will start with rotation)"); + Log.Debug("[Plugin] Initializing CombatDutyDetection..."); + CombatDutyDetection = new CombatDutyDetectionService(Condition, Log, ClientState, CommandManager, Framework, Configuration); + Log.Debug("[Plugin] Initializing DeathHandler..."); + DeathHandler = new DeathHandlerService(Condition, Log, ClientState, CommandManager, Framework, Configuration, GameGui, DataManager); + Log.Debug("[Plugin] Initializing MemoryHelper..."); + MemoryHelper = new MemoryHelper(Log, GameInterop); + Log.Debug("[Plugin] Initializing HelperManager..."); + HelperManager = new HelperManager(Configuration, Log, CommandManager, Condition, ClientState, Framework, PartyInviteService, MultiClientIPC, CrossProcessIPC, PartyInviteAutoAccept, MemoryHelper); + Log.Debug("[Plugin] Initializing DungeonAutomation..."); + DungeonAutomation = new DungeonAutomationService(Condition, Log, ClientState, CommandManager, Framework, GameGui, Configuration, HelperManager, MemoryHelper, QuestionableIPC); + Log.Debug("[Plugin] Initializing StepsOfFaithHandler..."); + StepsOfFaithHandler = new StepsOfFaithHandler(Condition, Log, ClientState, CommandManager, Framework, Configuration); + Log.Debug("[Plugin] Initializing MSQProgressionService..."); + MSQProgressionService = new MSQProgressionService(DataManager, Log, QuestDetection, ClientState, Framework); + Log.Debug("[Plugin] Initializing ChauffeurMode..."); + ChauffeurMode = new ChauffeurModeService(Configuration, Log, ClientState, Condition, Framework, CommandManager, DataManager, PartyList, ObjectTable, QuestionableIPC, CrossProcessIPC, PartyInviteService, PartyInviteAutoAccept, PluginInterface, MemoryHelper, MovementMonitor); + MovementMonitor.SetChauffeurMode(ChauffeurMode); + Log.Debug("[Plugin] Initializing AR Post Process Event Quest Service..."); + EventQuestResolver eventQuestResolver = new EventQuestResolver(DataManager, Log); + ARPostProcessService = new ARPostProcessEventQuestService(PluginInterface, QuestionableIPC, eventQuestResolver, Configuration, Log, Framework, CommandManager, LifestreamIPC); + Log.Debug("[Plugin] Initializing Allied Society Services..."); + AlliedSocietyDatabase = new AlliedSocietyDatabase(Configuration, Log); + AlliedSocietyQuestSelector = new AlliedSocietyQuestSelector(QuestionableIPC, Log); + AlliedSocietyRotationService = new AlliedSocietyRotationService(QuestionableIPC, AlliedSocietyDatabase, AlliedSocietyQuestSelector, AutoRetainerIPC, Configuration, Log, Framework, CommandManager, Condition, ClientState); + AlliedSocietyPriorityWindow = new AlliedSocietyPriorityWindow(Configuration, AlliedSocietyDatabase); + MultiClientIPC.OnChatMessageReceived += OnMultiClientChatReceived; + CrossProcessIPC.OnChatMessageReceived += OnMultiClientChatReceived; + CrossProcessIPC.OnCommandReceived += OnCommandReceived; + QuestRotationService.SetDCTravelService(DCTravelService); + QuestRotationService.SetSafeWaitService(SafeWaitService); + QuestRotationService.SetPreCheckService(PreCheckService); + QuestRotationService.SetMovementMonitor(MovementMonitor); + QuestRotationService.SetCombatDutyDetection(CombatDutyDetection); + QuestRotationService.SetDeathHandler(DeathHandler); + QuestRotationService.SetDungeonAutomation(DungeonAutomation); + QuestRotationService.SetStepsOfFaithHandler(StepsOfFaithHandler); + Log.Debug("[Plugin] Initializing DataCenterService..."); + DataCenterService dataCenterService = new DataCenterService(DataManager, Log); + Log.Debug($"[Plugin] Loaded {Configuration.StopPoints?.Count ?? 0} stop points from config"); + if (Configuration.QuestCompletionByCharacter != null) + { + QuestRotationService.LoadQuestCompletionData(Configuration.QuestCompletionByCharacter); + } + if (Configuration.EventQuestCompletionByCharacter != null) + { + EventQuestService.LoadEventQuestCompletionData(Configuration.EventQuestCompletionByCharacter); + } + Log.Debug("[Plugin] Initializing windows..."); + ConfigWindow = new ConfigWindow(this); + NewMainWindow = new NewMainWindow(this, AutoRetainerIPC, QuestTrackingService, QuestRotationService, EventQuestService, AlliedSocietyRotationService, AlliedSocietyPriorityWindow, dataCenterService, MSQProgressionService, Log, PluginInterface.UiBuilder, DataManager); + DebugWindow = new DebugWindow(this, CombatDutyDetection, DeathHandler, DungeonAutomation); + WindowSystem.AddWindow(ConfigWindow); + WindowSystem.AddWindow(NewMainWindow); + WindowSystem.AddWindow(DebugWindow); + WindowSystem.AddWindow(AlliedSocietyPriorityWindow); + CommandManager.AddHandler("/qstcomp", new CommandInfo(OnCommand) + { + HelpMessage = "Open the Quest Sequence Manager" + }); + CommandManager.AddHandler("/qsthelper", new CommandInfo(OnHelperCommand) + { + HelpMessage = "Helper commands: /qsthelper reset - Reset helper status to Available" + }); + PluginInterface.UiBuilder.Draw += WindowSystem.Draw; + PluginInterface.UiBuilder.OpenConfigUi += ToggleConfigUi; + PluginInterface.UiBuilder.OpenMainUi += ToggleMainUi; + Log.Information("[QuestionableCompanion] Plugin loaded successfully"); + Log.Information("[QuestionableCompanion] IPC services initialized with lazy-loading (will connect when other plugins are ready)"); + Framework.RunOnFrameworkThread(delegate + { + HelperManager.AnnounceIfHelper(); + }); + Framework.Update += OnFrameworkUpdate; + } + catch (Exception ex) + { + Log.Error("[Plugin] Failed to initialize: " + ex.Message); + Log.Error("[Plugin] Stack trace: " + ex.StackTrace); + throw; + } + } + + private void OnFrameworkUpdate(IFramework framework) + { + try + { + if (QuestRotationService != null && QuestRotationService.IsRotationActive && (DateTime.Now - lastChauffeurCheck).TotalSeconds >= 5.0) + { + lastChauffeurCheck = DateTime.Now; + ChauffeurMode?.CheckWaitTerritoryTask(); + if (Configuration.ChauffeurModeEnabled && Configuration.IsQuester) + { + ChauffeurMode?.CheckTaskDistance(); + } + } + } + catch (Exception ex) + { + Log.Error("[Plugin] Framework update error: " + ex.Message); + } + } + + private void SaveQuestCompletionData() + { + if (QuestRotationService != null) + { + Configuration.QuestCompletionByCharacter = QuestRotationService.GetQuestCompletionData(); + Configuration.Save(); + Log.Debug("[Plugin] Quest completion data saved to config"); + } + } + + private void SaveEventQuestCompletionData() + { + if (EventQuestService != null) + { + Configuration.EventQuestCompletionByCharacter = EventQuestService.GetEventQuestCompletionData(); + Configuration.Save(); + Log.Debug("[Plugin] Event quest completion data saved to config"); + } + } + + private void OnMultiClientChatReceived(string message) + { + Log.Information("========================================"); + Log.Information("[MULTI-CLIENT] Message received from other client:"); + Log.Information("[MULTI-CLIENT] " + message); + Log.Information("========================================"); + try + { + CommandManager.ProcessCommand("/echo [Multi-Client] " + message); + } + catch (Exception ex) + { + Log.Error("[MULTI-CLIENT] Failed to send to chat: " + ex.Message); + } + } + + private void OnCommandReceived(string command) + { + if (!Configuration.IsHighLevelHelper) + { + Log.Debug("[CHAUFFEUR] Not a helper, ignoring command from other client"); + return; + } + Log.Information("========================================"); + Log.Information("[CHAUFFEUR] Command received from other client:"); + Log.Information("[CHAUFFEUR] " + command); + Log.Information("========================================"); + Framework.RunOnFrameworkThread(delegate + { + try + { + Log.Information("[CHAUFFEUR] Executing: " + command); + CommandManager.ProcessCommand(command); + Log.Information("[CHAUFFEUR] Command executed successfully"); + } + catch (Exception ex) + { + Log.Error("[CHAUFFEUR] Failed to execute command: " + ex.Message); + } + }); + } + + public void Dispose() + { + instance = null; + try + { + try + { + Configuration.StopPoints = QuestRotationService?.GetAllStopPoints() ?? new List(); + Configuration.QuestCompletionByCharacter = QuestRotationService?.GetQuestCompletionData() ?? new Dictionary>(); + Configuration.Save(); + Log.Debug("[Plugin] Configuration saved"); + } + catch (Exception ex) + { + Log.Error("[Plugin] Failed to save configuration: " + ex.Message); + } + try + { + if (Framework != null) + { + Framework.Update -= OnFrameworkUpdate; + } + if (MultiClientIPC != null) + { + MultiClientIPC.OnChatMessageReceived -= OnMultiClientChatReceived; + } + if (CrossProcessIPC != null) + { + CrossProcessIPC.OnChatMessageReceived -= OnMultiClientChatReceived; + CrossProcessIPC.OnCommandReceived -= OnCommandReceived; + } + Log.Debug("[Plugin] Events unsubscribed"); + } + catch (Exception ex2) + { + Log.Error("[Plugin] Failed to unsubscribe events: " + ex2.Message); + } + try + { + ARPostProcessService?.Dispose(); + ChauffeurMode?.Dispose(); + StepsOfFaithHandler?.Dispose(); + DungeonAutomation?.Dispose(); + DeathHandler?.Dispose(); + CombatDutyDetection?.Dispose(); + MovementMonitor?.Dispose(); + DCTravelService?.Dispose(); + PreCheckService?.Dispose(); + QuestRotationService?.Dispose(); + EventQuestService?.Dispose(); + SubmarineManager?.Dispose(); + QuestTrackingService?.Dispose(); + QuestDetection?.Dispose(); + HelperManager?.Dispose(); + PartyInviteAutoAccept?.Dispose(); + CrossProcessIPC?.Dispose(); + MultiClientIPC?.Dispose(); + LifestreamIPC?.Dispose(); + AutoRetainerIPC?.Dispose(); + QuestionableIPC?.Dispose(); + Log.Debug("[Plugin] Services disposed"); + } + catch (Exception ex3) + { + Log.Error("[Plugin] Failed to dispose services: " + ex3.Message); + } + try + { + PluginInterface.UiBuilder.Draw -= WindowSystem.Draw; + PluginInterface.UiBuilder.OpenConfigUi -= ToggleConfigUi; + PluginInterface.UiBuilder.OpenMainUi -= ToggleMainUi; + WindowSystem.RemoveAllWindows(); + ConfigWindow?.Dispose(); + NewMainWindow?.Dispose(); + DebugWindow?.Dispose(); + Log.Debug("[Plugin] UI disposed"); + } + catch (Exception ex4) + { + Log.Error("[Plugin] Failed to dispose UI: " + ex4.Message); + } + try + { + CommandManager.RemoveHandler("/qstcomp"); + CommandManager.RemoveHandler("/qsthelper"); + Log.Debug("[Plugin] Command handlers removed"); + } + catch (Exception ex5) + { + Log.Error("[Plugin] Failed to remove command handler: " + ex5.Message); + } + Log.Information("[QuestionableCompanion] Plugin disposed successfully"); + } + catch (Exception ex6) + { + Log.Error("[Plugin] Critical error during disposal: " + ex6.Message); + Log.Error("[Plugin] Stack trace: " + ex6.StackTrace); + } + } + + private void OnCommand(string command, string args) + { + string argLower = args.Trim().ToLower(); + if (argLower == "dbg") + { + DebugWindow.Toggle(); + return; + } + if (argLower == "task") + { + TestGetCurrentTask(); + return; + } + if (argLower.StartsWith("invite ")) + { + TestPartyInvite(args.Substring(7).Trim()); + return; + } + if (argLower == "invitehelpers") + { + TestInviteHelpers(); + return; + } + if (argLower == "disband") + { + TestDisband(); + return; + } + if (argLower.StartsWith("multi ")) + { + string message = args.Substring(6).Trim(); + TestMultiClientChat(message); + return; + } + if (argLower.StartsWith("cmd ")) + { + string cmdText = args.Substring(4).Trim(); + TestSendCommand(cmdText); + return; + } + switch (argLower) + { + case "chauffeur": + TestChauffeurMode(); + break; + case "society": + TestAlliedSociety(); + break; + case "stopcon": + TestStopConditions(); + break; + case "mounts": + TestListMounts(); + break; + default: + NewMainWindow.Toggle(); + break; + } + } + + private void OnHelperCommand(string command, string args) + { + string argLower = args.Trim().ToLower(); + if (argLower == "reset") + { + if (!Configuration.IsHighLevelHelper) + { + ChatGui.Print("[QSTHelper] You are not configured as a Helper!"); + return; + } + IPlayerCharacter localPlayer = ClientState.LocalPlayer; + if (localPlayer == null) + { + ChatGui.Print("[QSTHelper] Not logged in!"); + return; + } + ChauffeurModeService chauffeurMode = ChauffeurMode; + if (chauffeurMode != null) + { + chauffeurMode.ResetChauffeurState(); + ChatGui.Print("[QSTHelper] Status reset to Available (full reset)"); + Log.Information("[QSTHelper] Helper status manually reset to Available (full reset via ChauffeurMode)"); + return; + } + Configuration.CurrentHelperStatus = HelperStatus.Available; + Configuration.AssignedQuester = string.Empty; + Configuration.Save(); + string helperName = localPlayer.Name.ToString(); + ushort helperWorld = (ushort)localPlayer.HomeWorld.RowId; + CrossProcessIPC.BroadcastHelperStatus(helperName, helperWorld, "Available"); + ChatGui.Print("[QSTHelper] Status reset to Available (config only)"); + Log.Information("[QSTHelper] Helper status manually reset to Available (config only)"); + } + else if (argLower == "status") + { + if (!Configuration.IsHighLevelHelper) + { + ChatGui.Print("[QSTHelper] You are not configured as a Helper!"); + return; + } + string status = Configuration.CurrentHelperStatus switch + { + HelperStatus.Available => "Available", + HelperStatus.Transporting => "Transporting", + HelperStatus.InDungeon => "In Dungeon", + _ => "Unknown", + }; + string assigned = (string.IsNullOrEmpty(Configuration.AssignedQuester) ? "None" : Configuration.AssignedQuester); + ChatGui.Print("[QSTHelper] Status: " + status); + ChatGui.Print("[QSTHelper] Assigned Quester: " + assigned); + } + else + { + ChatGui.Print("[QSTHelper] Commands:"); + ChatGui.Print(" /qsthelper reset - Reset status to Available"); + ChatGui.Print(" /qsthelper status - Show current status"); + } + } + + private void TestGetCurrentTask() + { + Log.Information("========================================"); + Log.Information("[TEST] Testing Questionable.GetCurrentTask IPC"); + Log.Information("========================================"); + if (QuestionableIPC == null) + { + Log.Error("[TEST] QuestionableIPC is null!"); + return; + } + QuestionableIPC.ForceCheckAvailability(); + if (!QuestionableIPC.IsAvailable) + { + Log.Warning("[TEST] Questionable is not available!"); + return; + } + bool isRunning = QuestionableIPC.IsRunning(); + Log.Information($"[TEST] Questionable IsRunning: {isRunning}"); + object currentTask = QuestionableIPC.GetCurrentTask(); + if (currentTask == null) + { + Log.Information("[TEST] GetCurrentTask returned NULL (no task active)"); + } + else + { + Log.Information("[TEST] Current Task Found!"); + Log.Information("[TEST] - Type: " + currentTask.GetType().FullName); + Log.Information($"[TEST] - Value: {currentTask}"); + Log.Information("[TEST] - ToString(): " + currentTask.ToString()); + PropertyInfo[] properties = currentTask.GetType().GetProperties(); + if (properties.Length != 0) + { + Log.Information($"[TEST] Properties found: {properties.Length}"); + PropertyInfo[] array = properties; + foreach (PropertyInfo prop in array) + { + try + { + object value = prop.GetValue(currentTask); + Log.Information($"[TEST] - {prop.Name}: {value ?? "null"} (Type: {prop.PropertyType.Name})"); + } + catch (Exception ex) + { + Log.Warning("[TEST] - " + prop.Name + ": ERROR - " + ex.Message); + } + } + } + else + { + Log.Information("[TEST] No properties found - might be a primitive type or string"); + } + } + Log.Information("========================================"); + } + + private void TestPartyInvite(string characterNameWithWorld) + { + Log.Information("========================================"); + Log.Information("[TEST] Testing PartyInvite Service"); + Log.Information("========================================"); + if (string.IsNullOrEmpty(characterNameWithWorld)) + { + Log.Error("[TEST] Usage: /qstcomp invite "); + Log.Error("[TEST] Example: /qstcomp invite Firstname Lastname@Odin"); + Log.Information("========================================"); + return; + } + string[] parts = characterNameWithWorld.Split('@'); + if (parts.Length != 2) + { + Log.Error("[TEST] Invalid format! Use: CharacterName@WorldName"); + Log.Error("[TEST] Example: Firstname Lastname@Odin"); + Log.Information("========================================"); + return; + } + string characterName = parts[0].Trim(); + string worldName = parts[1].Trim(); + Log.Information("[TEST] Character: " + characterName); + Log.Information("[TEST] World: " + worldName); + ExcelSheet worldSheet = DataManager.GetExcelSheet(); + if (worldSheet == null) + { + Log.Error("[TEST] Failed to load World sheet!"); + Log.Information("========================================"); + return; + } + ushort worldId = 0; + foreach (World world in worldSheet) + { + if (world.Name.ExtractText().Equals(worldName, StringComparison.OrdinalIgnoreCase)) + { + worldId = (ushort)world.RowId; + break; + } + } + if (worldId == 0) + { + Log.Error("[TEST] World '" + worldName + "' not found!"); + Log.Information("========================================"); + return; + } + Log.Information($"[TEST] World ID: {worldId}"); + Log.Information("[TEST] Sending party invite..."); + if (PartyInviteService.InviteToParty(characterName, worldId)) + { + Log.Information("[TEST] Party invite sent successfully!"); + } + else + { + Log.Error("[TEST] Failed to send party invite!"); + } + Log.Information("========================================"); + } + + private void TestInviteHelpers() + { + Log.Information("========================================"); + Log.Information("[TEST] Testing Helper Invite System"); + Log.Information("========================================"); + if (!Configuration.IsQuester) + { + Log.Error("[TEST] This client is not configured as a Quester!"); + Log.Error("[TEST] Please enable 'I'm a Quester' in Settings > Multi-Client Role"); + Log.Information("========================================"); + return; + } + List<(string, ushort)> availableHelpers = HelperManager.GetAvailableHelpers(); + if (availableHelpers.Count == 0) + { + 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.Information("========================================"); + return; + } + Log.Information($"[TEST] Auto-discovered helpers: {availableHelpers.Count}"); + foreach (var (name, worldId) in availableHelpers) + { + Log.Information($"[TEST] - {name}@{worldId}"); + } + Log.Information("[TEST] Invoking HelperManager.InviteHelpers()..."); + HelperManager.InviteHelpers(); + Log.Information("========================================"); + } + + private void TestDisband() + { + Log.Information("========================================"); + Log.Information("[TEST] Testing Party Disband"); + Log.Information("========================================"); + Log.Information("[TEST] Disbanding party..."); + HelperManager.DisbandParty(); + Log.Information("========================================"); + } + + private void TestMultiClientChat(string message) + { + Log.Information("========================================"); + Log.Information("[TEST] Testing Multi-Client Chat IPC"); + Log.Information("========================================"); + if (string.IsNullOrEmpty(message)) + { + Log.Error("[TEST] Usage: /qstcomp multi "); + Log.Error("[TEST] Example: /qstcomp multi Hello from Client 1!"); + Log.Information("========================================"); + } + else + { + Log.Information("[TEST] Sending message via both IPC systems: " + message); + MultiClientIPC.SendChatMessage(message); + CrossProcessIPC.SendChatMessage(message); + Log.Information("[TEST] Message sent! Check other clients for receipt."); + Log.Information("========================================"); + } + } + + private void TestSendCommand(string command) + { + Log.Information("========================================"); + Log.Information("[TEST] Testing Cross-Process Command (Chauffeur Mode)"); + Log.Information("========================================"); + if (string.IsNullOrEmpty(command)) + { + Log.Error("[TEST] Usage: /qstcomp cmd "); + Log.Error("[TEST] Example: /qstcomp cmd /teleport Limsa Lominsa"); + Log.Information("========================================"); + } + else + { + Log.Information("[TEST] Sending command to other client: " + command); + CrossProcessIPC.SendCommand(command); + Log.Information("[TEST] Command sent! Check other client for execution."); + Log.Information("========================================"); + } + } + + private void TestChauffeurMode() + { + Log.Information("========================================"); + Log.Information("[TEST] Testing Chauffeur Mode"); + Log.Information("========================================"); + if (!Configuration.ChauffeurModeEnabled) + { + Log.Warning("[TEST] Chauffeur Mode is DISABLED in settings!"); + Log.Information("========================================"); + return; + } + if (Configuration.IsQuester) + { + Log.Information("[TEST] Role: QUESTER"); + Log.Information($"[TEST] Distance Threshold: {Configuration.ChauffeurDistanceThreshold} yalms"); + Log.Information("[TEST] Checking current task distance..."); + ChauffeurMode?.CheckTaskDistance(); + } + else if (Configuration.IsHighLevelHelper) + { + Log.Information("[TEST] Role: HELPER"); + Log.Information($"[TEST] Mount ID: {Configuration.ChauffeurMountId}"); + if (Configuration.ChauffeurMountId == 0) + { + Log.Warning("[TEST] No mount configured! Please select a multi-seater mount in settings."); + } + } + else + { + Log.Warning("[TEST] No role configured! Please select Quester or Helper in settings."); + } + Log.Information("========================================"); + } + + private void TestListMounts() + { + Log.Information("========================================"); + Log.Information("[TEST] Listing Multi-Seater Mounts"); + Log.Information("========================================"); + List<(uint, string, byte)> mounts = ChauffeurMode?.GetMultiSeaterMounts() ?? new List<(uint, string, byte)>(); + if (mounts.Count == 0) + { + Log.Warning("[TEST] No multi-seater mounts found!"); + } + else + { + Log.Information($"[TEST] Found {mounts.Count} multi-seater mounts:"); + foreach (var (id, name, seats) in mounts) + { + Log.Information($"[TEST] - {name} (ID: {id}, Seats: {seats})"); + } + } + Log.Information("========================================"); + } + + private void TestAlliedSociety() + { + Log.Information("========================================"); + Log.Information("[AlliedSociety] Testing Allied Society IPC Methods"); + Log.Information("========================================"); + if (QuestionableIPC == null) + { + Log.Error("[AlliedSociety] QuestionableIPC is null!"); + Log.Information("========================================"); + return; + } + QuestionableIPC.ForceCheckAvailability(); + if (!QuestionableIPC.IsAvailable) + { + Log.Warning("[AlliedSociety] Questionable is not available!"); + Log.Information("========================================"); + return; + } + int remainingAllowances = QuestionableIPC.GetAlliedSocietyRemainingAllowances(); + Log.Information("[AlliedSociety] ========================================"); + Log.Information($"[AlliedSociety] Daily Allowances Remaining: {remainingAllowances}/12"); + Log.Information("[AlliedSociety] ========================================"); + List societiesWithQuests = QuestionableIPC.GetAlliedSocietiesWithAvailableQuests(); + Log.Information($"[AlliedSociety] Societies with available quests: {societiesWithQuests.Count}"); + foreach (byte societyId in societiesWithQuests) + { + Log.Information($"[AlliedSociety] - Society ID: {societyId}"); + } + Log.Information("[AlliedSociety] ========================================"); + Dictionary alliedSocietyAllAvailableQuestCounts = QuestionableIPC.GetAlliedSocietyAllAvailableQuestCounts(); + Log.Information("[AlliedSociety] Quest counts by society:"); + foreach (var (societyId2, count) in alliedSocietyAllAvailableQuestCounts) + { + Log.Information($"[AlliedSociety] - Society {societyId2}: {count} quests"); + } + Log.Information("[AlliedSociety] ========================================"); + string[] societyNames = new string[20] + { + "Amalj'aa", "Sylphs", "Kobolds", "Sahagin", "Ixal", "Vanu Vanu", "Vath", "Moogles", "Kojin", "Ananta", + "Namazu", "Pixies", "Qitari", "Dwarves", "Arkasodara", "Omicrons", "Loporrits", "Pelupelu", "Mamool Ja", "Yok Huy" + }; + for (byte societyId3 = 1; societyId3 <= 20; societyId3++) + { + string societyName = societyNames[societyId3 - 1]; + Log.Information("[AlliedSociety] ----------------------------------------"); + Log.Information($"[AlliedSociety] Testing Society ID {societyId3}: {societyName}"); + Log.Information("[AlliedSociety] ----------------------------------------"); + int currentRank = QuestionableIPC.GetAlliedSocietyCurrentRank(societyId3); + Log.Information($"[AlliedSociety] Current Rank: {currentRank}"); + bool isMaxRank = QuestionableIPC.GetAlliedSocietyIsMaxRank(societyId3); + Log.Information($"[AlliedSociety] Is Max Rank: {isMaxRank}"); + List questIds = QuestionableIPC.GetAlliedSocietyAvailableQuestIds(societyId3); + Log.Information($"[AlliedSociety] Available Quests: {questIds.Count}"); + foreach (string questId in questIds) + { + bool num2 = QuestionableIPC.IsQuestComplete(questId); + bool isReady = QuestionableIPC.IsReadyToAcceptQuest(questId); + string completedStatus = (num2 ? "✓ Completed" : "✗ Not Completed"); + string readyStatus = (isReady ? "✓ Ready" : "✗ Not Ready"); + Log.Information("[AlliedSociety] - Quest ID: " + questId); + Log.Information("[AlliedSociety] Completed: " + completedStatus + " | Ready to Accept: " + readyStatus); + } + List optimalQuestIds = QuestionableIPC.GetAlliedSocietyOptimalQuests(societyId3); + Log.Information($"[AlliedSociety] Optimal Quests: {optimalQuestIds.Count}"); + foreach (string questId2 in optimalQuestIds) + { + bool num3 = QuestionableIPC.IsQuestComplete(questId2); + bool isReady2 = QuestionableIPC.IsReadyToAcceptQuest(questId2); + string completedStatus2 = (num3 ? "✓ Completed" : "✗ Not Completed"); + string readyStatus2 = (isReady2 ? "✓ Ready" : "✗ Not Ready"); + Log.Information("[AlliedSociety] - Optimal Quest ID: " + questId2); + Log.Information("[AlliedSociety] Completed: " + completedStatus2 + " | Ready to Accept: " + readyStatus2); + } + int addedCount = QuestionableIPC.AddAlliedSocietyOptimalQuests(societyId3); + Log.Information($"[AlliedSociety] Added {addedCount} optimal quests to priority queue"); + } + Log.Information("========================================"); + Log.Information("[AlliedSociety] All Allied Society tests completed!"); + Log.Information("========================================"); + } + + private void TestStopConditions() + { + Log.Information("========================================"); + Log.Information("[StopCondition] Testing Stop Condition IPC Methods"); + Log.Information("========================================"); + if (QuestionableIPC == null) + { + Log.Error("[StopCondition] QuestionableIPC is null!"); + Log.Information("========================================"); + return; + } + QuestionableIPC.ForceCheckAvailability(); + if (!QuestionableIPC.IsAvailable) + { + Log.Warning("[StopCondition] Questionable is not available!"); + Log.Information("========================================"); + return; + } + bool stopConditionsEnabled = QuestionableIPC.GetStopConditionsEnabled(); + Log.Information($"[StopCondition] Stop Conditions Enabled: {stopConditionsEnabled}"); + List stopQuests = QuestionableIPC.GetStopQuestList(); + Log.Information($"[StopCondition] Stop Quest Count: {stopQuests.Count}"); + foreach (string questId in stopQuests) + { + Log.Information("[StopCondition] - Quest ID: " + questId); + } + StopConditionData levelStopCondition = QuestionableIPC.GetLevelStopCondition(); + if (levelStopCondition != null) + { + Log.Information("[StopCondition] Level Stop Condition:"); + Log.Information($"[StopCondition] Enabled: {levelStopCondition.Enabled}"); + Log.Information($"[StopCondition] Target Level: {levelStopCondition.TargetValue}"); + } + else + { + Log.Information("[StopCondition] Level Stop Condition: Not configured"); + } + StopConditionData sequenceStopCondition = QuestionableIPC.GetSequenceStopCondition(); + if (sequenceStopCondition != null) + { + Log.Information("[StopCondition] Sequence Stop Condition:"); + Log.Information($"[StopCondition] Enabled: {sequenceStopCondition.Enabled}"); + Log.Information($"[StopCondition] Target Sequence: {sequenceStopCondition.TargetValue}"); + } + else + { + Log.Information("[StopCondition] Sequence Stop Condition: Not configured"); + } + Log.Information("[StopCondition] ========================================"); + Log.Information("[StopCondition] Testing Quest Sequence Stop Conditions"); + Log.Information("[StopCondition] ========================================"); + Log.Information("[StopCondition] Test 1: GetAllQuestSequenceStopConditions"); + Dictionary allQuestSequenceStopConditions = QuestionableIPC.GetAllQuestSequenceStopConditions(); + if (allQuestSequenceStopConditions.Count > 0) + { + Log.Information($"[StopCondition] ✓ Found {allQuestSequenceStopConditions.Count} condition(s)"); + foreach (KeyValuePair kvp in allQuestSequenceStopConditions) + { + Log.Information($"[StopCondition] - Quest: {kvp.Key} => {kvp.Value}"); + } + } + else + { + Log.Information("[StopCondition] ℹ No quest sequence stop conditions configured (this is normal if none are set)"); + } + if (stopQuests.Count > 0) + { + string testQuestId = stopQuests[0]; + Log.Information("[StopCondition] Test 2: GetQuestSequenceStopCondition for quest " + testQuestId); + object getResult = QuestionableIPC.GetQuestSequenceStopCondition(testQuestId, 1u, 1); + if (getResult != null) + { + Log.Information($"[StopCondition] ✓ Found condition: {getResult}"); + } + else + { + Log.Information("[StopCondition] ℹ No condition found for " + testQuestId + " (Seq: 1, Step: 1)"); + } + } + else + { + Log.Information("[StopCondition] Test 2: Skipped (no stop quests available)"); + } + if (allQuestSequenceStopConditions.Count > 0) + { + string firstQuestId = ""; + using (Dictionary.KeyCollection.Enumerator enumerator3 = allQuestSequenceStopConditions.Keys.GetEnumerator()) + { + if (enumerator3.MoveNext()) + { + firstQuestId = enumerator3.Current; + } + } + Log.Information("[StopCondition] Test 3: RemoveQuestSequenceStopCondition for quest " + firstQuestId); + QuestionableIPC.RemoveQuestSequenceStopCondition(firstQuestId); + } + else + { + Log.Information("[StopCondition] Test 3: Skipped (no conditions to remove)"); + } + Log.Information("========================================"); + Log.Information("[StopCondition] All Stop Condition tests completed!"); + Log.Information("========================================"); + } + + public void ToggleConfigUi() + { + ConfigWindow.Toggle(); + } + + public void ToggleMainUi() + { + NewMainWindow.Toggle(); + } + + public List<(string Name, ushort WorldId)> GetAvailableHelpers() + { + return HelperManager?.GetAvailableHelpers() ?? new List<(string, ushort)>(); + } + + public ChauffeurModeService? GetChauffeurMode() + { + return ChauffeurMode; + } + + public HelperManager? GetHelperManager() + { + return HelperManager; + } + + public DungeonAutomationService? GetDungeonAutomation() + { + return DungeonAutomation; + } +}