qstcompanion v1.0.6

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -553,10 +553,17 @@ public class ExecutionService : IDisposable
}
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.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)
{

View file

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

View file

@ -0,0 +1,489 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Plugin.Services;
using Newtonsoft.Json;
using QuestionableCompanion.Models;
namespace QuestionableCompanion.Services;
public class LANHelperClient : IDisposable
{
public class ChauffeurMessageEventArgs : EventArgs
{
public LANMessageType Type { get; }
public LANChauffeurResponse Data { get; }
public ChauffeurMessageEventArgs(LANMessageType type, LANChauffeurResponse data)
{
Type = type;
Data = data;
}
}
private readonly IPluginLog log;
private readonly IClientState clientState;
private readonly IFramework framework;
private readonly Configuration config;
private readonly Dictionary<string, TcpClient> activeConnections = new Dictionary<string, TcpClient>();
private readonly Dictionary<string, LANHelperInfo> discoveredHelpers = new Dictionary<string, LANHelperInfo>();
private CancellationTokenSource? cancellationTokenSource;
private string cachedPlayerName = string.Empty;
private ushort cachedWorldId;
public IReadOnlyList<LANHelperInfo> DiscoveredHelpers => discoveredHelpers.Values.ToList();
public event EventHandler<ChauffeurMessageEventArgs>? OnChauffeurMessageReceived;
public LANHelperClient(IPluginLog log, IClientState clientState, IFramework framework, Configuration config)
{
this.log = log;
this.clientState = clientState;
this.framework = framework;
this.config = config;
framework.Update += OnFrameworkUpdate;
}
private void OnFrameworkUpdate(IFramework fw)
{
IPlayerCharacter player = clientState.LocalPlayer;
if (player != null)
{
cachedPlayerName = player.Name.ToString();
cachedWorldId = (ushort)player.HomeWorld.RowId;
}
}
public async Task Initialize()
{
if (!config.EnableLANHelpers)
{
return;
}
cancellationTokenSource = new CancellationTokenSource();
log.Information("[LANClient] Initializing LAN Helper Client...");
foreach (string ip in config.LANHelperIPs)
{
await ConnectToHelperAsync(ip);
}
Task.Run(() => HeartbeatMonitorAsync(cancellationTokenSource.Token));
}
private async Task HeartbeatMonitorAsync(CancellationToken cancellationToken)
{
log.Information("[LANClient] Heartbeat monitor started (30s interval)");
while (!cancellationToken.IsCancellationRequested)
{
try
{
await Task.Delay(30000, cancellationToken);
foreach (string ip in config.LANHelperIPs.ToList())
{
if (!activeConnections.ContainsKey(ip) || !activeConnections[ip].Connected)
{
log.Debug("[LANClient] Heartbeat: " + ip + " disconnected, reconnecting...");
await ConnectToHelperAsync(ip);
continue;
}
LANHeartbeat heartbeatData = new LANHeartbeat
{
ClientName = (string.IsNullOrEmpty(cachedPlayerName) ? "Unknown" : cachedPlayerName),
ClientWorldId = cachedWorldId,
ClientRole = (config.IsQuester ? "Quester" : "Helper")
};
await SendMessageAsync(ip, new LANMessage(LANMessageType.HEARTBEAT, heartbeatData));
log.Debug($"[LANClient] Heartbeat sent to {ip} (as {heartbeatData.ClientName}@{heartbeatData.ClientWorldId})");
}
foreach (LANHelperInfo helper in discoveredHelpers.Values.ToList())
{
if (!string.IsNullOrEmpty(helper.IPAddress))
{
LANHeartbeat heartbeatData = new LANHeartbeat
{
ClientName = (string.IsNullOrEmpty(cachedPlayerName) ? "Unknown" : cachedPlayerName),
ClientWorldId = cachedWorldId,
ClientRole = (config.IsQuester ? "Quester" : "Helper")
};
await SendMessageAsync(helper.IPAddress, new LANMessage(LANMessageType.HEARTBEAT, heartbeatData));
log.Information($"[LANClient] Heartbeat sent to discovered helper {helper.Name}@{helper.IPAddress} (as {heartbeatData.ClientName}, Role={heartbeatData.ClientRole})");
}
}
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex2)
{
log.Error("[LANClient] Heartbeat monitor error: " + ex2.Message);
}
}
log.Information("[LANClient] Heartbeat monitor stopped");
}
public async Task<bool> ConnectToHelperAsync(string ipAddress)
{
if (activeConnections.ContainsKey(ipAddress))
{
log.Debug("[LANClient] Already connected to " + ipAddress);
return true;
}
try
{
log.Information($"[LANClient] Connecting to helper at {ipAddress}:{config.LANServerPort}...");
TcpClient client = new TcpClient();
await client.ConnectAsync(ipAddress, config.LANServerPort);
activeConnections[ipAddress] = client;
log.Information("[LANClient] ✓ Connected to " + ipAddress);
LANHelperStatusResponse statusResponse = await RequestHelperStatusAsync(ipAddress);
if (statusResponse != null)
{
discoveredHelpers[ipAddress] = new LANHelperInfo
{
Name = statusResponse.Name,
WorldId = statusResponse.WorldId,
IPAddress = ipAddress,
Status = statusResponse.Status,
LastSeen = DateTime.Now
};
log.Information($"[LANClient] Helper discovered: {statusResponse.Name} ({statusResponse.Status})");
}
Task.Run(() => ListenToHelperAsync(ipAddress, client), cancellationTokenSource.Token);
return true;
}
catch (Exception ex)
{
log.Error("[LANClient] Failed to connect to " + ipAddress + ": " + ex.Message);
return false;
}
}
private async Task ListenToHelperAsync(string ipAddress, TcpClient client)
{
try
{
using NetworkStream stream = client.GetStream();
using StreamReader reader = new StreamReader(stream, Encoding.UTF8);
while (client.Connected && !cancellationTokenSource.Token.IsCancellationRequested)
{
string line = await reader.ReadLineAsync(cancellationTokenSource.Token);
if (string.IsNullOrEmpty(line))
{
break;
}
try
{
LANMessage message = JsonConvert.DeserializeObject<LANMessage>(line);
if (message != null)
{
HandleHelperMessage(ipAddress, message);
}
}
catch (JsonException ex)
{
log.Error("[LANClient] Invalid message from " + ipAddress + ": " + ex.Message);
}
}
}
catch (Exception ex2)
{
log.Error("[LANClient] Connection to " + ipAddress + " lost: " + ex2.Message);
}
finally
{
log.Information("[LANClient] Disconnected from " + ipAddress);
activeConnections.Remove(ipAddress);
client.Close();
}
}
private void HandleHelperMessage(string ipAddress, LANMessage message)
{
log.Debug($"[LANClient] Received {message.Type} from {ipAddress}");
switch (message.Type)
{
case LANMessageType.HELPER_STATUS:
{
LANHelperStatusResponse status = message.GetData<LANHelperStatusResponse>();
if (status == null)
{
break;
}
if (!discoveredHelpers.ContainsKey(ipAddress))
{
log.Information($"[LANClient] New helper discovered via status: {status.Name}@{status.WorldId} ({ipAddress})");
discoveredHelpers[ipAddress] = new LANHelperInfo
{
Name = status.Name,
WorldId = status.WorldId,
IPAddress = ipAddress,
Status = status.Status,
LastSeen = DateTime.Now
};
}
else
{
discoveredHelpers[ipAddress].Status = status.Status;
discoveredHelpers[ipAddress].LastSeen = DateTime.Now;
if (discoveredHelpers[ipAddress].Name != status.Name)
{
discoveredHelpers[ipAddress].Name = status.Name;
discoveredHelpers[ipAddress].WorldId = status.WorldId;
}
}
break;
}
case LANMessageType.INVITE_ACCEPTED:
log.Information("[LANClient] ✓ Helper at " + ipAddress + " accepted invite");
break;
case LANMessageType.FOLLOW_STARTED:
log.Information("[LANClient] ✓ Helper at " + ipAddress + " started following");
break;
case LANMessageType.FOLLOW_ARRIVED:
log.Information("[LANClient] ✓ Helper at " + ipAddress + " arrived at destination");
break;
case LANMessageType.HELPER_READY:
log.Information("[LANClient] ✓ Helper at " + ipAddress + " is ready");
break;
case LANMessageType.HELPER_IN_PARTY:
log.Information("[LANClient] ✓ Helper at " + ipAddress + " joined party");
break;
case LANMessageType.HELPER_IN_DUTY:
log.Information("[LANClient] ✓ Helper at " + ipAddress + " entered duty");
break;
case LANMessageType.CHAUFFEUR_HELPER_READY_FOR_MOUNT:
{
LANChauffeurResponse readyData = message.GetData<LANChauffeurResponse>();
if (readyData != null)
{
log.Information($"[LANClient] Received Chauffeur Mount Ready from {readyData.QuesterName}@{readyData.QuesterWorldId}");
this.OnChauffeurMessageReceived?.Invoke(this, new ChauffeurMessageEventArgs(LANMessageType.CHAUFFEUR_HELPER_READY_FOR_MOUNT, readyData));
}
break;
}
case LANMessageType.CHAUFFEUR_HELPER_ARRIVED_DEST:
{
LANChauffeurResponse arrivedData = message.GetData<LANChauffeurResponse>();
if (arrivedData != null)
{
log.Information($"[LANClient] Received Chauffeur Arrived from {arrivedData.QuesterName}@{arrivedData.QuesterWorldId}");
this.OnChauffeurMessageReceived?.Invoke(this, new ChauffeurMessageEventArgs(LANMessageType.CHAUFFEUR_HELPER_ARRIVED_DEST, arrivedData));
}
break;
}
case LANMessageType.INVITE_NOTIFICATION:
case LANMessageType.DUTY_COMPLETE:
case LANMessageType.FOLLOW_COMMAND:
case LANMessageType.CHAUFFEUR_PICKUP_REQUEST:
break;
}
}
public async Task<bool> RequestHelperAsync(string ipAddress, string dutyName = "")
{
IPlayerCharacter player = clientState.LocalPlayer;
if (player == null)
{
return false;
}
LANHelperRequest request = new LANHelperRequest
{
QuesterName = player.Name.ToString(),
QuesterWorldId = (ushort)player.HomeWorld.RowId,
DutyName = dutyName
};
return await SendMessageAsync(ipAddress, new LANMessage(LANMessageType.REQUEST_HELPER, request));
}
public async Task<LANHelperStatusResponse?> RequestHelperStatusAsync(string ipAddress)
{
if (!(await SendMessageAsync(ipAddress, new LANMessage(LANMessageType.HELPER_STATUS))))
{
return null;
}
await Task.Delay(500);
if (discoveredHelpers.TryGetValue(ipAddress, out LANHelperInfo helper))
{
return new LANHelperStatusResponse
{
Name = helper.Name,
WorldId = helper.WorldId,
Status = helper.Status
};
}
return null;
}
public async Task<bool> SendFollowCommandAsync(string ipAddress, float x, float y, float z, uint territoryId)
{
LANFollowCommand followCmd = new LANFollowCommand
{
X = x,
Y = y,
Z = z,
TerritoryId = territoryId
};
log.Information($"[LANClient] Sending follow command to {ipAddress}: ({x:F2}, {y:F2}, {z:F2})");
return await SendMessageAsync(ipAddress, new LANMessage(LANMessageType.FOLLOW_COMMAND, followCmd));
}
public async Task<bool> NotifyInviteSentAsync(string ipAddress, string helperName)
{
log.Information("[LANClient] Notifying " + ipAddress + " of invite sent to " + helperName);
return await SendMessageAsync(ipAddress, new LANMessage(LANMessageType.INVITE_NOTIFICATION, helperName));
}
private async Task<bool> SendMessageAsync(string ipAddress, LANMessage message)
{
_ = 2;
try
{
if (!activeConnections.ContainsKey(ipAddress) && !(await ConnectToHelperAsync(ipAddress)))
{
return false;
}
TcpClient client = activeConnections[ipAddress];
if (!client.Connected)
{
log.Warning("[LANClient] Not connected to " + ipAddress + ", reconnecting...");
activeConnections.Remove(ipAddress);
if (!(await ConnectToHelperAsync(ipAddress)))
{
return false;
}
client = activeConnections[ipAddress];
}
string json = JsonConvert.SerializeObject(message);
byte[] bytes = Encoding.UTF8.GetBytes(json + "\n");
await client.GetStream().WriteAsync(bytes, 0, bytes.Length);
log.Debug($"[LANClient] Sent {message.Type} to {ipAddress}");
return true;
}
catch (Exception ex)
{
log.Error("[LANClient] Failed to send message to " + ipAddress + ": " + ex.Message);
return false;
}
}
public async Task<int> ScanNetworkAsync(int timeoutSeconds = 5)
{
log.Information($"[LANClient] \ud83d\udce1 Scanning network for helpers (timeout: {timeoutSeconds}s)...");
int foundCount = 0;
try
{
using UdpClient udpClient = new UdpClient(47789);
udpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, optionValue: true);
CancellationTokenSource cancellation = new CancellationTokenSource();
cancellation.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
while (!cancellation.Token.IsCancellationRequested)
{
try
{
UdpReceiveResult result = await udpClient.ReceiveAsync(cancellation.Token);
dynamic announcement = JsonConvert.DeserializeObject<object>(Encoding.UTF8.GetString(result.Buffer));
if (announcement?.Type == "HELPER_ANNOUNCE")
{
string helperIP = result.RemoteEndPoint.Address.ToString();
string helperName = (string)announcement.Name;
_ = (int)announcement.Port;
log.Information("[LANClient] ✓ Found helper: " + helperName + " at " + helperIP);
if (!config.LANHelperIPs.Contains(helperIP))
{
config.LANHelperIPs.Add(helperIP);
config.Save();
log.Information("[LANClient] → Added " + helperIP + " to configuration");
foundCount++;
}
await ConnectToHelperAsync(helperIP);
}
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex2)
{
log.Debug("[LANClient] Scan error: " + ex2.Message);
}
}
}
catch (Exception ex3)
{
log.Error("[LANClient] Network scan failed: " + ex3.Message);
}
if (foundCount > 0)
{
log.Information($"[LANClient] ✓ Scan complete: Found {foundCount} new helper(s)");
}
else
{
log.Information("[LANClient] Scan complete: No new helpers found");
}
return foundCount;
}
public LANHelperInfo? GetFirstAvailableHelper()
{
return (from h in discoveredHelpers.Values
where h.Status == LANHelperStatus.Available
orderby h.LastSeen
select h).FirstOrDefault();
}
public async Task<bool> SendChauffeurSummonAsync(string ipAddress, LANChauffeurSummon summonData)
{
log.Information("[LANClient] *** SENDING CHAUFFEUR_PICKUP_REQUEST to " + ipAddress + " ***");
log.Information($"[LANClient] Summon data: Quester={summonData.QuesterName}@{summonData.QuesterWorldId}, Zone={summonData.ZoneId}");
LANMessage message = new LANMessage(LANMessageType.CHAUFFEUR_PICKUP_REQUEST, summonData);
bool num = await SendMessageAsync(ipAddress, message);
if (num)
{
log.Information("[LANClient] ✓ CHAUFFEUR_PICKUP_REQUEST sent successfully to " + ipAddress);
}
else
{
log.Error("[LANClient] ✗ FAILED to send CHAUFFEUR_PICKUP_REQUEST to " + ipAddress);
}
return num;
}
public void DisconnectAll()
{
log.Information("[LANClient] Disconnecting from all helpers...");
foreach (KeyValuePair<string, TcpClient> kvp in activeConnections.ToList())
{
try
{
SendMessageAsync(kvp.Key, new LANMessage(LANMessageType.DISCONNECT)).Wait(1000);
kvp.Value.Close();
}
catch
{
}
}
activeConnections.Clear();
discoveredHelpers.Clear();
}
public void Dispose()
{
cancellationTokenSource?.Cancel();
DisconnectAll();
}
}

View file

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

View file

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

View file

@ -1,6 +1,5 @@
using System;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Component.GUI;
namespace QuestionableCompanion.Services;
@ -21,6 +20,8 @@ public class PartyInviteAutoAccept : IDisposable
private DateTime autoAcceptUntil = DateTime.MinValue;
private bool hasLoggedAlwaysAccept;
public PartyInviteAutoAccept(IPluginLog log, IFramework framework, IGameGui gameGui, IPartyList partyList, Configuration configuration)
{
this.log = log;
@ -47,16 +48,50 @@ public class PartyInviteAutoAccept : IDisposable
log.Information("[PartyInviteAutoAccept] Will accept ALL party invites during this time!");
}
public void EnableForQuester(string questerName)
{
shouldAutoAccept = true;
autoAcceptUntil = DateTime.Now.AddSeconds(60.0);
log.Information("[PartyInviteAutoAccept] Auto-accept enabled for quester: " + questerName);
log.Information("[PartyInviteAutoAccept] Will accept invites for 60 seconds");
}
private unsafe void OnFrameworkUpdate(IFramework framework)
{
if (!shouldAutoAccept)
bool shouldAcceptNow = false;
if (configuration.IsHighLevelHelper && configuration.AlwaysAutoAcceptInvites)
{
return;
if (!hasLoggedAlwaysAccept)
{
log.Information("[PartyInviteAutoAccept] === ALWAYS AUTO-ACCEPT ENABLED ===");
log.Information("[PartyInviteAutoAccept] Helper will continuously accept ALL party invites");
log.Information("[PartyInviteAutoAccept] This mode is ALWAYS ON (no timeout)");
hasLoggedAlwaysAccept = true;
}
shouldAcceptNow = true;
}
if (DateTime.Now > autoAcceptUntil)
else if (shouldAutoAccept)
{
if (hasLoggedAlwaysAccept)
{
log.Information("[PartyInviteAutoAccept] Always auto-accept disabled");
hasLoggedAlwaysAccept = false;
}
if (DateTime.Now > autoAcceptUntil)
{
shouldAutoAccept = false;
log.Information("[PartyInviteAutoAccept] Auto-accept window expired");
return;
}
shouldAcceptNow = true;
}
else if (hasLoggedAlwaysAccept)
{
log.Information("[PartyInviteAutoAccept] Always auto-accept disabled");
hasLoggedAlwaysAccept = false;
}
if (!shouldAcceptNow)
{
shouldAutoAccept = false;
log.Information("[PartyInviteAutoAccept] Auto-accept window expired");
return;
}
try
@ -72,66 +107,35 @@ public class PartyInviteAutoAccept : IDisposable
break;
}
}
if (addonPtr == IntPtr.Zero)
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)
AtkUnitBase* addon = (AtkUnitBase*)addonPtr;
if (addon == null)
{
log.Warning("[PartyInviteAutoAccept] Addon pointer is null!");
return;
}
if (!addon2->IsVisible)
else if (addon->IsVisible)
{
log.Debug("[PartyInviteAutoAccept] Addon exists but not visible yet");
return;
AtkValue* values = stackalloc AtkValue[1];
*values = new AtkValue
{
Type = FFXIVClientStructs.FFXIV.Component.GUI.ValueType.Int,
Int = 0
};
addon->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
};
addon->FireCallback(2u, values2);
}
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)

View file

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

View file

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

View file

@ -47,10 +47,6 @@ public class StepsOfFaithHandler : IDisposable
public bool ShouldActivate(uint questId, bool isInSoloDuty)
{
if (!config.EnableAutoDutyUnsynced)
{
return false;
}
if (isActive)
{
return false;
@ -70,8 +66,10 @@ public class StepsOfFaithHandler : IDisposable
}
if (characterHandledStatus.GetValueOrDefault(characterName, defaultValue: false))
{
log.Debug("[StepsOfFaith] Character " + characterName + " already handled SoF - skipping");
return false;
}
log.Information("[StepsOfFaith] Handler will activate for " + characterName);
return true;
}
@ -108,11 +106,6 @@ public class StepsOfFaithHandler : IDisposable
}
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
{

View file

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