Compare commits

...

2 commits

Author SHA1 Message Date
8a7847ff37 muffin v7.38.9 2025-12-07 10:55:56 +10:00
ada27cf05b qstcompanion v1.0.6 2025-12-07 10:54:53 +10:00
51 changed files with 4699 additions and 1115 deletions

View file

@ -33,7 +33,7 @@ public abstract class LWindow : Window
}
}
protected bool IsPinned
protected new bool IsPinned
{
get
{
@ -45,7 +45,7 @@ public abstract class LWindow : Window
}
}
protected bool IsClickthrough
protected new bool IsClickthrough
{
get
{

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>NotificationMasterAPI</AssemblyName>
<GenerateAssemblyInfo>False</GenerateAssemblyInfo>
<TargetFramework>netcoreapp9.0</TargetFramework>
</PropertyGroup>
<PropertyGroup>
<LangVersion>12.0</LangVersion>
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
</PropertyGroup>
<PropertyGroup />
<ItemGroup />
<ItemGroup>
<Reference Include="Dalamud">
<HintPath>..\..\..\..\..\ffxiv\alyssile-xivl\addon\Hooks\dev\Dalamud.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View file

@ -0,0 +1,6 @@
namespace NotificationMasterAPI;
public static class Data
{
public const string MFAudioFormats = "*.3g2;*.3gp;*.3gp2;*.3gpp;*.asf;*.wma;*.wmv;*.aac;*.adts;*.avi;*.mp3;*.m4a;*.m4v;*.mov;*.mp4;*.sami;*.smi;*.wav;*.aiff";
}

View file

@ -0,0 +1,16 @@
namespace NotificationMasterAPI;
public static class NMAPINames
{
public const string DisplayToastNotification = "NotificationMasterAPI.DisplayToastNotification";
public const string FlashTaskbarIcon = "NotificationMasterAPI.FlashTaskbarIcon";
public const string PlaySound = "NotificationMasterAPI.PlaySound";
public const string BringGameForeground = "NotificationMasterAPI.BringGameForeground";
public const string StopSound = "NotificationMasterAPI.StopSound";
public const string Active = "NotificationMasterAPI.Active";
}

View file

@ -0,0 +1,146 @@
using System;
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc.Exceptions;
namespace NotificationMasterAPI;
public class NotificationMasterApi
{
private IDalamudPluginInterface PluginInterface;
/// <summary>
/// Creates an instance of NotificationMaster API. You do not need to check if NotificationMaster plugin is installed.
/// </summary>
/// <param name="dalamudPluginInterface">Plugin interface reference</param>
public NotificationMasterApi(IDalamudPluginInterface dalamudPluginInterface)
{
PluginInterface = dalamudPluginInterface;
}
private void Validate()
{
if (PluginInterface == null)
{
throw new NullReferenceException("NotificationMaster API was called before it was initialized");
}
}
/// <summary>
/// Checks if IPC is ready. You DO NOT need to call this method before invoking any of API functions unless you specifically want to check if plugin is installed and ready to accept requests.
/// </summary>
/// <returns></returns>
public bool IsIPCReady()
{
Validate();
try
{
PluginInterface.GetIpcSubscriber<object>("NotificationMasterAPI.Active").InvokeAction();
return true;
}
catch (IpcNotReadyError)
{
}
return false;
}
/// <summary>
/// Displays tray notification. This function does not throws an exception or displays an error if NotificationMaster is not installed.
/// </summary>
/// <param name="text">Text of tray notification</param>
/// <returns>Whether operation succeed.</returns>
public bool DisplayTrayNotification(string text)
{
return DisplayTrayNotification(null, text);
}
/// <summary>
/// Displays tray notification. This function does not throws an exception or displays an error if NotificationMaster is not installed.
/// </summary>
/// <param name="title">Title of tray notification</param>
/// <param name="text">Text of tray notification</param>
/// <returns>Whether operation succeed.</returns>
public bool DisplayTrayNotification(string? title, string text)
{
Validate();
try
{
return PluginInterface.GetIpcSubscriber<string, string, string, bool>("NotificationMasterAPI.DisplayToastNotification").InvokeFunc(PluginInterface.InternalName, title, text);
}
catch (IpcNotReadyError)
{
}
return false;
}
/// <summary>
/// Flashes game's taskbar icon. This function does not throws an exception or displays an error if NotificationMaster is not installed.
/// </summary>
/// <returns>Whether operation succeeded</returns>
public bool FlashTaskbarIcon()
{
Validate();
try
{
return PluginInterface.GetIpcSubscriber<string, bool>("NotificationMasterAPI.FlashTaskbarIcon").InvokeFunc(PluginInterface.InternalName);
}
catch (IpcNotReadyError)
{
}
return false;
}
/// <summary>
/// Attempts to bring game's window foreground. Due to Windows inconsistencies, it's not guaranteed to work. This function does not throws an exception or displays an error if NotificationMaster is not installed.
/// </summary>
/// <returns>Whether operation succeeded</returns>
public bool TryBringGameForeground()
{
Validate();
try
{
return PluginInterface.GetIpcSubscriber<string, bool>("NotificationMasterAPI.BringGameForeground").InvokeFunc(PluginInterface.InternalName);
}
catch (IpcNotReadyError)
{
}
return false;
}
/// <summary>
/// Begins to play a sound file. If another sound file is already playing, stops previous file and begins playing specified. This function does not throws an exception or displays an error if NotificationMaster is not installed.
/// </summary>
/// <param name="pathOnDisk">Path to local file. Can not be web URL. See <see cref="F:NotificationMasterAPI.Data.MFAudioFormats" /> for supported formats.</param>
/// <param name="volume">Volume between 0.0 and 1.0</param>
/// <param name="repeat">Whether to repeat sound file.</param>
/// <param name="stopOnGameFocus">Whether to stop file once game is focused. </param>
/// <returns>Whether operation succeeded</returns>
public bool PlaySound(string pathOnDisk, float volume = 1f, bool repeat = false, bool stopOnGameFocus = true)
{
Validate();
try
{
return PluginInterface.GetIpcSubscriber<string, string, float, bool, bool, bool>("NotificationMasterAPI.PlaySound").InvokeFunc(PluginInterface.InternalName, pathOnDisk, volume, repeat, stopOnGameFocus);
}
catch (IpcNotReadyError)
{
}
return false;
}
/// <summary>
/// Stops playing sound. This function does not throws an exception or displays an error if NotificationMaster is not installed.
/// </summary>
/// <returns>Whether operation succeeded</returns>
public bool StopSound()
{
Validate();
try
{
return PluginInterface.GetIpcSubscriber<string, bool>("NotificationMasterAPI.StopSound").InvokeFunc(PluginInterface.InternalName);
}
catch (IpcNotReadyError)
{
}
return false;
}
}

File diff suppressed because it is too large Load diff

View file

@ -173,6 +173,10 @@ internal static class Interact
protected override bool Start()
{
InteractionType = base.Task.InteractionType;
_interactionState = EInteractionState.None;
_needsUnmount = false;
delayedFinalCheck = false;
_continueAt = DateTime.MinValue;
IGameObject gameObject = gameFunctions.FindObjectByDataId(base.Task.DataId);
if (gameObject == null)
{
@ -259,6 +263,7 @@ internal static class Interact
{
if (base.ProgressContext.WasInterrupted())
{
logger.LogDebug("Interaction with {DataId} was interrupted", base.Task.DataId);
return ETaskResult.StillRunning;
}
if (base.ProgressContext.WasSuccessful() || _interactionState == EInteractionState.InteractionConfirmed)

View file

@ -196,15 +196,40 @@ internal static class WaitAtEnd
}
}
internal sealed class WaitNextStepOrSequenceExecutor : TaskExecutor<WaitNextStepOrSequence>
internal sealed class WaitNextStepOrSequenceExecutor(QuestController questController) : TaskExecutor<WaitNextStepOrSequence>()
{
private ElementId? _questId;
private byte _initialSequence;
private int _initialStep;
protected override bool Start()
{
QuestController.QuestProgress currentQuest = questController.CurrentQuest;
if (currentQuest != null)
{
_questId = currentQuest.Quest.Id;
_initialSequence = currentQuest.Sequence;
_initialStep = currentQuest.Step;
}
return true;
}
public override ETaskResult Update()
{
if (_questId != null)
{
QuestController.QuestProgress currentQuest = questController.CurrentQuest;
if (currentQuest == null || currentQuest.Quest.Id != _questId)
{
return ETaskResult.TaskComplete;
}
if (currentQuest.Sequence != _initialSequence || currentQuest.Step != _initialStep)
{
return ETaskResult.TaskComplete;
}
}
return ETaskResult.StillRunning;
}

View file

@ -33,6 +33,7 @@ internal abstract class TaskExecutor<T> : ITaskExecutor where T : class, ITask
if (task is T task2)
{
Task = task2;
ProgressContext = null;
return Start();
}
throw new TaskException($"Unable to cast {task.GetType()} to {typeof(T)}");

View file

@ -15,11 +15,6 @@ internal sealed class InterruptHandler : IDisposable
{
private unsafe delegate void ProcessActionEffect(uint sourceId, Character* sourceCharacter, Vector3* pos, EffectHeader* effectHeader, EffectEntry* effectArray, ulong* effectTail);
private static class Signatures
{
internal const string ActionEffect = "40 ?? 56 57 41 ?? 41 ?? 41 ?? 48 ?? ?? ?? ?? ?? ?? ?? 48";
}
[StructLayout(LayoutKind.Explicit)]
private struct EffectEntry
{
@ -150,7 +145,7 @@ internal sealed class InterruptHandler : IDisposable
_objectTable = objectTable;
_territoryData = territoryData;
_logger = logger;
_processActionEffectHook = gameInteropProvider.HookFromSignature<ProcessActionEffect>("40 ?? 56 57 41 ?? 41 ?? 41 ?? 48 ?? ?? ?? ?? ?? ?? ?? 48", HandleProcessActionEffect);
_processActionEffectHook = gameInteropProvider.HookFromAddress<ProcessActionEffect>(ActionEffectHandler.Addresses.Receive.Value, HandleProcessActionEffect);
_processActionEffectHook.Enable();
}

View file

@ -85,6 +85,8 @@ internal sealed class MovementController : IDisposable
private readonly AetheryteData _aetheryteData;
private readonly Configuration _configuration;
private readonly ILogger<MovementController> _logger;
private CancellationTokenSource? _cancellationTokenSource;
@ -93,6 +95,14 @@ internal sealed class MovementController : IDisposable
private long _pathfindStartTime;
private Vector3? _lastKnownPosition;
private long _lastPositionUpdateTime;
private Vector3? _expectedPosition;
private bool _isTrackingPlayerInput;
public bool IsNavmeshReady
{
get
@ -146,7 +156,9 @@ internal sealed class MovementController : IDisposable
public int NumQueuedPathfindRequests => _navmeshIpc.NumQueuedPathfindRequests;
public MovementController(NavmeshIpc navmeshIpc, IClientState clientState, IObjectTable objectTable, GameFunctions gameFunctions, ChatFunctions chatFunctions, ICondition condition, MovementOverrideController movementOverrideController, AetheryteData aetheryteData, ILogger<MovementController> logger)
public event EventHandler? PlayerInputDetected;
public MovementController(NavmeshIpc navmeshIpc, IClientState clientState, IObjectTable objectTable, GameFunctions gameFunctions, ChatFunctions chatFunctions, ICondition condition, MovementOverrideController movementOverrideController, AetheryteData aetheryteData, Configuration configuration, ILogger<MovementController> logger)
{
_navmeshIpc = navmeshIpc;
_clientState = clientState;
@ -156,11 +168,19 @@ internal sealed class MovementController : IDisposable
_condition = condition;
_movementOverrideController = movementOverrideController;
_aetheryteData = aetheryteData;
_configuration = configuration;
_logger = logger;
}
public unsafe void Update()
{
if (IsPathRunning && _isTrackingPlayerInput && DetectPlayerInputInterference())
{
_logger.LogInformation("Player input detected during automatic movement, raising event to stop automation");
this.PlayerInputDetected?.Invoke(this, EventArgs.Empty);
Stop();
return;
}
if (_pathfindTask != null && Destination != null)
{
if (!_pathfindTask.IsCompleted && Environment.TickCount64 - _pathfindStartTime > 30000 && _navmeshIpc.NumQueuedPathfindRequests > 5)
@ -188,6 +208,11 @@ internal sealed class MovementController : IDisposable
if (Destination.IsFlying && Destination.Land)
{
_logger.LogWarning("Adjusted destination failed, trying tolerance-based pathfinding");
if (!IsNavmeshReady)
{
_logger.LogWarning("Navmesh not ready for tolerance-based pathfinding");
return;
}
_cancellationTokenSource = new CancellationTokenSource();
_cancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(30L));
Vector3 vector2 = _objectTable[0]?.Position ?? Vector3.Zero;
@ -218,6 +243,11 @@ internal sealed class MovementController : IDisposable
(list, _) = tuple;
if (tuple.Item2 && Destination.ShouldRecalculateNavmesh())
{
if (!IsNavmeshReady)
{
_logger.LogWarning("Navmesh not ready for recalculation");
return;
}
Destination.NavmeshCalculations++;
Destination.PartialRoute.AddRange(list);
_logger.LogInformation("Running navmesh recalculation with fudged point ({From} to {To})", list.Last(), Destination.Position);
@ -232,6 +262,7 @@ internal sealed class MovementController : IDisposable
_logger.LogInformation("Navigating via route: [{Route}]", string.Join(" → ", _pathfindTask.Result.Select((Vector3 x) => x.ToString("G", CultureInfo.InvariantCulture))));
_navmeshIpc.MoveTo(list, Destination.IsFlying);
MovementStartedAt = DateTime.Now;
StartPlayerInputTracking();
ResetPathfinding();
}
else if (_pathfindTask.IsCompleted)
@ -321,6 +352,72 @@ internal sealed class MovementController : IDisposable
}
}
private void StartPlayerInputTracking()
{
IGameObject gameObject = _objectTable[0];
if (gameObject != null)
{
_lastKnownPosition = gameObject.Position;
_expectedPosition = gameObject.Position;
_lastPositionUpdateTime = Environment.TickCount64;
_isTrackingPlayerInput = true;
}
}
private bool DetectPlayerInputInterference()
{
if (!_configuration.General.StopOnPlayerInput)
{
return false;
}
if (!_isTrackingPlayerInput || !_lastKnownPosition.HasValue)
{
return false;
}
IGameObject gameObject = _objectTable[0];
if (gameObject == null)
{
return false;
}
Vector3 position = gameObject.Position;
long tickCount = Environment.TickCount64;
if (tickCount - _lastPositionUpdateTime < 100)
{
return false;
}
List<Vector3> waypoints = _navmeshIpc.GetWaypoints();
if (waypoints.Count > 0)
{
_expectedPosition = waypoints[0];
}
if (_expectedPosition.HasValue)
{
Vector3 vector = Vector3.Normalize(_expectedPosition.Value - _lastKnownPosition.Value);
Vector3 value = position - _lastKnownPosition.Value;
if (value.Length() > 0.1f)
{
Vector3 vector2 = Vector3.Normalize(value);
float num = Vector3.Dot(vector, vector2);
if (num < 0.7f)
{
_logger.LogDebug("Player movement detected: alignment={Alignment:F2}, actual={Actual}, expected={Expected}", num, value.ToString("G", CultureInfo.InvariantCulture), vector.ToString("G", CultureInfo.InvariantCulture));
return true;
}
}
}
_lastKnownPosition = position;
_lastPositionUpdateTime = tickCount;
return false;
}
private void StopPlayerInputTracking()
{
_isTrackingPlayerInput = false;
_lastKnownPosition = null;
_expectedPosition = null;
_lastPositionUpdateTime = 0L;
}
private void Restart(DestinationData destination)
{
Stop();
@ -364,6 +461,11 @@ internal sealed class MovementController : IDisposable
public void NavigateTo(EMovementType type, uint? dataId, Vector3 to, bool fly, bool sprint, float? stopDistance = null, float? verticalStopDistance = null, bool land = false)
{
if (!IsNavmeshReady)
{
_logger.LogWarning("Navmesh not ready, cannot start navigation to {Position}", to.ToString("G", CultureInfo.InvariantCulture));
return;
}
fly |= _condition[ConditionFlag.Diving];
if (fly && land)
{
@ -404,6 +506,11 @@ internal sealed class MovementController : IDisposable
public void NavigateTo(EMovementType type, uint? dataId, List<Vector3> to, bool fly, bool sprint, float? stopDistance, float? verticalStopDistance = null, bool land = false)
{
if (!IsNavmeshReady)
{
_logger.LogWarning("Navmesh not ready, cannot start navigation to {Position}", to.Last().ToString("G", CultureInfo.InvariantCulture));
return;
}
fly |= _condition[ConditionFlag.Diving];
if (fly && land && to.Count > 0)
{
@ -416,6 +523,7 @@ internal sealed class MovementController : IDisposable
_logger.LogInformation("Moving to {Destination}", Destination);
_navmeshIpc.MoveTo(to, fly);
MovementStartedAt = DateTime.Now;
StartPlayerInputTracking();
}
public void ResetPathfinding()
@ -436,6 +544,11 @@ internal sealed class MovementController : IDisposable
private Vector3? TryFindAccessibleDestination(Vector3 target, bool flying, bool landing)
{
if (!IsNavmeshReady)
{
_logger.LogWarning("Navmesh not ready, cannot find accessible destination");
return null;
}
float[] array = ((!(flying && landing)) ? ((!flying) ? new float[3] { 1f, 3f, 5f } : new float[3] { 2f, 5f, 10f }) : new float[3] { 5f, 10f, 15f });
float[] array2 = ((!flying) ? new float[3] { 1f, 2f, 3f } : new float[3] { 3f, 5f, 10f });
for (int i = 0; i < array.Length; i++)
@ -504,17 +617,52 @@ internal sealed class MovementController : IDisposable
if (Math.Abs((double)num - Destination.LastWaypoint.Distance2DAtLastUpdate) < 0.5)
{
int navmeshCalculations = Destination.NavmeshCalculations;
if (navmeshCalculations % 6 == 1)
switch (navmeshCalculations)
{
_logger.LogWarning("Jumping to try and resolve navmesh problem (n = {Calculations})", navmeshCalculations);
case 1:
case 7:
_logger.LogWarning("Jumping to try and resolve navmesh problem (n = {Calculations}) at {Position}", navmeshCalculations, Destination.Position.ToString("G", CultureInfo.InvariantCulture));
ActionManager.Instance()->UseAction(ActionType.GeneralAction, 2u, 3758096384uL, 0u, ActionManager.UseActionMode.None, 0u, null);
Destination.NavmeshCalculations++;
Destination.LastWaypoint.UpdatedAt = Environment.TickCount64;
}
else
break;
case 5:
_logger.LogWarning("Reloading navmesh (n = {Calculations}) at {Position}", navmeshCalculations, Destination.Position.ToString("G", CultureInfo.InvariantCulture));
_navmeshIpc.Reload();
Destination.LastWaypoint.UpdatedAt = Environment.TickCount64;
break;
case 6:
if (!IsNavmeshReady)
{
_logger.LogWarning("Recalculating navmesh (n = {Calculations})", navmeshCalculations);
_logger.LogWarning("Navmesh not ready after reload (n = {Calculations})", navmeshCalculations);
return false;
}
_logger.LogInformation("Navmesh ready after reload, restarting navigation (n = {Calculations})", navmeshCalculations);
Restart(Destination);
break;
case 8:
_logger.LogWarning("Rebuilding navmesh (n = {Calculations}) at {Position}", navmeshCalculations, Destination.Position.ToString("G", CultureInfo.InvariantCulture));
_navmeshIpc.Rebuild();
Destination.LastWaypoint.UpdatedAt = Environment.TickCount64;
break;
case 9:
if (!IsNavmeshReady)
{
_logger.LogWarning("Navmesh not ready after rebuild (n = {Calculations})", navmeshCalculations);
return false;
}
_logger.LogInformation("Navmesh ready after rebuild, restarting navigation (n = {Calculations})", navmeshCalculations);
Restart(Destination);
break;
default:
if (!IsNavmeshReady)
{
_logger.LogWarning("Navmesh not ready for recalculation (n = {Calculations})", navmeshCalculations);
return false;
}
_logger.LogWarning("Recalculating navmesh (n = {Calculations}) at {Position}", navmeshCalculations, Destination.Position.ToString("G", CultureInfo.InvariantCulture));
Restart(Destination);
break;
}
Destination.NavmeshCalculations = navmeshCalculations + 1;
return true;
@ -570,6 +718,7 @@ internal sealed class MovementController : IDisposable
public void Stop()
{
StopPlayerInputTracking();
_navmeshIpc.Stop();
ResetPathfinding();
Destination = null;

View file

@ -257,6 +257,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>
_toastGui.Toast += OnNormalToast;
_condition.ConditionChange += OnConditionChange;
_clientState.Logout += OnLogout;
_movementController.PlayerInputDetected += OnPlayerInputDetected;
}
public void Reload()
@ -683,48 +684,63 @@ internal sealed class QuestController : MiniTaskController<QuestController>
{
DebugState = "No quest active";
Stop("No quest active");
}
else if (questProgress.Step == 255)
{
DebugState = $"Waiting for sequence update (current: {questProgress.Sequence})";
if (!_taskQueue.AllTasksComplete)
{
DebugState = "Step 255 - processing interrupted tasks";
}
else
{
if (this.CurrentQuest == null)
{
return;
}
if (_gameFunctions.IsOccupied() && !_gameFunctions.IsOccupiedWithCustomDeliveryNpc(questProgress.Quest))
TimeSpan timeSpan = DateTime.Now - this.CurrentQuest.StepProgress.StartedAt;
if (timeSpan > TimeSpan.FromSeconds(3L))
{
_logger.LogWarning("Step 255 with no tasks for {WaitTime:F1}s, retrying step to ensure completion (quest: {QuestId}, sequence: {Sequence})", timeSpan.TotalSeconds, questProgress.Quest.Id, questProgress.Sequence);
QuestSequence questSequence = questProgress.Quest.FindSequence(questProgress.Sequence);
if (questSequence != null && questSequence.Steps.Count > 0)
{
this.CurrentQuest.SetStep(questSequence.Steps.Count - 1);
CheckNextTasks("Retry last step at 255");
}
}
}
}
else if (_gameFunctions.IsOccupied() && !_gameFunctions.IsOccupiedWithCustomDeliveryNpc(questProgress.Quest))
{
DebugState = "Occupied";
return;
}
if (_movementController.IsPathfinding)
else if (_movementController.IsPathfinding)
{
DebugState = "Pathfinding is running";
return;
}
if (_movementController.IsPathRunning)
else if (_movementController.IsPathRunning)
{
DebugState = "Path is running";
return;
}
if (DateTime.Now < _safeAnimationEnd)
else if (DateTime.Now < _safeAnimationEnd)
{
DebugState = "Waiting for Animation";
return;
}
if (questProgress.Sequence != b)
else if (questProgress.Sequence != b)
{
questProgress.SetSequence(b);
CheckNextTasks($"New sequence {questProgress == _startedQuest}/{_questFunctions.GetCurrentQuestInternal(allowNewMsq: true)}");
}
QuestSequence questSequence = questProgress.Quest.FindSequence(questProgress.Sequence);
if (questSequence == null)
else
{
QuestSequence questSequence2 = questProgress.Quest.FindSequence(questProgress.Sequence);
if (questSequence2 == null)
{
DebugState = $"Sequence {questProgress.Sequence} not found";
Stop("Unknown sequence");
}
else if (questProgress.Step == 255)
{
DebugState = "Step completed";
if (!_taskQueue.AllTasksComplete)
{
CheckNextTasks("Step complete");
}
}
else if (questSequence.Steps.Count > 0 && questProgress.Step >= questSequence.Steps.Count)
else if (questSequence2.Steps.Count > 0 && questProgress.Step >= questSequence2.Steps.Count)
{
DebugState = "Step not found";
Stop("Unknown step");
@ -735,6 +751,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>
}
}
}
}
public (QuestSequence? Sequence, QuestStep? Step, bool createTasks) GetNextStep()
{
@ -778,15 +795,21 @@ internal sealed class QuestController : MiniTaskController<QuestController>
_logger.LogWarning("Ignoring 'increase step count' for different sequence (expected {ExpectedSequence}, but we are at {CurrentSequence}", sequence, questSequence.Sequence);
}
_logger.LogInformation("Increasing step count from {CurrentValue}", CurrentQuest.Step);
if (CurrentQuest.Step + 1 < questSequence.Steps.Count)
{
CurrentQuest.SetStep(CurrentQuest.Step + 1);
}
else
bool num = CurrentQuest.Step + 1 >= questSequence.Steps.Count;
if (num)
{
CurrentQuest.SetStep(255);
}
else
{
CurrentQuest.SetStep(CurrentQuest.Step + 1);
}
ResetAutoRefreshState();
if (num)
{
_logger.LogInformation("Completed last step in sequence, waiting for game to update sequence");
return;
}
}
using (_logger.BeginScope("IncStepCt"))
{
@ -1291,12 +1314,23 @@ internal sealed class QuestController : MiniTaskController<QuestController>
}
}
private void OnPlayerInputDetected(object? sender, EventArgs e)
{
if (AutomationType != EAutomationType.Manual && IsRunning)
{
_logger.LogInformation("Player input detected during movement, stopping quest automation");
_chatGui.Print("Player input detected - stopping quest automation.", "Questionable", 576);
Stop("Player input detected");
}
}
public override void Dispose()
{
_toastGui.ErrorToast -= base.OnErrorToast;
_toastGui.Toast -= OnNormalToast;
_condition.ConditionChange -= OnConditionChange;
_clientState.Logout -= OnLogout;
_movementController.PlayerInputDetected -= OnPlayerInputDetected;
base.Dispose();
}
}

File diff suppressed because it is too large Load diff

View file

@ -39,6 +39,10 @@ internal sealed class NavmeshIpc
private readonly ICallGateSubscriber<float> _buildProgress;
private readonly ICallGateSubscriber<bool> _navReload;
private readonly ICallGateSubscriber<bool> _navRebuild;
public bool IsReady
{
get
@ -115,6 +119,8 @@ internal sealed class NavmeshIpc
_queryPointOnFloor = pluginInterface.GetIpcSubscriber<Vector3, bool, float, Vector3?>("vnavmesh.Query.Mesh.PointOnFloor");
_queryNearestPoint = pluginInterface.GetIpcSubscriber<Vector3, float, float, Vector3?>("vnavmesh.Query.Mesh.NearestPoint");
_buildProgress = pluginInterface.GetIpcSubscriber<float>("vnavmesh.Nav.BuildProgress");
_navReload = pluginInterface.GetIpcSubscriber<bool>("vnavmesh.Nav.Reload");
_navRebuild = pluginInterface.GetIpcSubscriber<bool>("vnavmesh.Nav.Rebuild");
}
public void Stop()
@ -129,6 +135,30 @@ internal sealed class NavmeshIpc
}
}
public void Reload()
{
try
{
_navReload.InvokeFunc();
}
catch (IpcError exception)
{
_logger.LogWarning(exception, "Could not reload navmesh");
}
}
public void Rebuild()
{
try
{
_navRebuild.InvokeFunc();
}
catch (IpcError exception)
{
_logger.LogWarning(exception, "Could not rebuild navmesh");
}
}
public Task<List<Vector3>> Pathfind(Vector3 localPlayerPosition, Vector3 targetPosition, bool fly, CancellationToken cancellationToken)
{
try
@ -147,8 +177,8 @@ internal sealed class NavmeshIpc
{
try
{
_pathSetTolerance.InvokeAction(0.25f);
return _pluginInterface.GetIpcSubscriber<Vector3, Vector3, bool, float, CancellationToken, Task<List<Vector3>>>("vnavmesh.Nav.PathfindWithTolerance").InvokeFunc(localPlayerPosition, targetPosition, fly, tolerance, cancellationToken);
_pathSetTolerance.InvokeAction(tolerance);
return _pluginInterface.GetIpcSubscriber<Vector3, Vector3, bool, float, Task<List<Vector3>>>("vnavmesh.Nav.PathfindWithTolerance").InvokeFunc(localPlayerPosition, targetPosition, fly, tolerance);
}
catch (IpcError exception)
{

View file

@ -11,6 +11,7 @@ using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.System.Framework;
using FFXIVClientStructs.FFXIV.Client.System.Memory;
using FFXIVClientStructs.FFXIV.Client.System.String;
using FFXIVClientStructs.FFXIV.Client.UI;
using Lumina.Excel.Sheets;
using Microsoft.Extensions.Logging;
using Questionable.Model.Questing;
@ -21,11 +22,6 @@ internal sealed class ChatFunctions
{
private delegate void ProcessChatBoxDelegate(nint uiModule, nint message, nint unused, byte a4);
private static class Signatures
{
internal const string SendChat = "48 89 5C 24 ?? 48 89 74 24 ?? 57 48 83 EC 20 48 8B F2 48 8B F9 45 84 C9";
}
[StructLayout(LayoutKind.Explicit)]
private readonly struct ChatPayload : IDisposable
{
@ -72,7 +68,7 @@ internal sealed class ChatFunctions
_gameFunctions = gameFunctions;
_targetManager = targetManager;
_logger = logger;
_processChatBox = Marshal.GetDelegateForFunctionPointer<ProcessChatBoxDelegate>(sigScanner.ScanText("48 89 5C 24 ?? 48 89 74 24 ?? 57 48 83 EC 20 48 8B F2 48 8B F9 45 84 C9"));
_processChatBox = Marshal.GetDelegateForFunctionPointer<ProcessChatBoxDelegate>(UIModule.Addresses.ProcessChatBoxEntry.Value);
_emoteCommands = (from x in dataManager.GetExcelSheet<Emote>()
where x.RowId != 0
where x.TextCommand.IsValid

View file

@ -14,6 +14,7 @@ using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.Game.Control;
using FFXIVClientStructs.FFXIV.Client.Game.Event;
using FFXIVClientStructs.FFXIV.Client.Game.Fate;
using FFXIVClientStructs.FFXIV.Client.Game.Object;
using FFXIVClientStructs.FFXIV.Client.Game.UI;
@ -34,11 +35,6 @@ internal sealed class GameFunctions
{
private delegate void AbandonDutyDelegate(bool a1);
private static class Signatures
{
internal const string AbandonDuty = "E8 ?? ?? ?? ?? 41 B2 01 EB 39";
}
private readonly QuestFunctions _questFunctions;
private readonly IDataManager _dataManager;
@ -74,7 +70,7 @@ internal sealed class GameFunctions
_gameGui = gameGui;
_configuration = configuration;
_logger = logger;
_abandonDuty = Marshal.GetDelegateForFunctionPointer<AbandonDutyDelegate>(sigScanner.ScanText("E8 ?? ?? ?? ?? 41 B2 01 EB 39"));
_abandonDuty = Marshal.GetDelegateForFunctionPointer<AbandonDutyDelegate>(EventFramework.Addresses.LeaveCurrentContent.Value);
_territoryToAetherCurrentCompFlgSet = (from x in dataManager.GetExcelSheet<TerritoryType>()
where x.RowId != 0
where x.AetherCurrentCompFlgSet.RowId != 0

View file

@ -0,0 +1,5 @@
namespace Questionable.Functions;
internal static class GameSignatures
{
}

View file

@ -183,22 +183,28 @@ internal sealed class GeneralConfigComponent : ConfigComponent
base.Configuration.General.UseEscToCancelQuesting = v2;
Save();
}
bool v3 = base.Configuration.General.ShowIncompleteSeasonalEvents;
if (ImGui.Checkbox("Show details for incomplete seasonal events", ref v3))
bool v3 = base.Configuration.General.StopOnPlayerInput;
if (ImGui.Checkbox("Stop automation when manually moving character", ref v3))
{
base.Configuration.General.ShowIncompleteSeasonalEvents = v3;
base.Configuration.General.StopOnPlayerInput = v3;
Save();
}
bool v4 = base.Configuration.General.HideSeasonalEventsFromJournalProgress;
if (ImGui.Checkbox("Hide Seasonal Events from Journal Progress", ref v4))
bool v4 = base.Configuration.General.ShowIncompleteSeasonalEvents;
if (ImGui.Checkbox("Show details for incomplete seasonal events", ref v4))
{
base.Configuration.General.HideSeasonalEventsFromJournalProgress = v4;
base.Configuration.General.ShowIncompleteSeasonalEvents = v4;
Save();
}
bool v5 = base.Configuration.General.ShowChangelogOnUpdate;
if (ImGui.Checkbox("Show changelog window when plugin updates", ref v5))
bool v5 = base.Configuration.General.HideSeasonalEventsFromJournalProgress;
if (ImGui.Checkbox("Hide Seasonal Events from Journal Progress", ref v5))
{
base.Configuration.General.ShowChangelogOnUpdate = v5;
base.Configuration.General.HideSeasonalEventsFromJournalProgress = v5;
Save();
}
bool v6 = base.Configuration.General.ShowChangelogOnUpdate;
if (ImGui.Checkbox("Show changelog window when plugin updates", ref v6))
{
base.Configuration.General.ShowChangelogOnUpdate = v6;
Save();
}
}
@ -206,16 +212,16 @@ internal sealed class GeneralConfigComponent : ConfigComponent
ImGui.Text("Questing");
using (ImRaii.PushIndent())
{
bool v6 = base.Configuration.General.ConfigureTextAdvance;
if (ImGui.Checkbox("Automatically configure TextAdvance with the recommended settings", ref v6))
bool v7 = base.Configuration.General.ConfigureTextAdvance;
if (ImGui.Checkbox("Automatically configure TextAdvance with the recommended settings", ref v7))
{
base.Configuration.General.ConfigureTextAdvance = v6;
base.Configuration.General.ConfigureTextAdvance = v7;
Save();
}
bool v7 = base.Configuration.General.SkipLowPriorityDuties;
if (ImGui.Checkbox("Unlock certain optional dungeons and raids (instead of waiting for completion)", ref v7))
bool v8 = base.Configuration.General.SkipLowPriorityDuties;
if (ImGui.Checkbox("Unlock certain optional dungeons and raids (instead of waiting for completion)", ref v8))
{
base.Configuration.General.SkipLowPriorityDuties = v7;
base.Configuration.General.SkipLowPriorityDuties = v8;
Save();
}
ImGui.SameLine();
@ -243,10 +249,10 @@ internal sealed class GeneralConfigComponent : ConfigComponent
}
}
ImGui.Spacing();
bool v8 = base.Configuration.General.AutoStepRefreshEnabled;
if (ImGui.Checkbox("Automatically refresh quest steps when stuck", ref v8))
bool v9 = base.Configuration.General.AutoStepRefreshEnabled;
if (ImGui.Checkbox("Automatically refresh quest steps when stuck", ref v9))
{
base.Configuration.General.AutoStepRefreshEnabled = v8;
base.Configuration.General.AutoStepRefreshEnabled = v9;
Save();
}
ImGui.SameLine();
@ -262,20 +268,20 @@ internal sealed class GeneralConfigComponent : ConfigComponent
ImGui.Text("This helps resume automated quest completion when interruptions occur.");
}
}
using (ImRaii.Disabled(!v8))
using (ImRaii.Disabled(!v9))
{
ImGui.Indent();
int v9 = base.Configuration.General.AutoStepRefreshDelaySeconds;
int v10 = base.Configuration.General.AutoStepRefreshDelaySeconds;
ImGui.SetNextItemWidth(150f);
if (ImGui.SliderInt("Refresh delay (seconds)", ref v9, 10, 180))
if (ImGui.SliderInt("Refresh delay (seconds)", ref v10, 10, 180))
{
base.Configuration.General.AutoStepRefreshDelaySeconds = v9;
base.Configuration.General.AutoStepRefreshDelaySeconds = v10;
Save();
}
Vector4 col = new Vector4(0.7f, 0.7f, 0.7f, 1f);
ImU8String text2 = new ImU8String(77, 1);
text2.AppendLiteral("Quest steps will refresh automatically after ");
text2.AppendFormatted(v9);
text2.AppendFormatted(v10);
text2.AppendLiteral(" seconds if no progress is made.");
ImGui.TextColored(in col, text2);
ImGui.Unindent();
@ -283,16 +289,16 @@ internal sealed class GeneralConfigComponent : ConfigComponent
ImGui.Spacing();
ImGui.Separator();
ImGui.Text("Priority Quest Management");
bool v10 = base.Configuration.General.ClearPriorityQuestsOnLogout;
if (ImGui.Checkbox("Clear priority quests on character logout", ref v10))
bool v11 = base.Configuration.General.ClearPriorityQuestsOnLogout;
if (ImGui.Checkbox("Clear priority quests on character logout", ref v11))
{
base.Configuration.General.ClearPriorityQuestsOnLogout = v10;
base.Configuration.General.ClearPriorityQuestsOnLogout = v11;
Save();
}
bool v11 = base.Configuration.General.ClearPriorityQuestsOnCompletion;
if (ImGui.Checkbox("Remove priority quests when completed", ref v11))
bool v12 = base.Configuration.General.ClearPriorityQuestsOnCompletion;
if (ImGui.Checkbox("Remove priority quests when completed", ref v12))
{
base.Configuration.General.ClearPriorityQuestsOnCompletion = v11;
base.Configuration.General.ClearPriorityQuestsOnCompletion = v12;
Save();
}
ImGui.SameLine();

View file

@ -25,9 +25,9 @@ internal sealed class PluginConfigComponent : ConfigComponent
private static readonly IReadOnlyList<PluginInfo> RequiredPlugins = new global::_003C_003Ez__ReadOnlyArray<PluginInfo>(new PluginInfo[3]
{
new PluginInfo("vnavmesh", "vnavmesh", "vnavmesh handles the navigation within a zone, moving\nyour character to the next quest-related objective.", new Uri("https://github.com/awgil/ffxiv_navmesh/"), new Uri("https://puni.sh/api/repository/veyn")),
new PluginInfo("Lifestream", "Lifestream", "Used to travel to aethernet shards in cities.", new Uri("https://github.com/NightmareXIV/Lifestream"), new Uri("https://github.com/NightmareXIV/MyDalamudPlugins/raw/main/pluginmaster.json")),
new PluginInfo("TextAdvance", "TextAdvance", "Automatically accepts and turns in quests, skips cutscenes\nand dialogue.", new Uri("https://github.com/NightmareXIV/TextAdvance"), new Uri("https://github.com/NightmareXIV/MyDalamudPlugins/raw/main/pluginmaster.json"))
new PluginInfo("TextAdvance", "TextAdvance", "Automatically accepts and turns in quests, skips cutscenes\nand dialogue.", new Uri("https://github.com/NightmareXIV/TextAdvance"), new Uri("https://github.com/NightmareXIV/MyDalamudPlugins/raw/main/pluginmaster.json")),
new PluginInfo("vnavmesh", "vnavmesh", "vnavmesh handles the navigation within a zone, moving\nyour character to the next quest-related objective.", new Uri("https://github.com/awgil/ffxiv_navmesh/"), new Uri("https://puni.sh/api/repository/veyn"))
});
private static readonly ReadOnlyDictionary<Configuration.ECombatModule, PluginInfo> CombatPlugins = new Dictionary<Configuration.ECombatModule, PluginInfo>
@ -36,13 +36,13 @@ internal sealed class PluginConfigComponent : ConfigComponent
Questionable.Configuration.ECombatModule.BossMod,
new PluginInfo("Boss Mod (VBM)", "BossMod", string.Empty, new Uri("https://github.com/awgil/ffxiv_bossmod"), new Uri("https://puni.sh/api/repository/veyn"))
},
{
Questionable.Configuration.ECombatModule.WrathCombo,
new PluginInfo("Wrath Combo", "WrathCombo", string.Empty, new Uri("https://github.com/PunishXIV/WrathCombo"), new Uri("https://puni.sh/api/plugins"))
},
{
Questionable.Configuration.ECombatModule.RotationSolverReborn,
new PluginInfo("Rotation Solver Reborn", "RotationSolver", string.Empty, new Uri("https://github.com/FFXIV-CombatReborn/RotationSolverReborn"), new Uri("https://raw.githubusercontent.com/FFXIV-CombatReborn/CombatRebornRepo/main/pluginmaster.json"))
},
{
Questionable.Configuration.ECombatModule.WrathCombo,
new PluginInfo("Wrath Combo", "WrathCombo", string.Empty, new Uri("https://github.com/PunishXIV/WrathCombo"), new Uri("https://puni.sh/api/plugins"))
}
}.AsReadOnly();
@ -66,9 +66,10 @@ internal sealed class PluginConfigComponent : ConfigComponent
_pluginInterface = pluginInterface;
_uiUtils = uiUtils;
_commandManager = commandManager;
PluginInfo[] obj = new PluginInfo[5]
PluginInfo[] obj = new PluginInfo[6]
{
new PluginInfo("Artisan", "Artisan", "Handles automatic crafting for quests that require\ncrafted items.", new Uri("https://github.com/PunishXIV/Artisan"), new Uri("https://puni.sh/api/plugins")),
new PluginInfo("AutoDuty", "AutoDuty", "Automatically handles dungeon and trial completion during\nMain Scenario Quest progression.", new Uri("https://github.com/erdelf/AutoDuty"), new Uri("https://puni.sh/api/repository/erdelf")),
null,
null,
null,
@ -82,7 +83,8 @@ internal sealed class PluginConfigComponent : ConfigComponent
Span<PluginDetailInfo> span = CollectionsMarshal.AsSpan(list);
int index = 0;
span[index] = new PluginDetailInfo("'Sniper no sniping' enabled", "Automatically completes sniping tasks introduced in Stormblood", () => automatonIpc.IsAutoSnipeEnabled);
obj[1] = new PluginInfo("CBT (formerly known as Automaton)", "Automaton", "Automaton is a collection of automation-related tweaks.", websiteUri, dalamudRepositoryUri, "/cbt", list);
obj[2] = new PluginInfo("CBT (formerly known as Automaton)", "Automaton", "Automaton is a collection of automation-related tweaks.", websiteUri, dalamudRepositoryUri, "/cbt", list);
obj[3] = new PluginInfo("NotificationMaster", "NotificationMaster", "Sends a configurable out-of-game notification if a quest\nrequires manual actions.", new Uri("https://github.com/NightmareXIV/NotificationMaster"), null);
Uri websiteUri2 = new Uri("https://github.com/PunishXIV/PandorasBox");
Uri dalamudRepositoryUri2 = new Uri("https://puni.sh/api/plugins");
index = 1;
@ -91,9 +93,8 @@ internal sealed class PluginConfigComponent : ConfigComponent
span = CollectionsMarshal.AsSpan(list2);
num = 0;
span[num] = new PluginDetailInfo("'Auto Active Time Maneuver' enabled", "Automatically completes active time maneuvers in\nsingle player instances, trials and raids\"", () => pandorasBoxIpc.IsAutoActiveTimeManeuverEnabled);
obj[2] = new PluginInfo("Pandora's Box", "PandorasBox", "Pandora's Box is a collection of tweaks.", websiteUri2, dalamudRepositoryUri2, "/pandora", list2);
obj[3] = new PluginInfo("QuestMap", "QuestMap", "Displays quest objectives and markers on the map for\nbetter navigation and tracking.", new Uri("https://github.com/rreminy/QuestMap"), null);
obj[4] = new PluginInfo("NotificationMaster", "NotificationMaster", "Sends a configurable out-of-game notification if a quest\nrequires manual actions.", new Uri("https://github.com/NightmareXIV/NotificationMaster"), null);
obj[4] = new PluginInfo("Pandora's Box", "PandorasBox", "Pandora's Box is a collection of tweaks.", websiteUri2, dalamudRepositoryUri2, "/pandora", list2);
obj[5] = new PluginInfo("QuestMap", "QuestMap", "Displays quest objectives and markers on the map for\nbetter navigation and tracking.", new Uri("https://github.com/rreminy/QuestMap"), null);
_recommendedPlugins = new global::_003C_003Ez__ReadOnlyArray<PluginInfo>(obj);
}
@ -147,8 +148,8 @@ internal sealed class PluginConfigComponent : ConfigComponent
_pluginInterface.SavePluginConfig(_configuration);
}
allRequiredInstalled &= DrawCombatPlugin(Questionable.Configuration.ECombatModule.BossMod, checklistPadding);
allRequiredInstalled &= DrawCombatPlugin(Questionable.Configuration.ECombatModule.WrathCombo, checklistPadding);
allRequiredInstalled &= DrawCombatPlugin(Questionable.Configuration.ECombatModule.RotationSolverReborn, checklistPadding);
allRequiredInstalled &= DrawCombatPlugin(Questionable.Configuration.ECombatModule.WrathCombo, checklistPadding);
}
}
ImGui.Spacing();

View file

@ -8,7 +8,7 @@ using Dalamud.Interface.Colors;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Plugin;
using Dalamud.Plugin.Services;
using Dalamud.Utility.Signatures;
using FFXIVClientStructs.FFXIV.Client.Game;
using LLib.GameData;
using Lumina.Excel;
using Lumina.Excel.Sheets;
@ -84,12 +84,9 @@ internal sealed class GatheringJournalComponent
private string _searchText = string.Empty;
[Signature("48 89 5C 24 ?? 57 48 83 EC 20 8B D9 8B F9")]
private GetIsGatheringItemGatheredDelegate _getIsGatheringItemGathered;
private bool IsGatheringItemGathered(uint item)
private static bool IsGatheringItemGathered(uint item)
{
return _getIsGatheringItemGathered((ushort)item) != 0;
return QuestManager.IsGatheringItemGathered((ushort)item);
}
public GatheringJournalComponent(IDataManager dataManager, IDalamudPluginInterface pluginInterface, UiUtils uiUtils, IGameInteropProvider gameInteropProvider, GatheringPointRegistry gatheringPointRegistry)

View file

@ -34,7 +34,7 @@ internal sealed class Configuration : IPluginConfiguration
public bool AutoStepRefreshEnabled { get; set; } = true;
public int AutoStepRefreshDelaySeconds { get; set; } = 10;
public int AutoStepRefreshDelaySeconds { get; set; } = 60;
public bool HideSeasonalEventsFromJournalProgress { get; set; }
@ -43,6 +43,8 @@ internal sealed class Configuration : IPluginConfiguration
public bool ClearPriorityQuestsOnCompletion { get; set; }
public bool ShowChangelogOnUpdate { get; set; } = true;
public bool StopOnPlayerInput { get; set; }
}
internal sealed class StopConfiguration

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,6 +7,13 @@ namespace QuestionableCompanion.Services;
public class CombatDutyDetectionService : IDisposable
{
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

@ -552,12 +552,19 @@ public class ExecutionService : IDisposable
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...");

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,22 +105,203 @@ 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);
List<(string Name, ushort WorldId)> helpersToInvite = new List<(string, ushort)>();
if (configuration.HelperSelection == HelperSelectionMode.Auto)
{
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
{
log.Information($"[HelperManager] Inviting {availableHelpers.Count} AUTO-DISCOVERED helper(s)...");
DisbandParty();
await Task.Delay(500);
foreach (var (name, worldId) in availableHelpers)
}
}
else
{
DisbandParty();
await Task.Delay(500);
}
foreach (var (name, worldId) in helpersToInvite)
{
if (string.IsNullOrEmpty(name) || worldId == 0)
{
@ -147,13 +338,23 @@ 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;
@ -344,9 +545,28 @@ public class HelperManager : IDisposable
{
GroupManager.Group* group = groupManager->GetGroup();
if (group != null && group->MemberCount > 0)
{
bool requesterInParty = false;
if (partyList != null)
{
foreach (IPartyMember member in partyList)
{
if (member.Name.ToString() == characterName && member.World.RowId == worldId)
{
requesterInParty = true;
break;
}
}
}
if (requesterInParty)
{
log.Information($"[HelperManager] Request from {characterName}@{worldId} who is ALREADY in my party! Ignoring leave request.");
needsToLeaveParty = false;
}
else
{
needsToLeaveParty = true;
log.Information("[HelperManager] Currently in party, notifying quester...");
log.Information("[HelperManager] Currently in party (but not with requester), notifying quester...");
crossProcessIPC.NotifyHelperInParty(localName, localWorldId);
if (condition[ConditionFlag.BoundByDuty])
{
@ -356,6 +576,7 @@ public class HelperManager : IDisposable
}
}
}
}
if (!isInDuty)
{
if (needsToLeaveParty)
@ -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,11 +48,34 @@ 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;
}
else if (shouldAutoAccept)
{
if (hasLoggedAlwaysAccept)
{
log.Information("[PartyInviteAutoAccept] Always auto-accept disabled");
hasLoggedAlwaysAccept = false;
}
if (DateTime.Now > autoAcceptUntil)
{
@ -59,6 +83,17 @@ public class PartyInviteAutoAccept : IDisposable
log.Information("[PartyInviteAutoAccept] Auto-accept window expired");
return;
}
shouldAcceptNow = true;
}
else if (hasLoggedAlwaysAccept)
{
log.Information("[PartyInviteAutoAccept] Always auto-accept disabled");
hasLoggedAlwaysAccept = false;
}
if (!shouldAcceptNow)
{
return;
}
try
{
string[] obj = new string[6] { "SelectYesno", "SelectYesNo", "_PartyInvite", "PartyInvite", "SelectString", "_Notification" };
@ -72,54 +107,22 @@ 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
};
addon2->FireCallback(1u, values);
addon->FireCallback(1u, values);
AtkValue* values2 = stackalloc AtkValue[2];
*values2 = new AtkValue
{
@ -131,7 +134,8 @@ public class PartyInviteAutoAccept : IDisposable
Type = FFXIVClientStructs.FFXIV.Component.GUI.ValueType.UInt,
UInt = 0u
};
addon2->FireCallback(2u, values2);
addon->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 + "...");
if (errorRecoveryService.RequestRelog())
{
errorRecoveryService.Reset();
currentState.Phase = RotationPhase.WaitingForCharacterLogin;
currentState.CurrentCharacter = charToRelog;
currentState.PhaseStartTime = DateTime.Now;
autoRetainerIpc.SwitchCharacter(charToRelog);
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,13 +684,7 @@ public class QuestRotationExecutionService : IDisposable
{
deathHandler.Update();
}
if (dungeonAutomation != null)
{
if (submarineManager.IsSubmarinePaused)
{
log.Debug("[QuestRotation] Submarine multi-mode active - skipping dungeon validation");
}
else
if (dungeonAutomation != null && !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)
@ -666,7 +692,6 @@ public class QuestRotationExecutionService : IDisposable
_ = submarineManager.IsSubmarinePaused;
}
}
}
if (combatDutyDetection != null)
{
combatDutyDetection.Update();
@ -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,6 +1358,8 @@ public class QuestRotationExecutionService : IDisposable
{
return;
}
if (configuration.ReturnToHomeworldOnStopQuest)
{
log.Information("[QuestRotation] ========================================");
log.Information("[QuestRotation] === SENDING HOMEWORLD RETURN COMMAND ===");
log.Information("[QuestRotation] ========================================");
@ -1327,6 +1372,11 @@ public class QuestRotationExecutionService : IDisposable
{
log.Error("[QuestRotation] Failed to send /li command: " + ex.Message);
}
}
else
{
log.Information("[QuestRotation] Skipping homeworld return (setting disabled)");
}
Task.Delay(2000).ContinueWith(delegate
{
framework.RunOnFrameworkThread(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})");
}
}
}
{

View file

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

View file

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

View file

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

View file

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