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()) { if (config.ReturnToHomeworldOnStopQuest) { AddLog(LogLevel.Info, "[DCTravel] Returning to homeworld before character switch..."); dcTravelService.ReturnToHomeworld(); Thread.Sleep(2000); AddLog(LogLevel.Info, "[DCTravel] Returned to homeworld"); } else { AddLog(LogLevel.Info, "[DCTravel] Skipping return to homeworld (setting disabled)"); } } 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"); } }