qstcompanion v1.0.1

This commit is contained in:
alydev 2025-12-04 04:39:08 +10:00
parent 3e10cbbbf2
commit 44c67ab71b
79 changed files with 21148 additions and 0 deletions

View file

@ -0,0 +1,8 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
[StructLayout(LayoutKind.Auto)]
[InlineArray(5)]
internal struct _003C_003Ey__InlineArray5<T>
{
}

View file

@ -0,0 +1,8 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
[StructLayout(LayoutKind.Auto)]
[InlineArray(7)]
internal struct _003C_003Ey__InlineArray7<T>
{
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>QSTCompanion</AssemblyName>
<GenerateAssemblyInfo>False</GenerateAssemblyInfo>
<TargetFramework>netcoreapp9.0</TargetFramework>
<PlatformTarget>x64</PlatformTarget>
</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>
<Reference Include="Newtonsoft.Json">
<HintPath>..\..\..\..\..\ffxiv\alyssile-xivl\addon\Hooks\dev\Newtonsoft.Json.dll</HintPath>
</Reference>
<Reference Include="Lumina">
<HintPath>..\..\..\..\..\ffxiv\alyssile-xivl\addon\Hooks\dev\Lumina.dll</HintPath>
</Reference>
<Reference Include="Lumina.Excel">
<HintPath>..\..\..\..\..\ffxiv\alyssile-xivl\addon\Hooks\dev\Lumina.Excel.dll</HintPath>
</Reference>
<Reference Include="FFXIVClientStructs">
<HintPath>..\..\..\..\..\ffxiv\alyssile-xivl\addon\Hooks\dev\FFXIVClientStructs.dll</HintPath>
</Reference>
<Reference Include="Dalamud.Bindings.ImGui">
<HintPath>..\..\..\..\..\ffxiv\alyssile-xivl\addon\Hooks\dev\Dalamud.Bindings.ImGui.dll</HintPath>
</Reference>
<Reference Include="InteropGenerator.Runtime">
<HintPath>..\..\..\..\..\ffxiv\alyssile-xivl\addon\Hooks\dev\InteropGenerator.Runtime.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View file

@ -0,0 +1,18 @@
namespace QuestionableCompanion.Data;
public class ExpansionProgress
{
public MSQExpansionData.Expansion Expansion { get; set; }
public int CompletedCount { get; set; }
public int ExpectedCount { get; set; }
public float Percentage { get; set; }
public bool IsComplete { get; set; }
public string ExpansionName => MSQExpansionData.GetExpansionName(Expansion);
public string ExpansionShortName => MSQExpansionData.GetExpansionShortName(Expansion);
}

View file

@ -0,0 +1,479 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
namespace QuestionableCompanion.Data;
public static class MSQExpansionData
{
public enum Expansion
{
ARealmReborn,
Heavensward,
Stormblood,
Shadowbringers,
Endwalker,
Dawntrail
}
private static readonly Dictionary<Expansion, HashSet<uint>> ExpansionQuests = new Dictionary<Expansion, HashSet<uint>>
{
{
Expansion.ARealmReborn,
new HashSet<uint>()
},
{
Expansion.Heavensward,
new HashSet<uint>()
},
{
Expansion.Stormblood,
new HashSet<uint>()
},
{
Expansion.Shadowbringers,
new HashSet<uint>()
},
{
Expansion.Endwalker,
new HashSet<uint>()
},
{
Expansion.Dawntrail,
new HashSet<uint>()
}
};
private static readonly Dictionary<Expansion, int> ExpectedQuestCounts = new Dictionary<Expansion, int>
{
{
Expansion.ARealmReborn,
200
},
{
Expansion.Heavensward,
100
},
{
Expansion.Stormblood,
100
},
{
Expansion.Shadowbringers,
100
},
{
Expansion.Endwalker,
100
},
{
Expansion.Dawntrail,
100
}
};
private static readonly Dictionary<Expansion, string> ExpansionNames = new Dictionary<Expansion, string>
{
{
Expansion.ARealmReborn,
"A Realm Reborn"
},
{
Expansion.Heavensward,
"Heavensward"
},
{
Expansion.Stormblood,
"Stormblood"
},
{
Expansion.Shadowbringers,
"Shadowbringers"
},
{
Expansion.Endwalker,
"Endwalker"
},
{
Expansion.Dawntrail,
"Dawntrail"
}
};
private static readonly Dictionary<Expansion, string> ExpansionShortNames = new Dictionary<Expansion, string>
{
{
Expansion.ARealmReborn,
"ARR"
},
{
Expansion.Heavensward,
"HW"
},
{
Expansion.Stormblood,
"SB"
},
{
Expansion.Shadowbringers,
"ShB"
},
{
Expansion.Endwalker,
"EW"
},
{
Expansion.Dawntrail,
"DT"
}
};
public static void RegisterQuest(uint questId, Expansion expansion)
{
if (ExpansionQuests.TryGetValue(expansion, out HashSet<uint> quests))
{
quests.Add(questId);
}
}
public static void ClearQuests()
{
foreach (HashSet<uint> value in ExpansionQuests.Values)
{
value.Clear();
}
}
public static Expansion GetExpansionForQuest(uint questId)
{
foreach (var (expansion2, hashSet2) in ExpansionQuests)
{
if (hashSet2.Contains(questId))
{
return expansion2;
}
}
return Expansion.ARealmReborn;
}
public static IReadOnlySet<uint> GetQuestsForExpansion(Expansion expansion)
{
if (!ExpansionQuests.TryGetValue(expansion, out HashSet<uint> quests))
{
return new HashSet<uint>();
}
return quests;
}
public static int GetExpectedQuestCount(Expansion expansion)
{
if (!ExpectedQuestCounts.TryGetValue(expansion, out var count))
{
return 0;
}
return count;
}
public static string GetExpansionName(Expansion expansion)
{
if (!ExpansionNames.TryGetValue(expansion, out string name))
{
return "Unknown";
}
return name;
}
public static string GetExpansionShortName(Expansion expansion)
{
if (!ExpansionShortNames.TryGetValue(expansion, out string name))
{
return "???";
}
return name;
}
public static IEnumerable<Expansion> GetAllExpansions()
{
return from e in Enum.GetValues<Expansion>()
orderby (int)e
select e;
}
public static int GetCompletedQuestCountForExpansion(IEnumerable<uint> completedQuestIds, Expansion expansion)
{
IReadOnlySet<uint> expansionQuests = GetQuestsForExpansion(expansion);
return completedQuestIds.Count((uint qId) => expansionQuests.Contains(qId));
}
public unsafe static (Expansion expansion, string debugInfo) GetCurrentExpansionFromGameWithDebug()
{
StringBuilder debug = new StringBuilder();
debug.AppendLine("=== AGENT SCENARIO TREE DEBUG ===");
try
{
AgentScenarioTree* agentScenarioTree = AgentScenarioTree.Instance();
StringBuilder stringBuilder = debug;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(30, 1, stringBuilder);
handler.AppendLiteral("AgentScenarioTree.Instance(): ");
handler.AppendFormatted((agentScenarioTree != null) ? "OK" : "NULL");
stringBuilder2.AppendLine(ref handler);
if (agentScenarioTree == null)
{
debug.AppendLine("ERROR: AgentScenarioTree is NULL!");
return (expansion: Expansion.ARealmReborn, debugInfo: debug.ToString());
}
stringBuilder = debug;
StringBuilder stringBuilder3 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(25, 1, stringBuilder);
handler.AppendLiteral("AgentScenarioTree->Data: ");
handler.AppendFormatted((agentScenarioTree->Data != null) ? "OK" : "NULL");
stringBuilder3.AppendLine(ref handler);
if (agentScenarioTree->Data == null)
{
debug.AppendLine("ERROR: AgentScenarioTree->Data is NULL!");
return (expansion: Expansion.ARealmReborn, debugInfo: debug.ToString());
}
ushort currentQuest = agentScenarioTree->Data->CurrentScenarioQuest;
ushort completedQuest = agentScenarioTree->Data->CompleteScenarioQuest;
stringBuilder = debug;
StringBuilder stringBuilder4 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(33, 2, stringBuilder);
handler.AppendLiteral("CurrentScenarioQuest (raw): ");
handler.AppendFormatted(currentQuest);
handler.AppendLiteral(" (0x");
handler.AppendFormatted(currentQuest, "X4");
handler.AppendLiteral(")");
stringBuilder4.AppendLine(ref handler);
stringBuilder = debug;
StringBuilder stringBuilder5 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(34, 2, stringBuilder);
handler.AppendLiteral("CompleteScenarioQuest (raw): ");
handler.AppendFormatted(completedQuest);
handler.AppendLiteral(" (0x");
handler.AppendFormatted(completedQuest, "X4");
handler.AppendLiteral(")");
stringBuilder5.AppendLine(ref handler);
ushort questToCheck = ((currentQuest != 0) ? currentQuest : completedQuest);
stringBuilder = debug;
StringBuilder stringBuilder6 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(25, 2, stringBuilder);
handler.AppendLiteral("Quest to check: ");
handler.AppendFormatted(questToCheck);
handler.AppendLiteral(" (using ");
handler.AppendFormatted((currentQuest != 0) ? "Current" : "Completed");
handler.AppendLiteral(")");
stringBuilder6.AppendLine(ref handler);
if (questToCheck == 0)
{
debug.AppendLine("WARNING: Both CurrentScenarioQuest and CompleteScenarioQuest are 0!");
return (expansion: Expansion.ARealmReborn, debugInfo: debug.ToString());
}
uint questId = (uint)(questToCheck | 0x10000);
stringBuilder = debug;
StringBuilder stringBuilder7 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(25, 2, stringBuilder);
handler.AppendLiteral("Converted Quest ID: ");
handler.AppendFormatted(questId);
handler.AppendLiteral(" (0x");
handler.AppendFormatted(questId, "X8");
handler.AppendLiteral(")");
stringBuilder7.AppendLine(ref handler);
Expansion expansion = GetExpansionForQuest(questId);
stringBuilder = debug;
StringBuilder stringBuilder8 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(22, 2, stringBuilder);
handler.AppendLiteral("Expansion for Quest ");
handler.AppendFormatted(questId);
handler.AppendLiteral(": ");
handler.AppendFormatted(GetExpansionName(expansion));
stringBuilder8.AppendLine(ref handler);
IReadOnlySet<uint> expansionQuests = GetQuestsForExpansion(expansion);
bool isRegistered = expansionQuests.Contains(questId);
stringBuilder = debug;
StringBuilder stringBuilder9 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(23, 3, stringBuilder);
handler.AppendLiteral("Quest ");
handler.AppendFormatted(questId);
handler.AppendLiteral(" registered in ");
handler.AppendFormatted(expansion);
handler.AppendLiteral(": ");
handler.AppendFormatted(isRegistered);
stringBuilder9.AppendLine(ref handler);
if (!isRegistered)
{
stringBuilder = debug;
StringBuilder stringBuilder10 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(48, 1, stringBuilder);
handler.AppendLiteral("WARNING: Quest ");
handler.AppendFormatted(questId);
handler.AppendLiteral(" is NOT in our registered quests!");
stringBuilder10.AppendLine(ref handler);
stringBuilder = debug;
StringBuilder stringBuilder11 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(30, 2, stringBuilder);
handler.AppendLiteral("Total registered quests for ");
handler.AppendFormatted(expansion);
handler.AppendLiteral(": ");
handler.AppendFormatted(expansionQuests.Count);
stringBuilder11.AppendLine(ref handler);
foreach (Expansion exp in GetAllExpansions())
{
if (GetQuestsForExpansion(exp).Contains(questId))
{
stringBuilder = debug;
StringBuilder stringBuilder12 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(10, 1, stringBuilder);
handler.AppendLiteral("FOUND in ");
handler.AppendFormatted(exp);
handler.AppendLiteral("!");
stringBuilder12.AppendLine(ref handler);
expansion = exp;
break;
}
}
}
stringBuilder = debug;
StringBuilder stringBuilder13 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(25, 1, stringBuilder);
handler.AppendLiteral(">>> FINAL EXPANSION: ");
handler.AppendFormatted(GetExpansionName(expansion));
handler.AppendLiteral(" <<<");
stringBuilder13.AppendLine(ref handler);
return (expansion: expansion, debugInfo: debug.ToString());
}
catch (Exception ex)
{
StringBuilder stringBuilder = debug;
StringBuilder stringBuilder14 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(11, 1, stringBuilder);
handler.AppendLiteral("EXCEPTION: ");
handler.AppendFormatted(ex.Message);
stringBuilder14.AppendLine(ref handler);
stringBuilder = debug;
StringBuilder stringBuilder15 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(7, 1, stringBuilder);
handler.AppendLiteral("Stack: ");
handler.AppendFormatted(ex.StackTrace);
stringBuilder15.AppendLine(ref handler);
return (expansion: Expansion.ARealmReborn, debugInfo: debug.ToString());
}
}
public static Expansion GetCurrentExpansionFromGame()
{
return GetCurrentExpansionFromGameWithDebug().expansion;
}
public static Expansion GetCurrentExpansion(IEnumerable<uint> completedQuestIds)
{
List<uint> questList = completedQuestIds.ToList();
if (questList.Count == 0)
{
return Expansion.ARealmReborn;
}
foreach (Expansion expansion in GetAllExpansions().Reverse().ToList())
{
IReadOnlySet<uint> expansionQuests = GetQuestsForExpansion(expansion);
if (questList.Where((uint qId) => expansionQuests.Contains(qId)).ToList().Count > 0)
{
return expansion;
}
}
return Expansion.ARealmReborn;
}
public static string GetExpansionDetectionDebugInfo(IEnumerable<uint> completedQuestIds)
{
List<uint> questList = completedQuestIds.ToList();
StringBuilder result = new StringBuilder();
result.AppendLine("=== EXPANSION DETECTION DEBUG ===");
StringBuilder stringBuilder = result;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(24, 1, stringBuilder);
handler.AppendLiteral("Total completed quests: ");
handler.AppendFormatted(questList.Count);
stringBuilder2.AppendLine(ref handler);
result.AppendLine("");
result.AppendLine("Checking expansions from highest to lowest:");
result.AppendLine("");
foreach (Expansion expansion in GetAllExpansions().Reverse())
{
IReadOnlySet<uint> expansionQuests = GetQuestsForExpansion(expansion);
List<uint> completedInExpansion = questList.Where((uint qId) => expansionQuests.Contains(qId)).ToList();
float percentage = ((expansionQuests.Count > 0) ? ((float)completedInExpansion.Count / (float)expansionQuests.Count * 100f) : 0f);
stringBuilder = result;
StringBuilder stringBuilder3 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(4, 2, stringBuilder);
handler.AppendFormatted(GetExpansionName(expansion));
handler.AppendLiteral(" (");
handler.AppendFormatted(GetExpansionShortName(expansion));
handler.AppendLiteral("):");
stringBuilder3.AppendLine(ref handler);
stringBuilder = result;
StringBuilder stringBuilder4 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(28, 1, stringBuilder);
handler.AppendLiteral(" - Total MSQ in expansion: ");
handler.AppendFormatted(expansionQuests.Count);
stringBuilder4.AppendLine(ref handler);
stringBuilder = result;
StringBuilder stringBuilder5 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(32, 2, stringBuilder);
handler.AppendLiteral(" - Completed by character: ");
handler.AppendFormatted(completedInExpansion.Count);
handler.AppendLiteral(" (");
handler.AppendFormatted(percentage, "F1");
handler.AppendLiteral("%)");
stringBuilder5.AppendLine(ref handler);
if (completedInExpansion.Count > 0)
{
string samples = string.Join(", ", completedInExpansion.OrderByDescending((uint x) => x).Take(5));
stringBuilder = result;
StringBuilder stringBuilder6 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(22, 1, stringBuilder);
handler.AppendLiteral(" - Sample Quest IDs: ");
handler.AppendFormatted(samples);
stringBuilder6.AppendLine(ref handler);
result.AppendLine(" >>> HAS COMPLETED QUESTS - WOULD SELECT THIS EXPANSION <<<");
}
else
{
result.AppendLine(" - No quests completed in this expansion");
}
result.AppendLine("");
}
Expansion currentExpansion = GetCurrentExpansion(questList);
result.AppendLine("===========================================");
stringBuilder = result;
StringBuilder stringBuilder7 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(34, 1, stringBuilder);
handler.AppendLiteral(">>> FINAL DETECTED EXPANSION: ");
handler.AppendFormatted(GetExpansionName(currentExpansion));
handler.AppendLiteral(" <<<");
stringBuilder7.AppendLine(ref handler);
result.AppendLine("===========================================");
return result.ToString();
}
public static ExpansionProgress GetExpansionProgress(IEnumerable<uint> completedQuestIds, Expansion expansion)
{
int completed = GetCompletedQuestCountForExpansion(completedQuestIds, expansion);
int expected = GetExpectedQuestCount(expansion);
return new ExpansionProgress
{
Expansion = expansion,
CompletedCount = completed,
ExpectedCount = expected,
Percentage = ((expected > 0) ? ((float)completed / (float)expected * 100f) : 0f),
IsComplete = (completed >= expected)
};
}
public static List<ExpansionProgress> GetAllExpansionProgress(IEnumerable<uint> completedQuestIds)
{
return (from exp in GetAllExpansions()
select GetExpansionProgress(completedQuestIds, exp)).ToList();
}
}

View file

@ -0,0 +1,22 @@
using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Dalamud.Bindings.ImGui;
namespace QuestionableCompanion.Helpers;
public static class ImGuiDragDrop
{
public static void SetDragDropPayload<T>(string type, T data, ImGuiCond cond = ImGuiCond.None) where T : struct
{
ReadOnlySpan<byte> span = MemoryMarshal.AsBytes(MemoryMarshal.CreateReadOnlySpan(in data, 1));
ImGui.SetDragDropPayload(type, span, cond);
}
public unsafe static bool AcceptDragDropPayload<T>(string type, out T payload, ImGuiDragDropFlags flags = ImGuiDragDropFlags.None) where T : struct
{
ImGuiPayload* pload = ImGui.AcceptDragDropPayload(type, flags);
payload = ((pload != null) ? Unsafe.Read<T>(pload->Data) : default(T));
return pload != null;
}
}

View file

@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
namespace QuestionableCompanion.Models;
public class AlliedSocietyCharacterStatus
{
public required string CharacterId { get; set; }
public AlliedSocietyRotationStatus Status { get; set; }
public DateTime? LastCompletionDate { get; set; }
public List<string> ImportedQuestIds { get; set; } = new List<string>();
}

View file

@ -0,0 +1,24 @@
using System.Collections.Generic;
namespace QuestionableCompanion.Models;
public class AlliedSocietyConfiguration
{
public List<AlliedSocietyPriority> Priorities { get; set; } = new List<AlliedSocietyPriority>();
public AlliedSocietyQuestMode QuestMode { get; set; }
public void InitializeDefaults()
{
Priorities.Clear();
for (byte i = 1; i <= 20; i++)
{
Priorities.Add(new AlliedSocietyPriority
{
SocietyId = i,
Enabled = true,
Order = i - 1
});
}
}
}

View file

@ -0,0 +1,10 @@
namespace QuestionableCompanion.Models;
public class AlliedSocietyPriority
{
public required byte SocietyId { get; set; }
public bool Enabled { get; set; }
public int Order { get; set; }
}

View file

@ -0,0 +1,12 @@
namespace QuestionableCompanion.Models;
public class AlliedSocietyProgress
{
public required string CharacterId { get; set; }
public required byte SocietyId { get; set; }
public int CurrentRank { get; set; }
public bool IsMaxRank { get; set; }
}

View file

@ -0,0 +1,7 @@
namespace QuestionableCompanion.Models;
public enum AlliedSocietyQuestMode
{
OnlyThreePerSociety,
AllAvailableQuests
}

View file

@ -0,0 +1,13 @@
namespace QuestionableCompanion.Models;
public enum AlliedSocietyRotationPhase
{
Idle,
StartingRotation,
ImportingQuests,
WaitingForQuestAccept,
MonitoringQuests,
CheckingCompletion,
WaitingForCharacterSwitch,
Completed
}

View file

@ -0,0 +1,8 @@
namespace QuestionableCompanion.Models;
public enum AlliedSocietyRotationStatus
{
Ready,
InProgress,
Complete
}

View file

@ -0,0 +1,25 @@
using System;
namespace QuestionableCompanion.Models;
[Serializable]
public class CharacterProgressInfo
{
public string World { get; set; } = "Unknown";
public uint LastQuestId { get; set; }
public string LastQuestName { get; set; } = "—";
public int CompletedQuestCount { get; set; }
public DateTime LastUpdatedUtc { get; set; } = DateTime.MinValue;
public uint LastCompletedMSQId { get; set; }
public string LastCompletedMSQName { get; set; } = "—";
public int CompletedMSQCount { get; set; }
public float MSQCompletionPercentage { get; set; }
}

View file

@ -0,0 +1,23 @@
using System;
namespace QuestionableCompanion.Models;
[Serializable]
public class ExecutionState
{
public string ActiveProfile { get; set; } = string.Empty;
public string CurrentCharacter { get; set; } = string.Empty;
public uint CurrentQuestId { get; set; }
public string CurrentQuestName { get; set; } = string.Empty;
public string CurrentSequence { get; set; } = string.Empty;
public ExecutionStatus Status { get; set; }
public int Progress { get; set; }
public DateTime LastUpdate { get; set; } = DateTime.Now;
}

View file

@ -0,0 +1,11 @@
namespace QuestionableCompanion.Models;
public enum ExecutionStatus
{
Idle,
Waiting,
Queued,
Running,
Complete,
Failed
}

View file

@ -0,0 +1,30 @@
using System;
namespace QuestionableCompanion.Models;
[Serializable]
public class LogEntry
{
public DateTime Timestamp { get; set; } = DateTime.Now;
public LogLevel Level { get; set; }
public string Message { get; set; } = string.Empty;
public string FormattedTimestamp => Timestamp.ToString("HH:mm:ss");
public string FormattedMessage => $"[{FormattedTimestamp}] {GetLevelIcon()} {Message}";
private string GetLevelIcon()
{
return Level switch
{
LogLevel.Info => "→",
LogLevel.Success => "✓",
LogLevel.Warning => "⏳",
LogLevel.Error => "✗",
LogLevel.Debug => "▶",
_ => "•",
};
}
}

View file

@ -0,0 +1,10 @@
namespace QuestionableCompanion.Models;
public enum LogLevel
{
Info,
Success,
Warning,
Error,
Debug
}

View file

@ -0,0 +1,19 @@
using System;
namespace QuestionableCompanion.Models;
[Serializable]
public class QuestConfig
{
public uint QuestId { get; set; }
public string QuestName { get; set; } = string.Empty;
public TriggerType TriggerType { get; set; } = TriggerType.OnComplete;
public SequenceConfig SequenceAfterQuest { get; set; } = new SequenceConfig();
public string NextCharacter { get; set; } = "auto_next";
public string AssignedCharacter { get; set; } = string.Empty;
}

View file

@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
namespace QuestionableCompanion.Models;
[Serializable]
public class QuestProfile
{
public string Name { get; set; } = "New Profile";
public List<string> Characters { get; set; } = new List<string>();
public List<QuestConfig> Quests { get; set; } = new List<QuestConfig>();
public bool IsActive { get; set; }
}

View file

@ -0,0 +1,25 @@
namespace QuestionableCompanion.Models;
public enum RotationPhase
{
Idle,
InitializingFirstCharacter,
WaitingForCharacterLogin,
ScanningQuests,
CheckingQuestCompletion,
DCTraveling,
WaitingForQuestStart,
Questing,
InCombat,
InDungeon,
HandlingSubmarines,
SyncingCharacterData,
WaitingForChauffeur,
TravellingWithChauffeur,
QuestActive,
WaitingForNextCharacterSwitch,
WaitingBeforeCharacterSwitch,
WaitingForHomeworldReturn,
Completed,
Error
}

View file

@ -0,0 +1,249 @@
using System;
using System.Collections.Generic;
namespace QuestionableCompanion.Models;
public class RotationState
{
private readonly object _lock = new object();
private uint _currentStopQuestId;
private List<string> _selectedCharacters = new List<string>();
private string _currentCharacter = "";
private string _nextCharacter = "";
private List<string> _completedCharacters = new List<string>();
private List<string> _remainingCharacters = new List<string>();
private RotationPhase _phase;
private DateTime _phaseStartTime = DateTime.Now;
private string _errorMessage = "";
private DateTime? _rotationStartTime;
private bool _hasQuestBeenAccepted;
private uint? _lastKnownQuestState;
public uint CurrentStopQuestId
{
get
{
lock (_lock)
{
return _currentStopQuestId;
}
}
set
{
lock (_lock)
{
_currentStopQuestId = value;
}
}
}
public List<string> SelectedCharacters
{
get
{
lock (_lock)
{
return new List<string>(_selectedCharacters);
}
}
set
{
lock (_lock)
{
_selectedCharacters = new List<string>(value);
}
}
}
public string CurrentCharacter
{
get
{
lock (_lock)
{
return _currentCharacter;
}
}
set
{
lock (_lock)
{
_currentCharacter = value;
}
}
}
public string NextCharacter
{
get
{
lock (_lock)
{
return _nextCharacter;
}
}
set
{
lock (_lock)
{
_nextCharacter = value;
}
}
}
public List<string> CompletedCharacters
{
get
{
lock (_lock)
{
return new List<string>(_completedCharacters);
}
}
set
{
lock (_lock)
{
_completedCharacters = new List<string>(value);
}
}
}
public List<string> RemainingCharacters
{
get
{
lock (_lock)
{
return new List<string>(_remainingCharacters);
}
}
set
{
lock (_lock)
{
_remainingCharacters = new List<string>(value);
}
}
}
public RotationPhase Phase
{
get
{
lock (_lock)
{
return _phase;
}
}
set
{
lock (_lock)
{
_phase = value;
}
}
}
public DateTime PhaseStartTime
{
get
{
lock (_lock)
{
return _phaseStartTime;
}
}
set
{
lock (_lock)
{
_phaseStartTime = value;
}
}
}
public string ErrorMessage
{
get
{
lock (_lock)
{
return _errorMessage;
}
}
set
{
lock (_lock)
{
_errorMessage = value;
}
}
}
public DateTime? RotationStartTime
{
get
{
lock (_lock)
{
return _rotationStartTime;
}
}
set
{
lock (_lock)
{
_rotationStartTime = value;
}
}
}
public bool HasQuestBeenAccepted
{
get
{
lock (_lock)
{
return _hasQuestBeenAccepted;
}
}
set
{
lock (_lock)
{
_hasQuestBeenAccepted = value;
}
}
}
public uint? LastKnownQuestState
{
get
{
lock (_lock)
{
return _lastKnownQuestState;
}
}
set
{
lock (_lock)
{
_lastKnownQuestState = value;
}
}
}
}

View file

@ -0,0 +1,13 @@
using System;
namespace QuestionableCompanion.Models;
[Serializable]
public class SequenceConfig
{
public SequenceType Type { get; set; }
public string Value { get; set; } = string.Empty;
public bool WaitForCompletion { get; set; } = true;
}

View file

@ -0,0 +1,7 @@
namespace QuestionableCompanion.Models;
public enum SequenceType
{
QuestionableProfile,
InternalAction
}

View file

@ -0,0 +1,30 @@
using System;
using System.Text.Json.Serialization;
namespace QuestionableCompanion.Models;
[Serializable]
public class StopPoint
{
public uint QuestId { get; set; }
[JsonInclude]
[JsonPropertyName("Sequence")]
public byte? Sequence { get; set; }
public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.Now;
public string DisplayName
{
get
{
if (Sequence.HasValue)
{
return $"Quest {QuestId} (Seq {Sequence.Value})";
}
return $"Quest {QuestId}";
}
}
}

View file

@ -0,0 +1,7 @@
namespace QuestionableCompanion.Models;
public enum TriggerType
{
OnAccept,
OnComplete
}

View file

@ -0,0 +1,454 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using Dalamud.Plugin.Services;
using Newtonsoft.Json.Linq;
namespace QuestionableCompanion.Services;
public class ARPostProcessEventQuestService : IDisposable
{
private readonly IDalamudPluginInterface pluginInterface;
private readonly QuestionableIPC questionableIPC;
private readonly EventQuestResolver eventQuestResolver;
private readonly Configuration configuration;
private readonly IPluginLog log;
private readonly IFramework framework;
private readonly ICommandManager commandManager;
private readonly LifestreamIPC lifestreamIPC;
private ICallGateSubscriber<object>? characterAdditionalTaskSubscriber;
private ICallGateSubscriber<string, object>? characterPostProcessSubscriber;
private Action? characterAdditionalTaskHandler;
private Action<string>? characterPostProcessHandler;
private bool isProcessingEventQuests;
private DateTime postProcessStartTime;
private List<string> currentQuestHierarchy = new List<string>();
private string currentPluginName = string.Empty;
private string lastTerritoryWaitDetected = string.Empty;
private DateTime lastTerritoryTeleportTime = DateTime.MinValue;
private const string PLUGIN_NAME = "QuestionableCompanion";
private const string AR_CHARACTER_ADDITIONAL_TASK = "AutoRetainer.OnCharacterAdditionalTask";
private const string AR_CHARACTER_POST_PROCESS_EVENT = "AutoRetainer.OnCharacterReadyForPostprocess";
private const string AR_FINISH_CHARACTER_POST_PROCESS = "AutoRetainer.FinishCharacterPostprocessRequest";
private const string AR_REQUEST_CHARACTER_POST_PROCESS = "AutoRetainer.RequestCharacterPostprocess";
public ARPostProcessEventQuestService(IDalamudPluginInterface pluginInterface, QuestionableIPC questionableIPC, EventQuestResolver eventQuestResolver, Configuration configuration, IPluginLog log, IFramework framework, ICommandManager commandManager, LifestreamIPC lifestreamIPC)
{
this.pluginInterface = pluginInterface;
this.questionableIPC = questionableIPC;
this.eventQuestResolver = eventQuestResolver;
this.configuration = configuration;
this.log = log;
this.framework = framework;
this.commandManager = commandManager;
this.lifestreamIPC = lifestreamIPC;
InitializeIPC();
}
private void InitializeIPC()
{
try
{
characterAdditionalTaskSubscriber = pluginInterface.GetIpcSubscriber<object>("AutoRetainer.OnCharacterAdditionalTask");
if (characterAdditionalTaskSubscriber == null)
{
return;
}
characterAdditionalTaskHandler = delegate
{
try
{
RegisterWithAutoRetainer();
}
catch
{
}
};
characterAdditionalTaskSubscriber.Subscribe(characterAdditionalTaskHandler);
characterPostProcessSubscriber = pluginInterface.GetIpcSubscriber<string, object>("AutoRetainer.OnCharacterReadyForPostprocess");
if (characterPostProcessSubscriber == null)
{
return;
}
characterPostProcessHandler = delegate(string pluginName)
{
try
{
OnARCharacterPostProcessStarted(pluginName);
}
catch
{
}
};
characterPostProcessSubscriber.Subscribe(characterPostProcessHandler);
}
catch
{
}
}
private void RegisterWithAutoRetainer()
{
try
{
pluginInterface.GetIpcSubscriber<string, object>("AutoRetainer.RequestCharacterPostprocess").InvokeAction("QuestionableCompanion");
}
catch
{
}
}
private void OnARCharacterPostProcessStarted(string pluginName)
{
try
{
if (pluginName != "QuestionableCompanion")
{
return;
}
if (!configuration.RunEventQuestsOnARPostProcess)
{
FinishPostProcess();
return;
}
currentPluginName = pluginName;
postProcessStartTime = DateTime.Now;
framework.RunOnFrameworkThread(async delegate
{
try
{
await ProcessEventQuestsAsync();
}
catch
{
FinishPostProcess();
}
});
}
catch
{
try
{
FinishPostProcess();
}
catch
{
}
}
}
private async Task ProcessEventQuestsAsync()
{
if (isProcessingEventQuests)
{
return;
}
isProcessingEventQuests = true;
bool shouldFinishPostProcess = false;
try
{
_ = 1;
try
{
List<string> detectedEventQuests = DetectActiveEventQuests();
if (detectedEventQuests.Count == 0)
{
shouldFinishPostProcess = true;
return;
}
currentQuestHierarchy = new List<string>(detectedEventQuests);
await ImportEventQuestsForPostProcess(detectedEventQuests);
await WaitForEventQuestsCompletion(detectedEventQuests);
shouldFinishPostProcess = true;
}
catch
{
shouldFinishPostProcess = true;
}
}
finally
{
if (shouldFinishPostProcess)
{
await ClearPriorityQuests();
FinishPostProcess();
}
isProcessingEventQuests = false;
}
}
private List<string> DetectActiveEventQuests()
{
try
{
return questionableIPC.GetCurrentlyActiveEventQuests() ?? new List<string>();
}
catch
{
return new List<string>();
}
}
private async Task ImportEventQuestsForPostProcess(List<string> detectedEventQuests)
{
List<string> allQuestsToImport = new List<string>();
try
{
foreach (string questId in detectedEventQuests)
{
foreach (string quest in await GetQuestHierarchy(questId))
{
if (!allQuestsToImport.Contains(quest))
{
allQuestsToImport.Add(quest);
}
}
}
if (!questionableIPC.IsAvailable)
{
return;
}
try
{
questionableIPC.ClearQuestPriority();
await Task.Delay(500);
}
catch
{
}
foreach (string questId2 in allQuestsToImport)
{
try
{
questionableIPC.AddQuestPriority(questId2);
await Task.Delay(100);
}
catch
{
}
}
await Task.Delay(500);
try
{
await framework.RunOnFrameworkThread(delegate
{
commandManager.ProcessCommand("/qst start");
});
}
catch
{
}
}
catch
{
throw;
}
}
private async Task<List<string>> GetQuestHierarchy(string questId)
{
List<string> hierarchy = new List<string>();
HashSet<string> visited = new HashSet<string>();
await CollectPrerequisitesRecursive(questId, hierarchy, visited);
return hierarchy;
}
private async Task CollectPrerequisitesRecursive(string questId, List<string> hierarchy, HashSet<string> visited)
{
if (visited.Contains(questId))
{
return;
}
visited.Add(questId);
try
{
List<string> prerequisites = eventQuestResolver.ResolveEventQuestDependencies(questId);
if (prerequisites.Count > 0)
{
foreach (string prereq in prerequisites)
{
await CollectPrerequisitesRecursive(prereq, hierarchy, visited);
}
}
hierarchy.Add(questId);
}
catch
{
hierarchy.Add(questId);
}
await Task.CompletedTask;
}
private async Task WaitForEventQuestsCompletion(List<string> originalEventQuests)
{
TimeSpan maxWaitTime = TimeSpan.FromMinutes(configuration.EventQuestPostProcessTimeoutMinutes);
DateTime startTime = DateTime.Now;
TimeSpan checkInterval = TimeSpan.FromSeconds(2L);
while (DateTime.Now - startTime < maxWaitTime)
{
try
{
CheckForTerritoryWait();
if (!questionableIPC.IsAvailable)
{
await Task.Delay(checkInterval);
continue;
}
bool isRunning = questionableIPC.IsRunning();
List<string> currentEventQuests = DetectActiveEventQuests();
if (originalEventQuests.Where((string q) => currentEventQuests.Contains(q)).ToList().Count == 0)
{
if (!isRunning)
{
break;
}
try
{
await framework.RunOnFrameworkThread(delegate
{
commandManager.ProcessCommand("/qst stop");
});
await Task.Delay(500);
break;
}
catch
{
break;
}
}
}
catch
{
}
await Task.Delay(checkInterval);
}
}
private void CheckForTerritoryWait()
{
if (!questionableIPC.IsRunning())
{
return;
}
object task = questionableIPC.GetCurrentTask();
if (task == null)
{
return;
}
try
{
if (!(task is JObject jObject))
{
return;
}
JToken taskNameToken = jObject["TaskName"];
if (taskNameToken == null)
{
return;
}
string taskName = taskNameToken.ToString();
if (string.IsNullOrEmpty(taskName))
{
return;
}
Match waitTerritoryMatch = new Regex("Wait\\(territory:\\s*(.+?)\\s*\\((\\d+)\\)\\)").Match(taskName);
if (!waitTerritoryMatch.Success)
{
return;
}
string territoryName = waitTerritoryMatch.Groups[1].Value.Trim();
uint territoryId = uint.Parse(waitTerritoryMatch.Groups[2].Value);
string territoryKey = $"{territoryName}_{territoryId}";
double timeSinceLastTeleport = (DateTime.Now - lastTerritoryTeleportTime).TotalSeconds;
if (lastTerritoryWaitDetected == territoryKey && timeSinceLastTeleport < 60.0)
{
return;
}
lastTerritoryWaitDetected = territoryKey;
lastTerritoryTeleportTime = DateTime.Now;
framework.RunOnFrameworkThread(delegate
{
try
{
commandManager.ProcessCommand("/li " + territoryName);
}
catch
{
}
});
}
catch
{
}
}
private async Task ClearPriorityQuests()
{
try
{
if (questionableIPC.IsAvailable)
{
questionableIPC.ClearQuestPriority();
await Task.Delay(500);
}
}
catch
{
}
}
private void FinishPostProcess()
{
try
{
pluginInterface.GetIpcSubscriber<object>("AutoRetainer.FinishCharacterPostprocessRequest").InvokeAction();
}
catch
{
}
}
public void Dispose()
{
try
{
if (characterAdditionalTaskHandler != null && characterAdditionalTaskSubscriber != null)
{
characterAdditionalTaskSubscriber.Unsubscribe(characterAdditionalTaskHandler);
}
if (characterPostProcessHandler != null && characterPostProcessSubscriber != null)
{
characterPostProcessSubscriber.Unsubscribe(characterPostProcessHandler);
}
}
catch
{
}
}
}

View file

@ -0,0 +1,162 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Dalamud.Plugin.Services;
using QuestionableCompanion.Models;
namespace QuestionableCompanion.Services;
public class AlliedSocietyDatabase
{
private readonly Configuration configuration;
private readonly IPluginLog log;
public AlliedSocietyDatabase(Configuration configuration, IPluginLog log)
{
this.configuration = configuration;
this.log = log;
if (configuration.AlliedSociety.RotationConfig.Priorities.Count == 0)
{
configuration.AlliedSociety.RotationConfig.InitializeDefaults();
SaveToConfig();
}
}
public void SaveToConfig()
{
configuration.Save();
}
public void UpdateCharacterProgress(string characterId, byte societyId, int rank, bool isMaxRank)
{
if (!configuration.AlliedSociety.CharacterProgress.ContainsKey(characterId))
{
configuration.AlliedSociety.CharacterProgress[characterId] = new List<AlliedSocietyProgress>();
}
List<AlliedSocietyProgress> progressList = configuration.AlliedSociety.CharacterProgress[characterId];
AlliedSocietyProgress existing = progressList.FirstOrDefault((AlliedSocietyProgress p) => p.SocietyId == societyId);
if (existing != null)
{
existing.CurrentRank = rank;
existing.IsMaxRank = isMaxRank;
}
else
{
progressList.Add(new AlliedSocietyProgress
{
CharacterId = characterId,
SocietyId = societyId,
CurrentRank = rank,
IsMaxRank = isMaxRank
});
}
SaveToConfig();
}
public AlliedSocietyProgress? GetProgress(string characterId, byte societyId)
{
if (configuration.AlliedSociety.CharacterProgress.TryGetValue(characterId, out List<AlliedSocietyProgress> list))
{
return list.FirstOrDefault((AlliedSocietyProgress p) => p.SocietyId == societyId);
}
return null;
}
public AlliedSocietyCharacterStatus GetCharacterStatus(string characterId)
{
if (!configuration.AlliedSociety.CharacterStatuses.ContainsKey(characterId))
{
configuration.AlliedSociety.CharacterStatuses[characterId] = new AlliedSocietyCharacterStatus
{
CharacterId = characterId,
Status = AlliedSocietyRotationStatus.Ready
};
SaveToConfig();
}
return configuration.AlliedSociety.CharacterStatuses[characterId];
}
public void UpdateCharacterStatus(string characterId, AlliedSocietyRotationStatus status)
{
GetCharacterStatus(characterId).Status = status;
SaveToConfig();
}
public void SetCharacterComplete(string characterId, DateTime completionDate)
{
AlliedSocietyCharacterStatus characterStatus = GetCharacterStatus(characterId);
characterStatus.Status = AlliedSocietyRotationStatus.Complete;
characterStatus.LastCompletionDate = completionDate;
characterStatus.ImportedQuestIds.Clear();
SaveToConfig();
}
public void CheckAndResetExpired(DateTime nextResetDate)
{
List<string> charactersToReset = GetCharactersNeedingReset(nextResetDate);
foreach (string charId in charactersToReset)
{
log.Information("[AlliedSociety] Resetting status for character " + charId);
AlliedSocietyCharacterStatus characterStatus = GetCharacterStatus(charId);
characterStatus.Status = AlliedSocietyRotationStatus.Ready;
characterStatus.ImportedQuestIds.Clear();
}
if (charactersToReset.Count > 0)
{
SaveToConfig();
}
}
public List<string> GetCharactersNeedingReset(DateTime nextResetDate)
{
List<string> result = new List<string>();
DateTime lastResetDate = nextResetDate.AddDays(-1.0);
foreach (KeyValuePair<string, AlliedSocietyCharacterStatus> kvp in configuration.AlliedSociety.CharacterStatuses)
{
AlliedSocietyCharacterStatus status = kvp.Value;
if (status.Status == AlliedSocietyRotationStatus.Ready)
{
continue;
}
if (status.LastCompletionDate.HasValue)
{
if (status.LastCompletionDate.Value < lastResetDate)
{
result.Add(kvp.Key);
}
}
else
{
result.Add(kvp.Key);
}
}
return result;
}
public void ClearAllStatuses()
{
foreach (KeyValuePair<string, AlliedSocietyCharacterStatus> kvp in configuration.AlliedSociety.CharacterStatuses)
{
kvp.Value.Status = AlliedSocietyRotationStatus.Ready;
kvp.Value.ImportedQuestIds.Clear();
}
SaveToConfig();
}
public void AddImportedQuest(string characterId, string questId)
{
AlliedSocietyCharacterStatus status = GetCharacterStatus(characterId);
if (!status.ImportedQuestIds.Contains(questId))
{
status.ImportedQuestIds.Add(questId);
SaveToConfig();
}
}
public void ClearImportedQuests(string characterId)
{
GetCharacterStatus(characterId).ImportedQuestIds.Clear();
SaveToConfig();
}
}

View file

@ -0,0 +1,87 @@
using System.Collections.Generic;
using System.Linq;
using Dalamud.Plugin.Services;
using QuestionableCompanion.Models;
namespace QuestionableCompanion.Services;
public class AlliedSocietyQuestSelector
{
private readonly QuestionableIPC questionableIpc;
private readonly IPluginLog log;
public AlliedSocietyQuestSelector(QuestionableIPC questionableIpc, IPluginLog log)
{
this.questionableIpc = questionableIpc;
this.log = log;
}
public List<string> SelectQuestsForCharacter(string characterId, int remainingAllowances, List<AlliedSocietyPriority> priorities, AlliedSocietyQuestMode mode)
{
List<string> selectedQuests = new List<string>();
int currentAllowances = remainingAllowances;
List<AlliedSocietyPriority> list = (from p in priorities
where p.Enabled
orderby p.Order
select p).ToList();
log.Debug($"[AlliedSociety] Selecting quests for {characterId}. Allowances: {remainingAllowances}, Mode: {mode}");
foreach (AlliedSocietyPriority priority in list)
{
if (currentAllowances <= 0)
{
log.Debug("[AlliedSociety] No allowances left, stopping selection");
break;
}
byte societyId = priority.SocietyId;
List<string> optimalQuests = questionableIpc.GetAlliedSocietyOptimalQuests(societyId);
if (optimalQuests.Count == 0)
{
continue;
}
List<string> readyQuests = new List<string>();
foreach (string questId in optimalQuests)
{
if (questionableIpc.IsReadyToAcceptQuest(questId))
{
readyQuests.Add(questId);
}
}
if (readyQuests.Count == 0)
{
continue;
}
if (mode == AlliedSocietyQuestMode.OnlyThreePerSociety)
{
List<string> questsToTake = readyQuests;
if (questsToTake.Count > 3)
{
questsToTake = questsToTake.Skip(questsToTake.Count - 3).Take(3).ToList();
}
foreach (string questId2 in questsToTake)
{
if (currentAllowances > 0)
{
selectedQuests.Add(questId2);
currentAllowances--;
continue;
}
break;
}
continue;
}
foreach (string questId3 in readyQuests)
{
if (currentAllowances > 0)
{
selectedQuests.Add(questId3);
currentAllowances--;
continue;
}
break;
}
}
log.Information($"[AlliedSociety] Selected {selectedQuests.Count} quests for {characterId}");
return selectedQuests;
}
}

View file

@ -0,0 +1,496 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using Dalamud.Plugin.Services;
using Newtonsoft.Json.Linq;
using QuestionableCompanion.Models;
namespace QuestionableCompanion.Services;
public class AlliedSocietyRotationService : IDisposable
{
private readonly QuestionableIPC questionableIpc;
private readonly AlliedSocietyDatabase database;
private readonly AlliedSocietyQuestSelector questSelector;
private readonly AutoRetainerIPC autoRetainerIpc;
private readonly Configuration configuration;
private readonly IPluginLog log;
private readonly IFramework framework;
private readonly ICommandManager commandManager;
private readonly ICondition condition;
private readonly IClientState clientState;
private bool isRotationActive;
private AlliedSocietyRotationPhase currentPhase;
private List<string> rotationCharacters = new List<string>();
private int currentCharacterIndex = -1;
private string currentCharacterId = string.Empty;
private DateTime phaseStartTime = DateTime.MinValue;
private int consecutiveNoQuestsCount;
private DateTime lastTerritoryTeleportTime = DateTime.MinValue;
private string lastTerritoryName = string.Empty;
private DateTime lastUpdate = DateTime.MinValue;
private const double UpdateIntervalMs = 500.0;
private DateTime characterSwitchStartTime = DateTime.MinValue;
private const double CharacterSwitchRetrySeconds = 20.0;
public bool IsRotationActive => isRotationActive;
public string CurrentCharacterId => currentCharacterId;
public AlliedSocietyRotationPhase CurrentPhase => currentPhase;
public AlliedSocietyRotationService(QuestionableIPC questionableIpc, AlliedSocietyDatabase database, AlliedSocietyQuestSelector questSelector, AutoRetainerIPC autoRetainerIpc, Configuration configuration, IPluginLog log, IFramework framework, ICommandManager commandManager, ICondition condition, IClientState clientState)
{
this.questionableIpc = questionableIpc;
this.database = database;
this.questSelector = questSelector;
this.autoRetainerIpc = autoRetainerIpc;
this.configuration = configuration;
this.log = log;
this.framework = framework;
this.commandManager = commandManager;
this.condition = condition;
this.clientState = clientState;
framework.Update += OnFrameworkUpdate;
}
public void Dispose()
{
framework.Update -= OnFrameworkUpdate;
}
public void StartRotation(List<string> characters)
{
if (isRotationActive)
{
log.Warning("[AlliedSociety] Rotation already active");
return;
}
if (characters.Count == 0)
{
log.Warning("[AlliedSociety] No characters selected for rotation");
return;
}
log.Information($"[AlliedSociety] Starting rotation with {characters.Count} characters");
rotationCharacters = new List<string>(characters);
isRotationActive = true;
currentCharacterIndex = -1;
AdvanceToNextCharacter();
}
public void StopRotation()
{
if (!isRotationActive)
{
return;
}
log.Information("[AlliedSociety] Stopping rotation");
isRotationActive = false;
currentPhase = AlliedSocietyRotationPhase.Idle;
currentCharacterId = string.Empty;
try
{
commandManager.ProcessCommand("/qst stop");
}
catch
{
}
}
private void OnFrameworkUpdate(IFramework framework)
{
if (!isRotationActive || (DateTime.Now - lastUpdate).TotalMilliseconds < 500.0)
{
return;
}
lastUpdate = DateTime.Now;
try
{
switch (currentPhase)
{
case AlliedSocietyRotationPhase.StartingRotation:
HandleStartingRotation();
break;
case AlliedSocietyRotationPhase.ImportingQuests:
HandleImportingQuests();
break;
case AlliedSocietyRotationPhase.WaitingForQuestAccept:
HandleWaitingForQuestAccept();
break;
case AlliedSocietyRotationPhase.MonitoringQuests:
HandleMonitoringQuests();
break;
case AlliedSocietyRotationPhase.CheckingCompletion:
HandleCheckingCompletion();
break;
case AlliedSocietyRotationPhase.WaitingForCharacterSwitch:
HandleWaitingForCharacterSwitch();
break;
}
}
catch (Exception ex)
{
log.Error("[AlliedSociety] Error in rotation loop: " + ex.Message);
StopRotation();
}
}
private void HandleStartingRotation()
{
if (clientState.LocalContentId == 0L)
{
return;
}
string currentLoggedInChar = autoRetainerIpc.GetCurrentCharacter();
if (string.IsNullOrEmpty(currentLoggedInChar) || currentLoggedInChar != currentCharacterId)
{
double waitTime = (DateTime.Now - characterSwitchStartTime).TotalSeconds;
if (!(waitTime > 20.0))
{
return;
}
log.Warning($"[AlliedSociety] Character switch timeout ({waitTime:F1}s). Retrying /ar relog for {currentCharacterId}...");
framework.RunOnFrameworkThread(delegate
{
try
{
commandManager.ProcessCommand("/ays relog " + currentCharacterId);
log.Information("[AlliedSociety] Retry relog command sent for " + currentCharacterId);
}
catch (Exception ex)
{
log.Error("[AlliedSociety] Failed to send retry relog: " + ex.Message);
}
});
characterSwitchStartTime = DateTime.Now;
}
else
{
questionableIpc.ForceCheckAvailability();
if (questionableIpc.IsAvailable)
{
log.Information("[AlliedSociety] ✓ Character logged in (" + currentLoggedInChar + "), Questionable ready");
SetPhase(AlliedSocietyRotationPhase.ImportingQuests);
}
}
}
private void HandleImportingQuests()
{
int allowances = questionableIpc.GetAlliedSocietyRemainingAllowances();
log.Information($"[AlliedSociety] Remaining allowances: {allowances}");
if (allowances <= 0)
{
log.Information("[AlliedSociety] No allowances left. Checking completion...");
SetPhase(AlliedSocietyRotationPhase.CheckingCompletion);
return;
}
List<string> quests = questSelector.SelectQuestsForCharacter(currentCharacterId, allowances, configuration.AlliedSociety.RotationConfig.Priorities, configuration.AlliedSociety.RotationConfig.QuestMode);
if (quests.Count == 0)
{
consecutiveNoQuestsCount++;
log.Warning($"[AlliedSociety] No quests selected (attempt {consecutiveNoQuestsCount}/3). Checking completion...");
if (consecutiveNoQuestsCount >= 3)
{
log.Error("[AlliedSociety] Failed to select quests 3 times consecutively. No quests available for this character.");
log.Information("[AlliedSociety] Marking character as complete and moving to next...");
consecutiveNoQuestsCount = 0;
database.SetCharacterComplete(currentCharacterId, DateTime.Now);
AdvanceToNextCharacter();
}
else
{
SetPhase(AlliedSocietyRotationPhase.CheckingCompletion);
}
return;
}
consecutiveNoQuestsCount = 0;
log.Information($"[AlliedSociety] Importing {quests.Count} quests to Questionable...");
foreach (string questId in quests)
{
log.Debug("[AlliedSociety] Adding quest " + questId + " to priority");
questionableIpc.AddQuestPriority(questId);
database.AddImportedQuest(currentCharacterId, questId);
}
log.Information("✓ All quests imported to Questionable");
log.Information("[AlliedSociety] Sending /qst start command...");
framework.RunOnFrameworkThread(delegate
{
try
{
commandManager.ProcessCommand("/qst start");
log.Information("[AlliedSociety] ✓ /qst start command sent successfully");
}
catch (Exception ex)
{
log.Error("[AlliedSociety] ✗ Failed to send /qst start: " + ex.Message);
}
});
log.Information("[AlliedSociety] Transitioning to WaitingForQuestAccept phase");
SetPhase(AlliedSocietyRotationPhase.WaitingForQuestAccept);
}
private void HandleWaitingForQuestAccept()
{
try
{
object task = questionableIpc.GetCurrentTask();
if (task != null && task is JObject jObject)
{
JToken taskNameToken = jObject["TaskName"];
if (taskNameToken != null)
{
string taskName = taskNameToken.ToString();
if (!string.IsNullOrEmpty(taskName) && taskName.Contains("Wait(territory:"))
{
log.Information("[AlliedSociety] [WaitAccept] Territory wait detected: " + taskName);
Match territoryMatch = Regex.Match(taskName, "Wait\\(territory:\\s*(.+?)\\s*\\((\\d+)\\)\\)");
if (territoryMatch.Success)
{
string territoryName = territoryMatch.Groups[1].Value.Trim();
TimeSpan timeSinceLastTeleport = DateTime.Now - lastTerritoryTeleportTime;
if (territoryName != lastTerritoryName || timeSinceLastTeleport.TotalSeconds > 10.0)
{
log.Information("[AlliedSociety] [WaitAccept] ▶ Sending Lifestream teleport: /li " + territoryName);
framework.RunOnFrameworkThread(delegate
{
try
{
commandManager.ProcessCommand("/li " + territoryName);
log.Information("[AlliedSociety] [WaitAccept] ✓ Lifestream command sent successfully");
}
catch (Exception ex2)
{
log.Error("[AlliedSociety] [WaitAccept] ✗ Failed to send Lifestream command: " + ex2.Message);
}
});
lastTerritoryTeleportTime = DateTime.Now;
lastTerritoryName = territoryName;
}
else
{
log.Debug("[AlliedSociety] [WaitAccept] Territory teleport debounced for: " + territoryName);
}
}
}
}
}
}
catch (Exception ex)
{
log.Debug("[AlliedSociety] [WaitAccept] Error checking task for teleport: " + ex.Message);
}
AlliedSocietyCharacterStatus characterStatus = database.GetCharacterStatus(currentCharacterId);
bool allAccepted = true;
int acceptedCount = 0;
int totalCount = characterStatus.ImportedQuestIds.Count;
foreach (string questId in characterStatus.ImportedQuestIds)
{
if (questionableIpc.IsReadyToAcceptQuest(questId))
{
allAccepted = false;
}
else
{
acceptedCount++;
}
}
if (allAccepted)
{
log.Information($"[AlliedSociety] ✓ All {totalCount} quests accepted. Monitoring progress...");
SetPhase(AlliedSocietyRotationPhase.MonitoringQuests);
}
}
private void HandleMonitoringQuests()
{
string currentQuestId = questionableIpc.GetCurrentQuestId();
AlliedSocietyCharacterStatus status = database.GetCharacterStatus(currentCharacterId);
log.Debug("[AlliedSociety] Monitoring - Current Quest: " + (currentQuestId ?? "null"));
if (currentQuestId != null && status.ImportedQuestIds.Contains(currentQuestId))
{
log.Debug("[AlliedSociety] Working on imported quest: " + currentQuestId);
try
{
object task = questionableIpc.GetCurrentTask();
log.Debug("[AlliedSociety] Task object type: " + (task?.GetType().Name ?? "null"));
if (task != null && task is JObject jObject)
{
log.Debug("[AlliedSociety] Task JObject: " + jObject.ToString());
JToken taskNameToken = jObject["TaskName"];
if (taskNameToken != null)
{
string taskName = taskNameToken.ToString();
log.Information("[AlliedSociety] Current Task Name: '" + taskName + "'");
if (!string.IsNullOrEmpty(taskName) && taskName.Contains("Wait(territory:"))
{
log.Information("[AlliedSociety] ✓ TERRITORY WAIT DETECTED! Raw task: " + taskName);
Match territoryMatch = Regex.Match(taskName, "Wait\\(territory:\\s*(.+?)\\s*\\((\\d+)\\)\\)");
if (territoryMatch.Success)
{
string territoryName = territoryMatch.Groups[1].Value.Trim();
log.Information("[AlliedSociety] ✓ Territory name parsed: '" + territoryName + "'");
TimeSpan timeSinceLastTeleport = DateTime.Now - lastTerritoryTeleportTime;
if (territoryName != lastTerritoryName || timeSinceLastTeleport.TotalSeconds > 10.0)
{
log.Information("[AlliedSociety] ▶ Territory wait detected: " + territoryName);
log.Information("[AlliedSociety] ▶ Sending Lifestream teleport command: /li " + territoryName);
framework.RunOnFrameworkThread(delegate
{
try
{
commandManager.ProcessCommand("/li " + territoryName);
log.Information("[AlliedSociety] ✓ Lifestream command sent successfully");
}
catch (Exception ex2)
{
log.Error("[AlliedSociety] ✗ Failed to send Lifestream command: " + ex2.Message);
}
});
lastTerritoryTeleportTime = DateTime.Now;
lastTerritoryName = territoryName;
}
else
{
log.Information($"[AlliedSociety] ⏸ Territory teleport debounced for: {territoryName} (last: {timeSinceLastTeleport.TotalSeconds:F1}s ago)");
}
}
else
{
log.Warning("[AlliedSociety] ✗ Territory wait detected but regex didn't match! Raw: " + taskName);
}
}
else if (!string.IsNullOrEmpty(taskName) && taskName.ToLower().Contains("wait"))
{
log.Debug("[AlliedSociety] Wait task (not territory): " + taskName);
}
}
else
{
log.Debug("[AlliedSociety] Task has no TaskName property");
}
}
else if (task == null)
{
log.Debug("[AlliedSociety] GetCurrentTask returned null");
}
else
{
log.Warning("[AlliedSociety] Task is not JObject, type: " + task.GetType().Name);
}
return;
}
catch (Exception ex)
{
log.Error("[AlliedSociety] Error checking task for teleport: " + ex.Message + "\n" + ex.StackTrace);
return;
}
}
if (currentQuestId == null || !status.ImportedQuestIds.Contains(currentQuestId))
{
log.Information("[AlliedSociety] No longer working on imported quests. Checking completion...");
SetPhase(AlliedSocietyRotationPhase.CheckingCompletion);
}
}
private void HandleCheckingCompletion()
{
int allowances = questionableIpc.GetAlliedSocietyRemainingAllowances();
log.Information($"[AlliedSociety] Checking completion. Allowances: {allowances}");
if (allowances == 0)
{
string currentQuestId = questionableIpc.GetCurrentQuestId();
AlliedSocietyCharacterStatus status = database.GetCharacterStatus(currentCharacterId);
if (currentQuestId != null && status.ImportedQuestIds.Contains(currentQuestId))
{
log.Information("[AlliedSociety] Still working on final quest " + currentQuestId + ". Waiting...");
return;
}
log.Information("[AlliedSociety] Character " + currentCharacterId + " completed all allowances.");
try
{
commandManager.ProcessCommand("/qst stop");
log.Information("[AlliedSociety] Sent /qst stop command after quest completion");
}
catch (Exception ex)
{
log.Error("[AlliedSociety] Failed to send /qst stop: " + ex.Message);
}
questionableIpc.ClearQuestPriority();
database.SetCharacterComplete(currentCharacterId, DateTime.Now);
SetPhase(AlliedSocietyRotationPhase.WaitingForCharacterSwitch);
}
else
{
log.Information("[AlliedSociety] Allowances remaining. Trying to import more quests...");
questionableIpc.ClearQuestPriority();
database.ClearImportedQuests(currentCharacterId);
SetPhase(AlliedSocietyRotationPhase.ImportingQuests);
}
}
private void HandleWaitingForCharacterSwitch()
{
if (!((DateTime.Now - phaseStartTime).TotalSeconds < 2.0))
{
AdvanceToNextCharacter();
}
}
private void AdvanceToNextCharacter()
{
currentCharacterIndex++;
if (currentCharacterIndex >= rotationCharacters.Count)
{
log.Information("[AlliedSociety] Rotation completed for all characters.");
StopRotation();
return;
}
string nextChar = rotationCharacters[currentCharacterIndex];
if (database.GetCharacterStatus(nextChar).Status == AlliedSocietyRotationStatus.Complete)
{
log.Information("[AlliedSociety] Skipping " + nextChar + " (Already Complete)");
AdvanceToNextCharacter();
return;
}
log.Information("[AlliedSociety] Switching to " + nextChar);
currentCharacterId = nextChar;
characterSwitchStartTime = DateTime.Now;
if (autoRetainerIpc.SwitchCharacter(nextChar))
{
SetPhase(AlliedSocietyRotationPhase.StartingRotation);
return;
}
log.Error("[AlliedSociety] Failed to switch to " + nextChar);
StopRotation();
}
private void SetPhase(AlliedSocietyRotationPhase phase)
{
log.Information($"[AlliedSociety] Phase: {currentPhase} → {phase}");
currentPhase = phase;
phaseStartTime = DateTime.Now;
}
}

View file

@ -0,0 +1,513 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using Dalamud.Plugin.Services;
using Newtonsoft.Json.Linq;
namespace QuestionableCompanion.Services;
public class AutoRetainerIPC : IDisposable
{
private readonly IDalamudPluginInterface pluginInterface;
private readonly IPluginLog log;
private readonly IClientState clientState;
private readonly ICommandManager commandManager;
private readonly IFramework framework;
private ICallGateSubscriber<List<ulong>>? getRegisteredCIDsSubscriber;
private ICallGateSubscriber<ulong, object>? getOfflineCharacterDataSubscriber;
private ICallGateProvider<string, object>? relogProvider;
private ICallGateSubscriber<bool>? getMultiModeEnabledSubscriber;
private ICallGateProvider<bool, object>? setMultiModeEnabledProvider;
private Dictionary<ulong, string> characterCache = new Dictionary<ulong, string>();
private HashSet<ulong> unknownCIDs = new HashSet<ulong>();
private bool subscribersInitialized;
private DateTime lastAvailabilityCheck = DateTime.MinValue;
private const int AvailabilityCheckCooldownSeconds = 5;
public bool IsAvailable { get; private set; }
public AutoRetainerIPC(IDalamudPluginInterface pluginInterface, IPluginLog log, IClientState clientState, ICommandManager commandManager, IFramework framework)
{
this.pluginInterface = pluginInterface;
this.log = log;
this.clientState = clientState;
this.commandManager = commandManager;
this.framework = framework;
InitializeIPC();
}
public void ClearCache()
{
characterCache.Clear();
unknownCIDs.Clear();
log.Information("[AutoRetainerIPC] Cache cleared");
}
private void InitializeIPC()
{
try
{
getRegisteredCIDsSubscriber = null;
getOfflineCharacterDataSubscriber = null;
relogProvider = null;
getMultiModeEnabledSubscriber = null;
setMultiModeEnabledProvider = null;
IsAvailable = false;
getRegisteredCIDsSubscriber = pluginInterface.GetIpcSubscriber<List<ulong>>("AutoRetainer.GetRegisteredCIDs");
getOfflineCharacterDataSubscriber = pluginInterface.GetIpcSubscriber<ulong, object>("AutoRetainer.GetOfflineCharacterData");
try
{
relogProvider = pluginInterface.GetIpcProvider<string, object>("AutoRetainer.Relog");
log.Debug("[AutoRetainerIPC] Relog IPC provider initialized");
}
catch (Exception ex)
{
log.Debug("[AutoRetainerIPC] Failed to initialize Relog provider: " + ex.Message);
}
try
{
getMultiModeEnabledSubscriber = pluginInterface.GetIpcSubscriber<bool>("AutoRetainer.GetMultiModeEnabled");
setMultiModeEnabledProvider = pluginInterface.GetIpcProvider<bool, object>("AutoRetainer.SetMultiModeEnabled");
log.Debug("[AutoRetainerIPC] Multi-Mode IPC initialized");
}
catch (Exception ex2)
{
log.Debug("[AutoRetainerIPC] Failed to initialize Multi-Mode IPC: " + ex2.Message);
}
subscribersInitialized = true;
log.Debug("[AutoRetainerIPC] IPC subscribers initialized (lazy-loading enabled)");
}
catch (Exception ex3)
{
IsAvailable = false;
subscribersInitialized = false;
log.Error("[AutoRetainerIPC] Failed to initialize subscribers: " + ex3.Message);
}
}
private bool TryEnsureAvailable()
{
if (IsAvailable)
{
return true;
}
if (!subscribersInitialized)
{
return false;
}
DateTime now = DateTime.Now;
if ((now - lastAvailabilityCheck).TotalSeconds < 5.0)
{
return false;
}
lastAvailabilityCheck = now;
try
{
if (getRegisteredCIDsSubscriber == null)
{
return false;
}
List<ulong> testCids = getRegisteredCIDsSubscriber.InvokeFunc();
if (!IsAvailable)
{
IsAvailable = true;
log.Information($"[AutoRetainerIPC] ✅ AutoRetainer is now available ({testCids?.Count ?? 0} characters)");
}
return true;
}
catch (Exception ex)
{
log.Debug("[AutoRetainerIPC] AutoRetainer not yet available: " + ex.Message);
IsAvailable = false;
return false;
}
}
public bool TryReinitialize()
{
log.Information("[AutoRetainerIPC] Manual IPC reinitialization requested");
lastAvailabilityCheck = DateTime.MinValue;
bool num = TryEnsureAvailable();
if (num)
{
log.Information("[AutoRetainerIPC] IPC reinitialization successful");
return num;
}
log.Warning("[AutoRetainerIPC] IPC still unavailable after reinitialization attempt");
return num;
}
public List<string> GetRegisteredCharacters()
{
log.Debug("[AutoRetainerIPC] GetRegisteredCharacters called");
TryEnsureAvailable();
if (!IsAvailable || getRegisteredCIDsSubscriber == null)
{
log.Warning("[AutoRetainerIPC] Cannot get characters - IPC not available");
log.Warning($"[AutoRetainerIPC] IsAvailable: {IsAvailable}, Subscriber: {getRegisteredCIDsSubscriber != null}");
return new List<string>();
}
try
{
List<ulong> cids = getRegisteredCIDsSubscriber.InvokeFunc();
if (cids == null || cids.Count == 0)
{
log.Warning("[AutoRetainerIPC] No CIDs returned from AutoRetainer");
return new List<string>();
}
List<string> characters = new List<string>();
foreach (ulong cid in cids)
{
string charName = GetCharacterNameFromCID(cid);
if (!string.IsNullOrEmpty(charName))
{
characters.Add(charName);
continue;
}
log.Debug($"[AutoRetainerIPC] Could not resolve name for CID: {cid}");
}
if (characters.Count == 0)
{
log.Warning("[AutoRetainerIPC] No character names could be resolved from CIDs");
}
return characters;
}
catch (Exception ex)
{
log.Error("[AutoRetainerIPC] GetRegisteredCharacters failed: " + ex.Message);
log.Error("[AutoRetainerIPC] Stack trace: " + ex.StackTrace);
return new List<string>();
}
}
private string GetCharacterNameFromCID(ulong cid)
{
if (characterCache.TryGetValue(cid, out string cachedName))
{
if (cachedName.Contains("@"))
{
return cachedName;
}
log.Debug($"[AutoRetainerIPC] Removing invalid cache entry for CID {cid}: '{cachedName}'");
characterCache.Remove(cid);
}
if (unknownCIDs.Contains(cid))
{
return $"Unknown (CID: {cid})";
}
if (getOfflineCharacterDataSubscriber == null)
{
log.Debug("[AutoRetainerIPC] OfflineCharacterData subscriber is null");
return string.Empty;
}
try
{
object data = getOfflineCharacterDataSubscriber.InvokeFunc(cid);
if (data == null)
{
if (!unknownCIDs.Contains(cid))
{
log.Warning($"[AutoRetainerIPC] No data returned for CID {cid}");
unknownCIDs.Add(cid);
}
return $"Unknown (CID: {cid})";
}
string resolvedName = null;
FieldInfo nameField = data.GetType().GetField("Name");
FieldInfo worldField = data.GetType().GetField("World");
if (nameField != null && worldField != null)
{
string name = nameField.GetValue(data)?.ToString();
string world = worldField.GetValue(data)?.ToString();
log.Warning($"[AutoRetainerIPC] Field values for CID {cid} - Name: '{name}', World: '{world}'");
if (!string.IsNullOrEmpty(name) && name != "Unknown")
{
if (string.IsNullOrEmpty(world) && clientState.IsLoggedIn && clientState.LocalPlayer != null && clientState.LocalPlayer.Name.ToString() == name)
{
world = clientState.LocalPlayer.HomeWorld.Value.Name.ToString();
log.Information("[AutoRetainerIPC] Resolved world from ClientState for " + name + ": " + world);
}
if (!string.IsNullOrEmpty(world))
{
resolvedName = name + "@" + world;
characterCache[cid] = resolvedName;
return resolvedName;
}
log.Warning($"[AutoRetainerIPC] World is empty for CID {cid}, cannot create full name");
}
else
{
log.Warning($"[AutoRetainerIPC] Name is empty/invalid for CID {cid}");
}
}
PropertyInfo nameProperty = data.GetType().GetProperty("Name");
PropertyInfo worldProperty = data.GetType().GetProperty("World");
if (nameProperty != null && worldProperty != null)
{
string name2 = nameProperty.GetValue(data)?.ToString();
string world2 = worldProperty.GetValue(data)?.ToString();
if (!string.IsNullOrEmpty(name2) && !string.IsNullOrEmpty(world2) && name2 != "Unknown")
{
resolvedName = name2 + "@" + world2;
characterCache[cid] = resolvedName;
log.Debug($"[AutoRetainerIPC] CID {cid} resolved to {resolvedName} (via properties)");
return resolvedName;
}
}
if (data is JToken jToken)
{
resolvedName = ParseJTokenCharacterData(jToken, cid);
if (!string.IsNullOrEmpty(resolvedName))
{
characterCache[cid] = resolvedName;
log.Debug($"[AutoRetainerIPC] CID {cid} resolved to {resolvedName} (via JSON)");
return resolvedName;
}
}
if (!unknownCIDs.Contains(cid))
{
log.Warning($"[AutoRetainerIPC] Could not resolve name for CID {cid}");
LogDataStructure(data, cid);
unknownCIDs.Add(cid);
}
return $"Unknown (CID: {cid})";
}
catch (Exception ex)
{
if (!unknownCIDs.Contains(cid))
{
log.Warning($"[AutoRetainerIPC] Exception resolving CID {cid}: {ex.Message}");
log.Debug("[AutoRetainerIPC] Stack trace: " + ex.StackTrace);
unknownCIDs.Add(cid);
}
return $"Unknown (CID: {cid})";
}
}
private string? ParseJTokenCharacterData(JToken jToken, ulong cid)
{
try
{
JToken nameToken = jToken.SelectToken("Name") ?? jToken.SelectToken("name") ?? jToken.SelectToken("Value.Name");
JToken worldToken = jToken.SelectToken("World") ?? jToken.SelectToken("world") ?? jToken.SelectToken("Value.World");
if (nameToken != null && worldToken != null)
{
string name = nameToken.Value<string>();
string world = worldToken.Value<string>();
if (!string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(world))
{
return name + "@" + world;
}
if (!string.IsNullOrEmpty(name))
{
log.Warning($"[AutoRetainerIPC] JSON has Name but World is empty for CID {cid}");
}
}
string[] array = new string[4] { "NameWithWorld", "nameWithWorld", "[\"NameWithWorld\"]", "Value.NameWithWorld" };
foreach (string path in array)
{
if (string.IsNullOrEmpty(path))
{
continue;
}
JToken token = jToken.SelectToken(path);
if (token != null && token.Type == JTokenType.String)
{
string value = token.Value<string>();
if (!string.IsNullOrEmpty(value) && value.Contains("@"))
{
log.Information("[AutoRetainerIPC] Found name via JSON path '" + path + "': " + value);
return value;
}
}
}
}
catch (Exception ex)
{
log.Warning($"[AutoRetainerIPC] Error parsing JToken for CID {cid}: {ex.Message}");
}
return null;
}
private void LogDataStructure(object data, ulong cid)
{
try
{
if (data is JToken jToken)
{
log.Debug($"[AutoRetainerIPC] JSON structure for CID {cid}:");
log.Debug(jToken.ToString());
return;
}
PropertyInfo[] properties = data.GetType().GetProperties();
log.Debug($"[AutoRetainerIPC] Object structure for CID {cid}:");
foreach (PropertyInfo prop in properties.Take(10))
{
try
{
object value = prop.GetValue(data);
log.Debug($" {prop.Name} = {value ?? "(null)"}");
}
catch
{
log.Debug(" " + prop.Name + " = (error reading value)");
}
}
}
catch
{
}
}
public string? GetCurrentCharacter()
{
try
{
string result = null;
framework.RunOnFrameworkThread(delegate
{
try
{
if (!clientState.IsLoggedIn)
{
result = null;
}
else if (clientState.LocalPlayer == null)
{
result = null;
}
else
{
string text = clientState.LocalPlayer.Name.ToString();
string text2 = clientState.LocalPlayer.HomeWorld.Value.Name.ToString();
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(text2))
{
result = null;
}
else
{
result = text + "@" + text2;
}
}
}
catch (Exception ex2)
{
log.Debug("[AutoRetainerIPC] GetCurrentCharacter inner failed: " + ex2.Message);
result = null;
}
}).Wait();
return result;
}
catch (Exception ex)
{
log.Debug("[AutoRetainerIPC] GetCurrentCharacter failed: " + ex.Message);
return null;
}
}
public bool SwitchCharacter(string characterNameWithWorld)
{
if (string.IsNullOrEmpty(characterNameWithWorld))
{
log.Warning("[AutoRetainerIPC] Character name is null or empty");
return false;
}
TryEnsureAvailable();
if (!IsAvailable)
{
log.Warning("[AutoRetainerIPC] AutoRetainer not available");
return false;
}
try
{
log.Information("[AutoRetainerIPC] Requesting relog to: " + characterNameWithWorld);
string command = "/ays relog " + characterNameWithWorld;
bool success = false;
framework.RunOnFrameworkThread(delegate
{
try
{
commandManager.ProcessCommand(command);
success = true;
log.Information("[AutoRetainerIPC] Relog command executed: " + command);
}
catch (Exception ex2)
{
log.Error("[AutoRetainerIPC] Failed to execute relog command: " + ex2.Message);
success = false;
}
}).Wait();
return success;
}
catch (Exception ex)
{
log.Error("[AutoRetainerIPC] Failed to switch character: " + ex.Message);
return false;
}
}
public bool GetMultiModeEnabled()
{
TryEnsureAvailable();
if (!IsAvailable || getMultiModeEnabledSubscriber == null)
{
log.Debug("[AutoRetainerIPC] Multi-Mode IPC not available");
return false;
}
try
{
return getMultiModeEnabledSubscriber.InvokeFunc();
}
catch (Exception ex)
{
log.Error("[AutoRetainerIPC] GetMultiModeEnabled failed: " + ex.Message);
return false;
}
}
public bool SetMultiModeEnabled(bool enabled)
{
TryEnsureAvailable();
if (!IsAvailable || setMultiModeEnabledProvider == null)
{
log.Warning("[AutoRetainerIPC] Multi-Mode IPC not available");
return false;
}
try
{
setMultiModeEnabledProvider.SendMessage(enabled);
log.Information($"[AutoRetainerIPC] Multi-Mode set to: {enabled}");
return true;
}
catch (Exception ex)
{
log.Error("[AutoRetainerIPC] SetMultiModeEnabled failed: " + ex.Message);
return false;
}
}
public void Dispose()
{
IsAvailable = false;
characterCache.Clear();
unknownCIDs.Clear();
log.Information("[AutoRetainerIPC] Service disposed");
}
}

View file

@ -0,0 +1,233 @@
using System;
using System.Diagnostics;
using System.Numerics;
using System.Threading;
using System.Threading.Tasks;
using Dalamud.Game.ClientState.Conditions;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.Game.Control;
using FFXIVClientStructs.FFXIV.Common.Math;
namespace QuestionableCompanion.Services;
public class CharacterSafeWaitService
{
private readonly IClientState clientState;
private readonly IPluginLog log;
private readonly IFramework framework;
private readonly ICondition condition;
private readonly IGameGui gameGui;
public CharacterSafeWaitService(IClientState clientState, IPluginLog log, IFramework framework, ICondition condition, IGameGui gameGui)
{
this.clientState = clientState;
this.log = log;
this.framework = framework;
this.condition = condition;
this.gameGui = gameGui;
}
public unsafe Task<bool> WaitForCharacterFullyLoadedAsync(int timeoutSeconds = 5, int checkIntervalMs = 200)
{
return Task.Run(delegate
{
Stopwatch stopwatch = Stopwatch.StartNew();
log.Information("[CharLoad] Waiting for character to fully load...");
TimeSpan timeSpan = TimeSpan.FromSeconds(timeoutSeconds);
while (stopwatch.Elapsed < timeSpan)
{
try
{
if (Control.GetLocalPlayer() == null || !clientState.IsLoggedIn)
{
Thread.Sleep(checkIntervalMs);
}
else
{
if (!condition[ConditionFlag.BetweenAreas] && !condition[ConditionFlag.BetweenAreas51] && !condition[ConditionFlag.OccupiedInCutSceneEvent])
{
stopwatch.Stop();
log.Information($"[CharLoad] Character fully loaded in {stopwatch.ElapsedMilliseconds}ms!");
return true;
}
Thread.Sleep(checkIntervalMs);
}
}
catch (Exception ex)
{
log.Error("[CharLoad] Error during load check: " + ex.Message);
Thread.Sleep(checkIntervalMs);
}
}
stopwatch.Stop();
log.Warning($"[CharLoad] Character load timeout after {stopwatch.ElapsedMilliseconds}ms");
return false;
});
}
public bool WaitForCharacterFullyLoaded(int timeoutSeconds = 5)
{
return WaitForCharacterFullyLoadedAsync(timeoutSeconds).GetAwaiter().GetResult();
}
public async Task PerformSafeWaitAsync()
{
Stopwatch sw = Stopwatch.StartNew();
log.Information("[SafeWait] Starting character stabilization...");
try
{
if (!(await WaitForCharacterFullyLoadedAsync(120)))
{
log.Warning("[SafeWait] Character not fully loaded, proceeding anyway");
}
await Task.Delay(250);
await WaitForMovementEndAsync();
await Task.Delay(250);
await WaitForActionsCompleteAsync();
await Task.Delay(500);
sw.Stop();
log.Information($"[SafeWait] Character stabilization complete in {sw.ElapsedMilliseconds}ms");
}
catch (Exception ex)
{
sw.Stop();
log.Error($"[SafeWait] Error during safe wait after {sw.ElapsedMilliseconds}ms: {ex.Message}");
}
}
public void PerformSafeWait()
{
PerformSafeWaitAsync().GetAwaiter().GetResult();
}
private async Task WaitForMovementEndAsync(int maxWaitMs = 5000, int checkIntervalMs = 100)
{
Stopwatch sw = Stopwatch.StartNew();
while (sw.ElapsedMilliseconds < maxWaitMs)
{
try
{
if (!IsPlayerMoving())
{
log.Debug($"[SafeWait] Movement ended after {sw.ElapsedMilliseconds}ms");
return;
}
await Task.Delay(checkIntervalMs);
}
catch (Exception ex)
{
log.Error("[SafeWait] Error checking movement: " + ex.Message);
await Task.Delay(checkIntervalMs);
}
}
log.Warning($"[SafeWait] Movement wait timeout after {sw.ElapsedMilliseconds}ms");
}
private void WaitForMovementEnd()
{
WaitForMovementEndAsync().GetAwaiter().GetResult();
}
private async Task WaitForActionsCompleteAsync(int maxWaitMs = 10000, int checkIntervalMs = 100)
{
Stopwatch sw = Stopwatch.StartNew();
while (sw.ElapsedMilliseconds < maxWaitMs)
{
try
{
if (!IsPlayerInAction())
{
log.Debug($"[SafeWait] Actions completed after {sw.ElapsedMilliseconds}ms");
return;
}
await Task.Delay(checkIntervalMs);
}
catch (Exception ex)
{
log.Error("[SafeWait] Error checking actions: " + ex.Message);
await Task.Delay(checkIntervalMs);
}
}
log.Warning($"[SafeWait] Action wait timeout after {sw.ElapsedMilliseconds}ms");
}
private void WaitForActionsComplete()
{
WaitForActionsCompleteAsync().GetAwaiter().GetResult();
}
private unsafe bool IsPlayerMoving()
{
try
{
BattleChara* player = Control.GetLocalPlayer();
if (player == null)
{
return false;
}
FFXIVClientStructs.FFXIV.Common.Math.Vector3 currentPos = player->Character.GameObject.Position;
Thread.Sleep(50);
player = Control.GetLocalPlayer();
if (player == null)
{
return false;
}
FFXIVClientStructs.FFXIV.Common.Math.Vector3 newPos = player->Character.GameObject.Position;
return System.Numerics.Vector3.Distance(currentPos, newPos) > 0.01f;
}
catch
{
return false;
}
}
private unsafe bool IsPlayerInAction()
{
try
{
BattleChara* player = Control.GetLocalPlayer();
if (player == null)
{
return false;
}
if (player->Character.IsCasting)
{
log.Debug("[SafeWait] Player is casting");
return true;
}
return false;
}
catch
{
return false;
}
}
public async Task PerformQuickSafeWaitAsync()
{
Stopwatch sw = Stopwatch.StartNew();
log.Debug("[SafeWait] Performing quick safe wait...");
try
{
await WaitForMovementEndAsync(3000);
await Task.Delay(1000);
sw.Stop();
log.Debug($"[SafeWait] Quick safe wait complete in {sw.ElapsedMilliseconds}ms");
}
catch (Exception ex)
{
sw.Stop();
log.Error("[SafeWait] Error during quick safe wait: " + ex.Message);
}
}
public void PerformQuickSafeWait()
{
PerformQuickSafeWaitAsync().GetAwaiter().GetResult();
}
}

View file

@ -0,0 +1,368 @@
using System;
using Dalamud.Game.ClientState.Conditions;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Plugin.Services;
namespace QuestionableCompanion.Services;
public class CombatDutyDetectionService : IDisposable
{
private readonly ICondition condition;
private readonly IPluginLog log;
private readonly IClientState clientState;
private readonly ICommandManager commandManager;
private readonly IFramework framework;
private readonly Configuration config;
private bool wasInCombat;
private bool wasInDuty;
private DateTime dutyExitTime = DateTime.MinValue;
private DateTime dutyEntryTime = DateTime.MinValue;
private DateTime lastStateChange = DateTime.MinValue;
private bool combatCommandsActive;
private bool hasCombatCommandsForDuty;
private bool isInAutoDutyDungeon;
private uint currentQuestId;
private bool isRotationActive;
public bool JustEnteredDuty { get; private set; }
public bool JustExitedDuty { get; private set; }
public DateTime DutyExitTime => dutyExitTime;
public bool IsInCombat { get; private set; }
public bool IsInDuty { get; private set; }
public bool IsInDutyQueue { get; private set; }
public bool ShouldPauseAutomation
{
get
{
if (!IsInCombat && !IsInDuty)
{
return IsInDutyQueue;
}
return true;
}
}
public void AcknowledgeDutyEntry()
{
JustEnteredDuty = false;
}
public void AcknowledgeDutyExit()
{
JustExitedDuty = false;
}
public CombatDutyDetectionService(ICondition condition, IPluginLog log, IClientState clientState, ICommandManager commandManager, IFramework framework, Configuration config)
{
this.condition = condition;
this.log = log;
this.clientState = clientState;
this.commandManager = commandManager;
this.framework = framework;
this.config = config;
log.Information("[CombatDuty] Service initialized");
}
public void SetRotationActive(bool active)
{
isRotationActive = active;
}
public void SetAutoDutyDungeon(bool isAutoDuty)
{
isInAutoDutyDungeon = isAutoDuty;
}
public void SetCurrentQuestId(uint questId)
{
currentQuestId = questId;
}
public void Update()
{
if (clientState.LocalPlayer == null || !clientState.IsLoggedIn)
{
return;
}
if (isRotationActive)
{
bool inCombat = condition[ConditionFlag.InCombat];
if (inCombat != wasInCombat)
{
IsInCombat = inCombat;
wasInCombat = inCombat;
lastStateChange = DateTime.Now;
if (inCombat)
{
log.Information("[CombatDuty] Combat started - pausing automation");
if (currentQuestId == 811)
{
log.Information("[CombatDuty] Quest 811 - combat commands DISABLED (RSR off)");
return;
}
}
else
{
log.Information("[CombatDuty] Combat ended - resuming automation");
if (combatCommandsActive && !IsInDuty)
{
log.Information("[CombatDuty] Not in duty - disabling combat commands");
DisableCombatCommands();
}
else if (combatCommandsActive && IsInDuty)
{
log.Information("[CombatDuty] In duty - keeping combat commands active");
}
}
}
}
if (isRotationActive && config.EnableCombatHandling && IsInCombat && !combatCommandsActive && currentQuestId != 811)
{
IPlayerCharacter player = clientState.LocalPlayer;
if (player != null)
{
float hpPercent = (float)player.CurrentHp / (float)player.MaxHp * 100f;
if (hpPercent <= (float)config.CombatHPThreshold)
{
log.Warning($"[CombatDuty] HP at {hpPercent:F1}% (threshold: {config.CombatHPThreshold}%) - enabling combat commands");
EnableCombatCommands();
}
}
}
bool inDuty = condition[ConditionFlag.BoundByDuty] || condition[ConditionFlag.BoundByDuty56] || condition[ConditionFlag.BoundByDuty95];
if (inDuty != wasInDuty)
{
IsInDuty = inDuty;
wasInDuty = inDuty;
lastStateChange = DateTime.Now;
if (inDuty)
{
log.Information("[CombatDuty] Duty started - pausing automation");
JustEnteredDuty = true;
JustExitedDuty = false;
dutyEntryTime = DateTime.Now;
hasCombatCommandsForDuty = false;
}
else
{
log.Information("[CombatDuty] Duty completed - resuming automation");
JustEnteredDuty = false;
JustExitedDuty = true;
dutyExitTime = DateTime.Now;
dutyEntryTime = DateTime.MinValue;
hasCombatCommandsForDuty = false;
if (combatCommandsActive)
{
log.Information("[CombatDuty] Duty ended - disabling combat commands");
DisableCombatCommands();
}
}
}
if (isRotationActive && IsInDuty && !isInAutoDutyDungeon && !hasCombatCommandsForDuty && dutyEntryTime != DateTime.MinValue)
{
if (currentQuestId == 811)
{
log.Information("[CombatDuty] Quest 811 - skipping combat commands (RSR disabled)");
hasCombatCommandsForDuty = true;
return;
}
if (currentQuestId == 4591)
{
log.Information("[CombatDuty] Quest 4591 (Steps of Faith) - skipping combat commands (handler does it)");
hasCombatCommandsForDuty = true;
return;
}
if ((DateTime.Now - dutyEntryTime).TotalSeconds >= 8.0)
{
log.Information("[CombatDuty] 8 seconds in Solo Duty - enabling combat commands");
EnableCombatCommands();
hasCombatCommandsForDuty = true;
}
}
bool inQueue = condition[ConditionFlag.WaitingForDuty] || condition[ConditionFlag.WaitingForDutyFinder];
if (inQueue != IsInDutyQueue)
{
IsInDutyQueue = inQueue;
lastStateChange = DateTime.Now;
if (inQueue)
{
log.Information("[CombatDuty] Duty queue active - pausing automation");
}
else
{
log.Information("[CombatDuty] Duty queue ended - resuming automation");
}
}
}
public TimeSpan TimeSinceLastStateChange()
{
if (lastStateChange == DateTime.MinValue)
{
return TimeSpan.Zero;
}
return DateTime.Now - lastStateChange;
}
private void EnableCombatCommands()
{
if (combatCommandsActive)
{
return;
}
try
{
log.Information("[CombatDuty] ========================================");
log.Information("[CombatDuty] === ENABLING COMBAT AUTOMATION ===");
log.Information("[CombatDuty] ========================================");
framework.RunOnTick(delegate
{
try
{
commandManager.ProcessCommand("/rsr auto");
log.Information("[CombatDuty] /rsr auto sent");
}
catch (Exception ex2)
{
log.Error("[CombatDuty] Failed to send /rsr auto: " + ex2.Message);
}
}, TimeSpan.Zero);
framework.RunOnTick(delegate
{
try
{
commandManager.ProcessCommand("/vbmai on");
log.Information("[CombatDuty] /vbmai on sent");
}
catch (Exception ex2)
{
log.Error("[CombatDuty] Failed to send /vbmai on: " + ex2.Message);
}
}, TimeSpan.FromMilliseconds(100L, 0L));
framework.RunOnTick(delegate
{
try
{
commandManager.ProcessCommand("/bmrai on");
log.Information("[CombatDuty] /bmrai on sent");
}
catch (Exception ex2)
{
log.Error("[CombatDuty] Failed to send /bmrai on: " + ex2.Message);
}
}, TimeSpan.FromMilliseconds(200L, 0L));
combatCommandsActive = true;
log.Information("[CombatDuty] Combat automation enabled");
}
catch (Exception ex)
{
log.Error("[CombatDuty] Error enabling combat commands: " + ex.Message);
}
}
private void DisableCombatCommands()
{
if (!combatCommandsActive)
{
return;
}
try
{
log.Information("[CombatDuty] ========================================");
log.Information("[CombatDuty] === DISABLING COMBAT AUTOMATION ===");
log.Information("[CombatDuty] ========================================");
framework.RunOnTick(delegate
{
try
{
commandManager.ProcessCommand("/rsr off");
log.Information("[CombatDuty] /rsr off sent");
}
catch (Exception ex2)
{
log.Error("[CombatDuty] Failed to send /rsr off: " + ex2.Message);
}
}, TimeSpan.Zero);
framework.RunOnTick(delegate
{
try
{
commandManager.ProcessCommand("/vbmai off");
log.Information("[CombatDuty] /vbmai off sent");
}
catch (Exception ex2)
{
log.Error("[CombatDuty] Failed to send /vbmai off: " + ex2.Message);
}
}, TimeSpan.FromMilliseconds(100L, 0L));
framework.RunOnTick(delegate
{
try
{
commandManager.ProcessCommand("/bmrai off");
log.Information("[CombatDuty] /bmrai off sent");
}
catch (Exception ex2)
{
log.Error("[CombatDuty] Failed to send /bmrai off: " + ex2.Message);
}
}, TimeSpan.FromMilliseconds(200L, 0L));
combatCommandsActive = false;
log.Information("[CombatDuty] Combat automation disabled");
}
catch (Exception ex)
{
log.Error("[CombatDuty] Error disabling combat commands: " + ex.Message);
}
}
public void ClearDutyExitFlag()
{
JustExitedDuty = false;
}
public void Reset()
{
IsInCombat = false;
IsInDuty = false;
IsInDutyQueue = false;
wasInCombat = false;
wasInDuty = false;
combatCommandsActive = false;
hasCombatCommandsForDuty = false;
JustEnteredDuty = false;
JustExitedDuty = false;
dutyExitTime = DateTime.MinValue;
dutyEntryTime = DateTime.MinValue;
log.Information("[CombatDuty] State reset");
}
public void Dispose()
{
if (combatCommandsActive)
{
DisableCombatCommands();
}
}
}

View file

@ -0,0 +1,514 @@
using System;
using System.Globalization;
using System.IO.MemoryMappedFiles;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Plugin.Services;
namespace QuestionableCompanion.Services;
public class CrossProcessIPC : IDisposable
{
private readonly IPluginLog log;
private readonly IFramework framework;
private readonly Configuration configuration;
private MemoryMappedFile? mmf;
private Thread? listenerThread;
private bool isRunning;
private const string MMF_NAME = "QSTCompanion_IPC";
private const int MMF_SIZE = 4096;
private const int POLLING_INTERVAL_MS = 10;
public event Action<string, ushort>? OnHelperAvailable;
public event Action<string, ushort>? OnHelperRequested;
public event Action? OnHelperDismissed;
public event Action<string>? OnChatMessageReceived;
public event Action<string>? OnCommandReceived;
public event Action<string, ushort>? OnHelperInParty;
public event Action<string, ushort>? OnHelperInDuty;
public event Action<string, ushort>? OnHelperReady;
public event Action? OnRequestHelperAnnouncements;
public event Action<string, ushort, uint, Vector3, Vector3, bool>? OnChauffeurSummonRequest;
public event Action<string>? OnChauffeurReadyForPickup;
public event Action<string, ushort>? OnChauffeurArrived;
public event Action<string, ushort, uint, string>? OnChauffeurZoneUpdate;
public event Action<string, ushort>? OnChauffeurMountReady;
public event Action? OnChauffeurPassengerMounted;
public event Action<string, ushort, string>? OnHelperStatusUpdate;
public event Action<string, ushort, uint, Vector3>? OnQuesterPositionUpdate;
public CrossProcessIPC(IPluginLog log, IFramework framework, Configuration configuration)
{
this.log = log;
this.framework = framework;
this.configuration = configuration;
InitializeIPC();
}
private void InitializeIPC()
{
try
{
mmf = MemoryMappedFile.CreateOrOpen("QSTCompanion_IPC", 4096L, MemoryMappedFileAccess.ReadWrite);
isRunning = true;
listenerThread = new Thread(ListenerLoop)
{
IsBackground = true,
Name = "QSTCompanion IPC Listener"
};
listenerThread.Start();
log.Information("[CrossProcessIPC] Initialized with Memory-Mapped File");
if (configuration.IsHighLevelHelper)
{
framework.RunOnFrameworkThread(delegate
{
AnnounceHelper();
});
}
}
catch (Exception ex)
{
log.Error("[CrossProcessIPC] Failed to initialize: " + ex.Message);
}
}
private void ListenerLoop()
{
string lastMessage = "";
while (isRunning)
{
try
{
if (mmf == null)
{
break;
}
using (MemoryMappedViewAccessor accessor = mmf.CreateViewAccessor(0L, 4096L, MemoryMappedFileAccess.Read))
{
byte[] buffer = new byte[4096];
accessor.ReadArray(0L, buffer, 0, 4096);
string message = Encoding.UTF8.GetString(buffer).TrimEnd('\0');
if (!string.IsNullOrEmpty(message) && message != lastMessage)
{
lastMessage = message;
ProcessMessage(message);
}
}
Thread.Sleep(10);
}
catch (Exception ex)
{
log.Error("[CrossProcessIPC] Listener error: " + ex.Message);
Thread.Sleep(1000);
}
}
}
private void ProcessMessage(string message)
{
try
{
string[] parts = message.Split('|');
if (parts.Length < 2)
{
return;
}
string command = parts[0];
_003C_003Ec__DisplayClass63_0 CS_0024_003C_003E8__locals0;
framework.RunOnFrameworkThread(delegate
{
try
{
string text = command;
if (text != null)
{
switch (text.Length)
{
case 16:
switch (text[0])
{
case 'H':
if (text == "HELPER_AVAILABLE" && parts.Length >= 3)
{
string text3 = parts[1];
if (ushort.TryParse(parts[2], out var result8))
{
log.Information($"[CrossProcessIPC] Helper available: {text3}@{result8}");
this.OnHelperAvailable?.Invoke(text3, result8);
}
}
break;
case 'C':
if (text == "CHAUFFEUR_SUMMON" && parts.Length >= 11)
{
ushort questerWorld = ushort.Parse(parts[2]);
uint zoneId = uint.Parse(parts[3]);
Vector3 targetPos = new Vector3(float.Parse(parts[4]), float.Parse(parts[5]), float.Parse(parts[6]));
Vector3 questerPos = new Vector3(float.Parse(parts[7]), float.Parse(parts[8]), float.Parse(parts[9]));
bool isAttuneAetheryte = bool.Parse(parts[10]);
framework.RunOnFrameworkThread(delegate
{
this.OnChauffeurSummonRequest?.Invoke((string)(object)CS_0024_003C_003E8__locals0, questerWorld, zoneId, targetPos, questerPos, isAttuneAetheryte);
});
}
break;
case 'Q':
if (text == "QUESTER_POSITION" && parts.Length >= 7)
{
string arg3 = parts[1];
if (ushort.TryParse(parts[2], out var result3) && uint.TryParse(parts[3], out var result4) && float.TryParse(parts[4], NumberStyles.Float, CultureInfo.InvariantCulture, out var result5) && float.TryParse(parts[5], NumberStyles.Float, CultureInfo.InvariantCulture, out var result6) && float.TryParse(parts[6], NumberStyles.Float, CultureInfo.InvariantCulture, out var result7))
{
Vector3 arg4 = new Vector3(result5, result6, result7);
this.OnQuesterPositionUpdate?.Invoke(arg3, result3, result4, arg4);
}
}
break;
}
break;
case 14:
switch (text[7])
{
case 'R':
if (text == "HELPER_REQUEST" && parts.Length >= 3)
{
string text12 = parts[1];
if (ushort.TryParse(parts[2], out var result14))
{
log.Information($"[CrossProcessIPC] Helper request: {text12}@{result14}");
this.OnHelperRequested?.Invoke(text12, result14);
}
}
break;
case 'D':
if (text == "HELPER_DISMISS")
{
log.Information("[CrossProcessIPC] Helper dismiss");
this.OnHelperDismissed?.Invoke();
}
break;
case 'I':
if (text == "HELPER_IN_DUTY" && parts.Length >= 3)
{
string text11 = parts[1];
if (ushort.TryParse(parts[2], out var result13))
{
log.Information($"[CrossProcessIPC] Helper in duty: {text11}@{result13}");
this.OnHelperInDuty?.Invoke(text11, result13);
}
}
break;
}
break;
case 15:
switch (text[0])
{
case 'H':
if (text == "HELPER_IN_PARTY" && parts.Length >= 3)
{
string text9 = parts[1];
if (ushort.TryParse(parts[2], out var result12))
{
log.Information($"[CrossProcessIPC] Helper in party: {text9}@{result12}");
this.OnHelperInParty?.Invoke(text9, result12);
}
}
break;
case 'C':
if (text == "CHAUFFEUR_READY" && parts.Length >= 2)
{
string text8 = parts[1];
log.Information("[CrossProcessIPC] Chauffeur ready: " + text8);
this.OnChauffeurReadyForPickup?.Invoke(text8);
}
break;
}
break;
case 21:
switch (text[10])
{
case 'Z':
if (text == "CHAUFFEUR_ZONE_UPDATE" && parts.Length >= 5)
{
string text5 = parts[1];
if (ushort.TryParse(parts[2], out var result10) && uint.TryParse(parts[3], out var result11))
{
string text6 = parts[4];
log.Information($"[CrossProcessIPC] Zone update: {text5}@{result10} -> {text6} ({result11})");
this.OnChauffeurZoneUpdate?.Invoke(text5, result10, result11, text6);
}
}
break;
case 'M':
if (text == "CHAUFFEUR_MOUNT_READY" && parts.Length >= 3)
{
string text4 = parts[1];
if (ushort.TryParse(parts[2], out var result9))
{
log.Information($"[CrossProcessIPC] Chauffeur mount ready for: {text4}@{result9}");
IPluginLog pluginLog = log;
DefaultInterpolatedStringHandler defaultInterpolatedStringHandler = new DefaultInterpolatedStringHandler(53, 1);
defaultInterpolatedStringHandler.AppendLiteral("[CrossProcessIPC] OnChauffeurMountReady subscribers: ");
Action<string, ushort>? action = this.OnChauffeurMountReady;
defaultInterpolatedStringHandler.AppendFormatted((action != null) ? action.GetInvocationList().Length : 0);
pluginLog.Information(defaultInterpolatedStringHandler.ToStringAndClear());
this.OnChauffeurMountReady?.Invoke(text4, result9);
}
}
break;
}
break;
case 4:
if (text == "CHAT" && parts.Length >= 2)
{
string text7 = parts[1];
log.Information("[CrossProcessIPC] Chat: " + text7);
this.OnChatMessageReceived?.Invoke(text7);
}
break;
case 7:
if (text == "COMMAND" && parts.Length >= 2)
{
string text10 = parts[1];
log.Information("[CrossProcessIPC] Command: " + text10);
this.OnCommandReceived?.Invoke(text10);
}
break;
case 12:
if (text == "HELPER_READY" && parts.Length >= 3)
{
string text13 = parts[1];
if (ushort.TryParse(parts[2], out var result15))
{
log.Information($"[CrossProcessIPC] Helper ready: {text13}@{result15}");
this.OnHelperReady?.Invoke(text13, result15);
}
}
break;
case 28:
if (text == "REQUEST_HELPER_ANNOUNCEMENTS")
{
log.Information("[CrossProcessIPC] Request for helper announcements received");
this.OnRequestHelperAnnouncements?.Invoke();
}
break;
case 17:
if (text == "CHAUFFEUR_ARRIVED" && parts.Length >= 3)
{
string text2 = parts[1];
if (ushort.TryParse(parts[2], out var result2))
{
log.Information($"[CrossProcessIPC] Chauffeur arrived for: {text2}@{result2}");
this.OnChauffeurArrived?.Invoke(text2, result2);
}
}
break;
case 27:
if (text == "CHAUFFEUR_PASSENGER_MOUNTED")
{
log.Information("[CrossProcessIPC] Chauffeur passenger mounted signal received");
IPluginLog pluginLog2 = log;
DefaultInterpolatedStringHandler defaultInterpolatedStringHandler2 = new DefaultInterpolatedStringHandler(59, 1);
defaultInterpolatedStringHandler2.AppendLiteral("[CrossProcessIPC] OnChauffeurPassengerMounted subscribers: ");
Action? action2 = this.OnChauffeurPassengerMounted;
defaultInterpolatedStringHandler2.AppendFormatted((action2 != null) ? action2.GetInvocationList().Length : 0);
pluginLog2.Information(defaultInterpolatedStringHandler2.ToStringAndClear());
this.OnChauffeurPassengerMounted?.Invoke();
}
break;
case 13:
if (text == "HELPER_STATUS" && parts.Length >= 4)
{
string arg = parts[1];
if (ushort.TryParse(parts[2], out var result))
{
string arg2 = parts[3];
this.OnHelperStatusUpdate?.Invoke(arg, result, arg2);
}
}
break;
}
}
}
catch (Exception ex2)
{
log.Error("[CrossProcessIPC] Error in event handler: " + ex2.Message);
}
});
}
catch (Exception ex)
{
log.Error("[CrossProcessIPC] Error processing message: " + ex.Message);
}
}
private void SendMessage(string message)
{
try
{
if (mmf == null)
{
return;
}
using MemoryMappedViewAccessor accessor = mmf.CreateViewAccessor(0L, 4096L, MemoryMappedFileAccess.Write);
byte[] buffer = Encoding.UTF8.GetBytes(message);
if (buffer.Length > 4095)
{
log.Warning($"[CrossProcessIPC] Message too large: {buffer.Length} bytes");
}
else
{
byte[] clearBuffer = new byte[4096];
accessor.WriteArray(0L, clearBuffer, 0, 4096);
accessor.WriteArray(0L, buffer, 0, buffer.Length);
}
}
catch (Exception ex)
{
log.Error("[CrossProcessIPC] Failed to send message: " + ex.Message);
}
}
public void AnnounceHelper()
{
if (configuration.IsHighLevelHelper)
{
IPlayerCharacter localPlayer = Plugin.ClientState?.LocalPlayer;
if (localPlayer != null)
{
string name = localPlayer.Name.ToString();
ushort worldId = (ushort)localPlayer.HomeWorld.RowId;
SendMessage($"HELPER_AVAILABLE|{name}|{worldId}");
log.Information($"[CrossProcessIPC] Announced as helper: {name}@{worldId}");
}
}
}
public void RequestHelper(string characterName, ushort worldId)
{
SendMessage($"HELPER_REQUEST|{characterName}|{worldId}");
log.Information($"[CrossProcessIPC] Requested helper: {characterName}@{worldId}");
}
public void DismissHelper()
{
SendMessage("HELPER_DISMISS");
log.Information("[CrossProcessIPC] Dismissed helper");
}
public void SendChatMessage(string message)
{
SendMessage("CHAT|" + message);
log.Information("[CrossProcessIPC] Chat: " + message);
}
public void SendCommand(string command)
{
SendMessage("COMMAND|" + command);
log.Information("[CrossProcessIPC] Command: " + command);
}
public void NotifyHelperInParty(string name, ushort worldId)
{
SendMessage($"HELPER_IN_PARTY|{name}|{worldId}");
log.Information($"[CrossProcessIPC] Notified: Helper in party {name}@{worldId}");
}
public void NotifyHelperInDuty(string name, ushort worldId)
{
SendMessage($"HELPER_IN_DUTY|{name}|{worldId}");
log.Information($"[CrossProcessIPC] Notified: Helper in duty {name}@{worldId}");
}
public void NotifyHelperReady(string name, ushort worldId)
{
SendMessage($"HELPER_READY|{name}|{worldId}");
log.Information($"[CrossProcessIPC] Notified: Helper ready {name}@{worldId}");
}
public void RequestHelperAnnouncements()
{
SendMessage("REQUEST_HELPER_ANNOUNCEMENTS");
log.Information("[CrossProcessIPC] Requesting helper announcements from all clients");
}
public void SendChauffeurSummonRequest(string questerName, ushort questerWorld, uint zoneId, Vector3 targetPos, Vector3 questerPos, bool isAttuneAetheryte)
{
SendMessage($"CHAUFFEUR_SUMMON|{questerName}|{questerWorld}|{zoneId}|{targetPos.X}|{targetPos.Y}|{targetPos.Z}|{questerPos.X}|{questerPos.Y}|{questerPos.Z}|{isAttuneAetheryte}");
log.Information($"[CrossProcessIPC] Chauffeur summon: {questerName}@{questerWorld} zone {zoneId} quester@({questerPos.X:F2},{questerPos.Y:F2},{questerPos.Z:F2}) AttuneAetheryte={isAttuneAetheryte}");
}
public void SendChauffeurMountReady(string questerName, ushort questerWorld)
{
SendMessage($"CHAUFFEUR_MOUNT_READY|{questerName}|{questerWorld}");
log.Information($"[CrossProcessIPC] Chauffeur mount ready for RidePillion: {questerName}@{questerWorld}");
}
public void SendChauffeurPassengerMounted()
{
SendMessage("CHAUFFEUR_PASSENGER_MOUNTED");
log.Debug("[CrossProcessIPC] Sent: CHAUFFEUR_PASSENGER_MOUNTED");
}
public void SendChauffeurReadyForPickup(string helperName)
{
SendMessage("CHAUFFEUR_READY|" + helperName);
log.Information("[CrossProcessIPC] Chauffeur ready: " + helperName);
}
public void SendChauffeurArrived(string questerName, ushort questerWorld)
{
SendMessage($"CHAUFFEUR_ARRIVED|{questerName}|{questerWorld}");
log.Information($"[CrossProcessIPC] Chauffeur arrived for: {questerName}@{questerWorld}");
}
public void SendChauffeurZoneUpdate(string characterName, ushort worldId, uint zoneId, string zoneName)
{
SendMessage($"CHAUFFEUR_ZONE_UPDATE|{characterName}|{worldId}|{zoneId}|{zoneName}");
log.Information($"[CrossProcessIPC] Zone update: {characterName}@{worldId} -> {zoneName} ({zoneId})");
}
public void BroadcastHelperStatus(string helperName, ushort helperWorld, string status)
{
SendMessage($"HELPER_STATUS|{helperName}|{helperWorld}|{status}");
}
public void BroadcastQuesterPosition(string questerName, ushort questerWorld, uint zoneId, Vector3 position)
{
SendMessage($"QUESTER_POSITION|{questerName}|{questerWorld}|{zoneId}|{position.X.ToString(CultureInfo.InvariantCulture)}|{position.Y.ToString(CultureInfo.InvariantCulture)}|{position.Z.ToString(CultureInfo.InvariantCulture)}");
}
public void Dispose()
{
isRunning = false;
listenerThread?.Join(1000);
mmf?.Dispose();
log.Information("[CrossProcessIPC] Disposed");
}
}

View file

@ -0,0 +1,14 @@
using System.Numerics;
namespace QuestionableCompanion.Services;
public class CurrentTask
{
public required string Type { get; init; }
public ushort TerritoryId { get; init; }
public string? InteractionType { get; init; }
public Vector3? Position { get; init; }
}

View file

@ -0,0 +1,374 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Dalamud.Plugin.Services;
namespace QuestionableCompanion.Services;
public class DCTravelService : IDisposable
{
private readonly IPluginLog log;
private readonly Configuration config;
private readonly IClientState clientState;
private readonly LifestreamIPC lifestreamIPC;
private readonly QuestionableIPC questionableIPC;
private readonly CharacterSafeWaitService safeWaitService;
private readonly ICommandManager commandManager;
private readonly IFramework framework;
private bool dcTravelCompleted;
private bool dcTravelInProgress;
public DCTravelService(IPluginLog log, Configuration config, LifestreamIPC lifestreamIPC, QuestionableIPC questionableIPC, CharacterSafeWaitService safeWaitService, IClientState clientState, ICommandManager commandManager, IFramework framework)
{
this.log = log;
this.config = config;
this.lifestreamIPC = lifestreamIPC;
this.questionableIPC = questionableIPC;
this.safeWaitService = safeWaitService;
this.clientState = clientState;
this.commandManager = commandManager;
this.framework = framework;
}
public bool ShouldPerformDCTravel()
{
log.Information("[DCTravel] ========================================");
log.Information("[DCTravel] === DC TRAVEL CHECK START ===");
log.Information("[DCTravel] ========================================");
log.Information($"[DCTravel] Config.EnableDCTravel: {config.EnableDCTravel}");
log.Information("[DCTravel] Config.DCTravelWorld: '" + config.DCTravelWorld + "'");
log.Information($"[DCTravel] State.dcTravelCompleted: {dcTravelCompleted}");
log.Information($"[DCTravel] State.dcTravelInProgress: {dcTravelInProgress}");
if (dcTravelCompleted)
{
log.Warning("[DCTravel] SKIP: Already completed for this character");
return false;
}
if (dcTravelInProgress)
{
log.Warning("[DCTravel] SKIP: Travel already in progress");
return false;
}
if (!config.EnableDCTravel)
{
log.Warning("[DCTravel] SKIP: DC Travel is DISABLED in config");
return false;
}
if (string.IsNullOrEmpty(config.DCTravelWorld))
{
log.Warning("[DCTravel] SKIP: No target world configured");
return false;
}
if (clientState.LocalPlayer == null)
{
log.Error("[DCTravel] SKIP: LocalPlayer is NULL");
return false;
}
string currentWorld = clientState.LocalPlayer.CurrentWorld.Value.Name.ToString();
log.Information("[DCTravel] Current World: '" + currentWorld + "'");
log.Information("[DCTravel] Target World: '" + config.DCTravelWorld + "'");
if (currentWorld.Equals(config.DCTravelWorld, StringComparison.OrdinalIgnoreCase))
{
log.Warning("[DCTravel] SKIP: Already on target world '" + config.DCTravelWorld + "'");
return false;
}
log.Information("[DCTravel] ========================================");
log.Information("[DCTravel] DC TRAVEL WILL BE PERFORMED!");
log.Information("[DCTravel] ========================================");
return true;
}
public async Task<bool> PerformDCTravel()
{
if (dcTravelInProgress)
{
log.Warning("[DCTravel] DC Travel already in progress");
return false;
}
if (string.IsNullOrEmpty(config.DCTravelWorld))
{
log.Error("[DCTravel] No target world configured");
return false;
}
log.Information("[DCTravel] ========================================");
log.Information("[DCTravel] === CHECKING LIFESTREAM AVAILABILITY ===");
log.Information("[DCTravel] ========================================");
log.Information($"[DCTravel] lifestreamIPC.IsAvailable (cached): {lifestreamIPC.IsAvailable}");
bool isAvailable = lifestreamIPC.ForceCheckAvailability();
log.Information($"[DCTravel] lifestreamIPC.ForceCheckAvailability() result: {isAvailable}");
if (!isAvailable)
{
log.Error("[DCTravel] ========================================");
log.Error("[DCTravel] ======= LIFESTREAM NOT AVAILABLE! ======");
log.Error("[DCTravel] ========================================");
log.Error("[DCTravel] Possible reasons:");
log.Error("[DCTravel] 1. Lifestream plugin is not installed");
log.Error("[DCTravel] 2. Lifestream plugin is not enabled");
log.Error("[DCTravel] 3. Lifestream plugin failed to load");
log.Error("[DCTravel] 4. IPC communication error");
log.Error("[DCTravel] ========================================");
return false;
}
log.Information("[DCTravel] Lifestream is available!");
dcTravelInProgress = true;
try
{
log.Information("[DCTravel] === INITIATING DATA CENTER TRAVEL ===");
log.Information("[DCTravel] Target World: " + config.DCTravelWorld);
log.Information("[DCTravel] ========================================");
log.Information("[DCTravel] === STOPPING QUESTIONABLE ===");
log.Information("[DCTravel] ========================================");
log.Information($"[DCTravel] Questionable IsRunning: {questionableIPC.IsRunning()}");
try
{
framework.RunOnFrameworkThread(delegate
{
commandManager.ProcessCommand("/qst stop");
Thread.Sleep(1000);
});
log.Information("[DCTravel] /qst stop command sent on Framework Thread");
log.Information($"[DCTravel] Questionable IsRunning after stop: {questionableIPC.IsRunning()}");
}
catch (Exception ex)
{
log.Error("[DCTravel] Error stopping Questionable: " + ex.Message);
}
log.Information("[DCTravel] Initiating travel to " + config.DCTravelWorld + "...");
log.Information("[DCTravel] Checking Lifestream status before travel...");
log.Information($"[DCTravel] Lifestream.IsAvailable: {lifestreamIPC.IsAvailable}");
log.Information($"[DCTravel] Lifestream.IsBusy(): {lifestreamIPC.IsBusy()}");
if (lifestreamIPC.IsBusy())
{
log.Error("[DCTravel] Lifestream is BUSY! Cannot start travel!");
framework.RunOnFrameworkThread(delegate
{
commandManager.ProcessCommand("/qst start");
}).Wait();
log.Information("[DCTravel] /qst start command sent on Framework Thread (recovery)");
dcTravelInProgress = false;
return false;
}
log.Information("[DCTravel] Sending /li " + config.DCTravelWorld + " command on Framework Thread...");
bool commandSuccess = false;
try
{
framework.RunOnFrameworkThread(delegate
{
try
{
commandManager.ProcessCommand("/li " + config.DCTravelWorld);
commandSuccess = true;
log.Information("[DCTravel] /li " + config.DCTravelWorld + " command executed on Framework Thread");
}
catch (Exception ex5)
{
log.Error("[DCTravel] Failed to execute /li command: " + ex5.Message);
commandSuccess = false;
}
}).Wait();
if (!commandSuccess)
{
log.Error("[DCTravel] Failed to send /li command");
framework.RunOnFrameworkThread(delegate
{
commandManager.ProcessCommand("/qst start");
}).Wait();
log.Information("[DCTravel] /qst start command sent (recovery)");
dcTravelInProgress = false;
return false;
}
Thread.Sleep(1000);
bool isBusy = lifestreamIPC.IsBusy();
log.Information($"[DCTravel] Lifestream.IsBusy() after command: {isBusy}");
if (!isBusy)
{
log.Warning("[DCTravel] Lifestream did not become busy after command!");
log.Warning("[DCTravel] Travel may not have started - check Lifestream manually");
}
}
catch (Exception ex2)
{
log.Error("[DCTravel] Error sending /li command: " + ex2.Message);
framework.RunOnFrameworkThread(delegate
{
commandManager.ProcessCommand("/qst start");
}).Wait();
log.Information("[DCTravel] /qst start command sent (recovery)");
dcTravelInProgress = false;
return false;
}
log.Information("[DCTravel] Waiting for travel completion...");
if (!(await WaitForTravelCompletion(120)))
{
log.Warning("[DCTravel] Travel timeout - proceeding anyway");
}
log.Information("[DCTravel] ========================================");
log.Information("[DCTravel] === RESUMING QUESTIONABLE ===");
log.Information("[DCTravel] ========================================");
try
{
framework.RunOnFrameworkThread(delegate
{
commandManager.ProcessCommand("/qst start");
}).Wait();
log.Information("[DCTravel] /qst start command sent on Framework Thread");
Thread.Sleep(1000);
log.Information($"[DCTravel] Questionable IsRunning after start: {questionableIPC.IsRunning()}");
}
catch (Exception ex3)
{
log.Error("[DCTravel] Error starting Questionable: " + ex3.Message);
}
dcTravelCompleted = true;
dcTravelInProgress = false;
log.Information("[DCTravel] === DATA CENTER TRAVEL COMPLETE ===");
return true;
}
catch (Exception ex4)
{
log.Error("[DCTravel] Error during DC travel: " + ex4.Message);
try
{
framework.RunOnFrameworkThread(delegate
{
commandManager.ProcessCommand("/qst start");
}).Wait();
log.Information("[DCTravel] /qst start command sent on Framework Thread (error recovery)");
}
catch
{
}
dcTravelInProgress = false;
return false;
}
}
private async Task<bool> WaitForTravelCompletion(int timeoutSeconds)
{
DateTime startTime = DateTime.Now;
TimeSpan timeout = TimeSpan.FromSeconds(timeoutSeconds);
log.Information("[DCTravel] ========================================");
log.Information("[DCTravel] === WAITING FOR TRAVEL TO START ===");
log.Information("[DCTravel] ========================================");
DateTime phase1Start = DateTime.Now;
TimeSpan phase1Timeout = TimeSpan.FromSeconds(30L);
bool travelStarted = false;
log.Information("[DCTravel] Waiting 5 seconds for Lifestream to queue travel tasks...");
await Task.Delay(5000);
while (DateTime.Now - phase1Start < phase1Timeout)
{
try
{
if (lifestreamIPC.IsBusy())
{
log.Information("[DCTravel] Lifestream travel has STARTED!");
travelStarted = true;
break;
}
double elapsed = (DateTime.Now - phase1Start).TotalSeconds;
if (elapsed % 5.0 < 0.5)
{
log.Information($"[DCTravel] Waiting for travel to start... ({elapsed:F1}s)");
}
}
catch (Exception ex)
{
log.Debug("[DCTravel] Error checking Lifestream status: " + ex.Message);
}
await Task.Delay(1000);
}
if (!travelStarted)
{
log.Error("[DCTravel] ❌ Travel did not start within 30 seconds!");
return false;
}
log.Information("[DCTravel] ========================================");
log.Information("[DCTravel] === WAITING FOR TRAVEL TO COMPLETE ===");
log.Information("[DCTravel] ========================================");
while (DateTime.Now - startTime < timeout)
{
try
{
if (!lifestreamIPC.IsBusy())
{
log.Information("[DCTravel] Lifestream is no longer busy!");
log.Information("[DCTravel] Waiting 30 seconds for character to stabilize...");
await Task.Delay(30000);
log.Information("[DCTravel] Travel complete!");
return true;
}
double elapsed2 = (DateTime.Now - startTime).TotalSeconds;
if (elapsed2 % 10.0 < 0.5)
{
log.Information($"[DCTravel] Lifestream is busy (traveling)... ({elapsed2:F0}s)");
}
}
catch (Exception ex2)
{
log.Debug("[DCTravel] Error checking Lifestream status: " + ex2.Message);
}
await Task.Delay(1000);
}
log.Warning($"[DCTravel] Travel timeout after {timeoutSeconds}s");
return false;
}
public async Task<bool> ReturnToHomeworld()
{
if (!lifestreamIPC.IsAvailable)
{
log.Warning("[DCTravel] Lifestream not available for homeworld return");
return false;
}
if (clientState.LocalPlayer == null)
{
return false;
}
string homeWorld = clientState.LocalPlayer.HomeWorld.Value.Name.ToString();
string currentWorld = clientState.LocalPlayer.CurrentWorld.Value.Name.ToString();
if (homeWorld.Equals(currentWorld, StringComparison.OrdinalIgnoreCase))
{
log.Information("[DCTravel] Already on homeworld");
return true;
}
log.Information("[DCTravel] Returning to homeworld: " + homeWorld);
bool success = lifestreamIPC.ChangeWorld(homeWorld);
if (success)
{
await Task.Delay(2000);
log.Information("[DCTravel] Homeworld return initiated");
}
return success;
}
public void ResetDCTravelState()
{
dcTravelCompleted = false;
dcTravelInProgress = false;
log.Information("[DCTravel] DC Travel state reset");
}
public bool IsDCTravelCompleted()
{
return dcTravelCompleted;
}
public bool IsDCTravelInProgress()
{
return dcTravelInProgress;
}
public void Dispose()
{
log.Information("[DCTravel] Service disposed");
}
}

View file

@ -0,0 +1,206 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Dalamud.Plugin.Services;
using Lumina.Excel;
using Lumina.Excel.Sheets;
namespace QuestionableCompanion.Services;
public class DataCenterService
{
private readonly IDataManager dataManager;
private readonly IPluginLog log;
private readonly Dictionary<string, string> worldToDCCache = new Dictionary<string, string>();
private readonly Dictionary<string, string> dataCenterToRegion = new Dictionary<string, string>
{
{ "Chaos", "EU" },
{ "Light", "EU" },
{ "Shadow", "EU" },
{ "Aether", "NA" },
{ "Primal", "NA" },
{ "Crystal", "NA" },
{ "Dynamis", "NA" },
{ "Elemental", "JP" },
{ "Gaia", "JP" },
{ "Mana", "JP" },
{ "Meteor", "JP" },
{ "Materia", "OCE" },
{ "陆行鸟", "Others" },
{ "莫古力", "Others" },
{ "猫小胖", "Others" },
{ "豆豆柴", "Others" }
};
public DataCenterService(IDataManager dataManager, IPluginLog log)
{
this.dataManager = dataManager;
this.log = log;
}
public void InitializeWorldMapping()
{
try
{
ExcelSheet<World> worldSheet = dataManager.GetExcelSheet<World>();
if (worldSheet == null)
{
return;
}
int worldCount = 0;
int skippedCount = 0;
foreach (World world in worldSheet)
{
if (world.RowId == 0)
{
continue;
}
string worldName = world.Name.ExtractText();
if (string.IsNullOrEmpty(worldName))
{
skippedCount++;
continue;
}
WorldDCGroupType? dataCenterGroup = world.DataCenter.ValueNullable;
if (!dataCenterGroup.HasValue)
{
skippedCount++;
continue;
}
string dataCenterName = dataCenterGroup.Value.Name.ExtractText();
if (string.IsNullOrEmpty(dataCenterName))
{
skippedCount++;
continue;
}
if (!world.IsPublic)
{
skippedCount++;
continue;
}
string region = GetRegionForDataCenter(dataCenterName);
worldToDCCache[worldName.ToLower()] = region;
worldCount++;
if (worldCount > 10)
{
_ = region != "Others";
}
}
}
catch (Exception)
{
}
}
private string GetRegionForDataCenter(string dataCenterName)
{
if (dataCenterToRegion.TryGetValue(dataCenterName, out string region))
{
return region;
}
return "Others";
}
public string GetDataCenterForWorld(string worldName)
{
if (string.IsNullOrEmpty(worldName))
{
return "Unknown";
}
string key = worldName.ToLower();
if (worldToDCCache.TryGetValue(key, out string dataCenter))
{
return dataCenter;
}
return "Unknown";
}
public Dictionary<string, List<string>> GroupCharactersByDataCenter(List<string> characters)
{
Dictionary<string, List<string>> grouped = new Dictionary<string, List<string>>
{
{
"EU",
new List<string>()
},
{
"NA",
new List<string>()
},
{
"JP",
new List<string>()
},
{
"OCE",
new List<string>()
},
{
"Others",
new List<string>()
},
{
"Unknown",
new List<string>()
}
};
foreach (string character in characters)
{
try
{
string[] parts = character.Split('@');
if (parts.Length != 2)
{
grouped["Unknown"].Add(character);
continue;
}
string worldName = parts[1];
string dataCenter = GetDataCenterForWorld(worldName);
if (!grouped.ContainsKey(dataCenter))
{
grouped[dataCenter] = new List<string>();
}
grouped[dataCenter].Add(character);
}
catch (Exception)
{
grouped["Unknown"].Add(character);
}
}
foreach (KeyValuePair<string, List<string>> item in grouped.Where((KeyValuePair<string, List<string>> g) => g.Value.Count > 0))
{
_ = item;
}
return grouped;
}
public List<string> GetAvailableDataCenters(Dictionary<string, List<string>> charactersByDataCenter)
{
List<string> dataCenters = new List<string> { "All" };
string[] array = new string[6] { "EU", "NA", "JP", "OCE", "Others", "Unknown" };
foreach (string dc in array)
{
if (charactersByDataCenter.TryGetValue(dc, out List<string> chars) && chars.Count > 0)
{
dataCenters.Add(dc);
}
}
return dataCenters;
}
public List<string> GetCharactersForDataCenter(List<string> allCharacters, string dataCenterName, Dictionary<string, List<string>> charactersByDataCenter)
{
if (dataCenterName == "All")
{
return allCharacters;
}
if (charactersByDataCenter.TryGetValue(dataCenterName, out List<string> characters))
{
return characters;
}
return new List<string>();
}
}

View file

@ -0,0 +1,252 @@
using System;
using System.Numerics;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.NativeWrapper;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Component.GUI;
using Lumina.Excel;
using Lumina.Excel.Sheets;
namespace QuestionableCompanion.Services;
public class DeathHandlerService : IDisposable
{
private readonly ICondition condition;
private readonly IPluginLog log;
private readonly IClientState clientState;
private readonly ICommandManager commandManager;
private readonly IFramework framework;
private readonly Configuration config;
private readonly IGameGui gameGui;
private readonly IDataManager dataManager;
private bool wasDead;
private Vector3 deathPosition = Vector3.Zero;
private uint deathTerritoryId;
private DateTime deathTime = DateTime.MinValue;
private bool hasClickedYesNo;
private bool needsTeleportBack;
private bool isRotationActive;
public bool IsDead { get; private set; }
public TimeSpan TimeSinceDeath
{
get
{
if (!IsDead || !(deathTime != DateTime.MinValue))
{
return TimeSpan.Zero;
}
return DateTime.Now - deathTime;
}
}
public DeathHandlerService(ICondition condition, IPluginLog log, IClientState clientState, ICommandManager commandManager, IFramework framework, Configuration config, IGameGui gameGui, IDataManager dataManager)
{
this.condition = condition;
this.log = log;
this.clientState = clientState;
this.commandManager = commandManager;
this.framework = framework;
this.config = config;
this.gameGui = gameGui;
this.dataManager = dataManager;
log.Information("[DeathHandler] Service initialized");
}
public void SetRotationActive(bool active)
{
isRotationActive = active;
}
public unsafe void Update()
{
if (clientState.LocalPlayer == null || !clientState.IsLoggedIn || !config.EnableDeathHandling)
{
return;
}
IPlayerCharacter player = clientState.LocalPlayer;
uint currentHp = player.CurrentHp;
if (currentHp == 0 && !wasDead)
{
IsDead = true;
wasDead = true;
deathTime = DateTime.Now;
deathPosition = player.Position;
deathTerritoryId = clientState.TerritoryType;
hasClickedYesNo = false;
needsTeleportBack = false;
string territoryName = GetTerritoryName(deathTerritoryId);
log.Warning("[DeathHandler] ========================================");
log.Warning("[DeathHandler] === PLAYER DIED (0% HP) ===");
log.Warning("[DeathHandler] ========================================");
log.Information($"[DeathHandler] Death Position: {deathPosition}");
log.Information($"[DeathHandler] Death Territory: {territoryName} ({deathTerritoryId})");
}
if (IsDead && !hasClickedYesNo)
{
try
{
AtkUnitBasePtr addonPtr = gameGui.GetAddonByName("SelectYesno");
if (addonPtr != IntPtr.Zero)
{
log.Information("[DeathHandler] SelectYesNo addon detected - clicking YES");
AtkUnitBase* addon = (AtkUnitBase*)(nint)addonPtr;
if (addon != null && addon->IsVisible)
{
AtkValue* values = stackalloc AtkValue[1];
*values = new AtkValue
{
Type = FFXIVClientStructs.FFXIV.Component.GUI.ValueType.Int,
Int = 0
};
addon->FireCallback(1u, values);
hasClickedYesNo = true;
log.Information("[DeathHandler] ✓ SelectYesNo callback fired (YES)");
log.Information("[DeathHandler] Waiting for respawn...");
}
}
}
catch (Exception ex)
{
log.Error("[DeathHandler] Error clicking SelectYesNo: " + ex.Message);
}
}
if (wasDead && currentHp != 0 && !player.IsDead)
{
log.Information("[DeathHandler] ========================================");
log.Information("[DeathHandler] === PLAYER RESPAWNED ===");
log.Information("[DeathHandler] ========================================");
IsDead = false;
wasDead = false;
needsTeleportBack = true;
log.Information("[DeathHandler] Preparing to teleport back to death location...");
}
if (needsTeleportBack && !IsDead && currentHp != 0 && (DateTime.Now - deathTime).TotalSeconds >= (double)config.DeathRespawnDelay)
{
TeleportBackToDeathLocation();
needsTeleportBack = false;
}
}
private string GetTerritoryName(uint territoryId)
{
try
{
ExcelSheet<TerritoryType> territorySheet = dataManager.GetExcelSheet<TerritoryType>();
if (territorySheet == null)
{
return territoryId.ToString();
}
if (!territorySheet.HasRow(territoryId))
{
return territoryId.ToString();
}
RowRef<PlaceName> placeNameRef = territorySheet.GetRow(territoryId).PlaceName;
if (placeNameRef.RowId == 0)
{
return territoryId.ToString();
}
PlaceName? placeName = placeNameRef.ValueNullable;
if (!placeName.HasValue)
{
return territoryId.ToString();
}
string name = placeName.Value.Name.ExtractText();
if (!string.IsNullOrEmpty(name))
{
return name;
}
}
catch (Exception ex)
{
log.Error("[DeathHandler] Error getting territory name: " + ex.Message);
}
return territoryId.ToString();
}
private void TeleportBackToDeathLocation()
{
try
{
string territoryName = GetTerritoryName(deathTerritoryId);
log.Information("[DeathHandler] ========================================");
log.Information("[DeathHandler] === TELEPORTING BACK TO DEATH LOCATION ===");
log.Information("[DeathHandler] ========================================");
log.Information($"[DeathHandler] Target Territory: {territoryName} ({deathTerritoryId})");
log.Information($"[DeathHandler] Target Position: {deathPosition}");
if (clientState.TerritoryType == deathTerritoryId)
{
log.Information("[DeathHandler] Already in death territory - using /li to teleport to position");
framework.RunOnFrameworkThread(delegate
{
try
{
string text = $"/li {deathPosition.X:F2}, {deathPosition.Y:F2}, {deathPosition.Z:F2}";
commandManager.ProcessCommand(text);
log.Information("[DeathHandler] ✓ Teleport to position: " + text);
}
catch (Exception ex2)
{
log.Error("[DeathHandler] Failed to send teleport command: " + ex2.Message);
}
}).Wait();
}
else
{
log.Information("[DeathHandler] Different territory - teleporting to death territory");
framework.RunOnFrameworkThread(delegate
{
try
{
string text = "/li " + territoryName;
commandManager.ProcessCommand(text);
log.Information("[DeathHandler] ✓ Teleport to territory: " + text);
}
catch (Exception ex2)
{
log.Error("[DeathHandler] Failed to send teleport command: " + ex2.Message);
}
}).Wait();
}
deathPosition = Vector3.Zero;
deathTerritoryId = 0u;
deathTime = DateTime.MinValue;
log.Information("[DeathHandler] Death handling complete");
}
catch (Exception ex)
{
log.Error("[DeathHandler] Error during teleport: " + ex.Message);
}
}
public void Reset()
{
IsDead = false;
wasDead = false;
needsTeleportBack = false;
hasClickedYesNo = false;
deathPosition = Vector3.Zero;
deathTerritoryId = 0u;
deathTime = DateTime.MinValue;
log.Information("[DeathHandler] State reset");
}
public void Dispose()
{
}
}

View file

@ -0,0 +1,378 @@
using System;
using System.Threading.Tasks;
using Dalamud.Game.ClientState.Conditions;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game.Group;
using Newtonsoft.Json.Linq;
namespace QuestionableCompanion.Services;
public class DungeonAutomationService : IDisposable
{
private readonly ICondition condition;
private readonly IPluginLog log;
private readonly IClientState clientState;
private readonly ICommandManager commandManager;
private readonly IFramework framework;
private readonly IGameGui gameGui;
private readonly Configuration config;
private readonly HelperManager helperManager;
private readonly MemoryHelper memoryHelper;
private readonly QuestionableIPC questionableIPC;
private bool isWaitingForParty;
private DateTime partyInviteTime = DateTime.MinValue;
private int inviteAttempts;
private bool isInvitingHelpers;
private DateTime helperInviteTime = DateTime.MinValue;
private bool isInDuty;
private bool hasStoppedAD;
private DateTime dutyEntryTime = DateTime.MinValue;
private bool pendingAutomationStop;
private DateTime lastDutyExitTime = DateTime.MinValue;
private DateTime lastDutyEntryTime = DateTime.MinValue;
private bool expectingDutyEntry;
private bool isAutomationActive;
private int originalDutyMode;
private bool hasSentAtY;
public bool IsWaitingForParty => isWaitingForParty;
public int CurrentPartySize { get; private set; } = 1;
public bool IsInAutoDutyDungeon => isAutomationActive;
public DungeonAutomationService(ICondition condition, IPluginLog log, IClientState clientState, ICommandManager commandManager, IFramework framework, IGameGui gameGui, Configuration config, HelperManager helperManager, MemoryHelper memoryHelper, QuestionableIPC questionableIPC)
{
this.condition = condition;
this.log = log;
this.clientState = clientState;
this.commandManager = commandManager;
this.framework = framework;
this.gameGui = gameGui;
this.config = config;
this.helperManager = helperManager;
this.memoryHelper = memoryHelper;
this.questionableIPC = questionableIPC;
condition.ConditionChange += OnConditionChanged;
log.Information("[DungeonAutomation] Service initialized with ConditionChange event");
log.Information($"[DungeonAutomation] Config - Required Party Size: {config.AutoDutyPartySize}");
log.Information($"[DungeonAutomation] Config - Party Wait Time: {config.AutoDutyMaxWaitForParty}s");
log.Information($"[DungeonAutomation] Config - Dungeon Automation Enabled: {config.EnableAutoDutyUnsynced}");
SetDutyModeBasedOnConfig();
}
public void StartDungeonAutomation()
{
if (!isAutomationActive)
{
log.Information("[DungeonAutomation] ========================================");
log.Information("[DungeonAutomation] === STARTING DUNGEON AUTOMATION ===");
log.Information("[DungeonAutomation] ========================================");
isAutomationActive = true;
expectingDutyEntry = true;
log.Information("[DungeonAutomation] Inviting helpers via HelperManager...");
helperManager.InviteHelpers();
isInvitingHelpers = true;
helperInviteTime = DateTime.Now;
inviteAttempts = 0;
}
}
public void SetDutyModeBasedOnConfig()
{
if (config.EnableAutoDutyUnsynced)
{
questionableIPC.SetDefaultDutyMode(2);
log.Information("[DungeonAutomation] Set Duty Mode to Unsync Party (2) - Automation Enabled");
}
else
{
questionableIPC.SetDefaultDutyMode(0);
log.Information("[DungeonAutomation] Set Duty Mode to Support (0) - Automation Disabled");
}
}
public void StopDungeonAutomation()
{
if (isAutomationActive)
{
log.Information("[DungeonAutomation] ========================================");
log.Information("[DungeonAutomation] === STOPPING DUNGEON AUTOMATION ===");
log.Information("[DungeonAutomation] ========================================");
isAutomationActive = false;
Reset();
}
}
private void UpdateHelperInvite()
{
double timeSinceInvite = (DateTime.Now - helperInviteTime).TotalSeconds;
try
{
if (timeSinceInvite >= 2.0)
{
isInvitingHelpers = false;
isWaitingForParty = true;
partyInviteTime = DateTime.Now;
log.Information("[DungeonAutomation] Helper invites sent, waiting for party...");
}
}
catch (Exception ex)
{
log.Error("[DungeonAutomation] Error in helper invite: " + ex.Message);
isInvitingHelpers = false;
}
}
public void Update()
{
if (config.EnableAutoDutyUnsynced && !isAutomationActive)
{
CheckWaitForPartyTask();
}
if (!hasStoppedAD && dutyEntryTime != DateTime.MinValue && (DateTime.Now - dutyEntryTime).TotalSeconds >= 1.0)
{
try
{
commandManager.ProcessCommand("/ad stop");
log.Information("[DungeonAutomation] /ad stop (1s after duty entry)");
hasStoppedAD = true;
dutyEntryTime = DateTime.MinValue;
}
catch (Exception ex)
{
log.Error("[DungeonAutomation] Failed to stop AD: " + ex.Message);
}
}
if (isInvitingHelpers)
{
UpdateHelperInvite();
}
else if (pendingAutomationStop && (DateTime.Now - dutyEntryTime).TotalSeconds >= 5.0)
{
log.Information("[DungeonAutomation] 5s delay complete - stopping automation now");
StopDungeonAutomation();
pendingAutomationStop = false;
}
else if (isWaitingForParty)
{
UpdatePartySize();
if (CurrentPartySize >= config.AutoDutyPartySize)
{
log.Information("[DungeonAutomation] ========================================");
log.Information("[DungeonAutomation] === PARTY FULL ===");
log.Information("[DungeonAutomation] ========================================");
log.Information($"[DungeonAutomation] Party Size: {CurrentPartySize}/{config.AutoDutyPartySize}");
isWaitingForParty = false;
partyInviteTime = DateTime.MinValue;
inviteAttempts = 0;
log.Information("[DungeonAutomation] Party full - ready for dungeon!");
}
else if ((DateTime.Now - partyInviteTime).TotalSeconds >= (double)config.AutoDutyMaxWaitForParty)
{
log.Warning($"[DungeonAutomation] Party not full after {config.AutoDutyMaxWaitForParty}s - retrying invite (Attempt #{inviteAttempts + 1})");
log.Information($"[DungeonAutomation] Current Party Size: {CurrentPartySize}/{config.AutoDutyPartySize}");
log.Information("[DungeonAutomation] Retrying helper invites...");
helperManager.InviteHelpers();
partyInviteTime = DateTime.Now;
}
}
}
private void CheckWaitForPartyTask()
{
if (questionableIPC.GetCurrentTask() is JObject jObject)
{
JToken taskNameToken = jObject["TaskName"];
if (taskNameToken != null && taskNameToken.ToString() == "WaitForParty")
{
StartDungeonAutomation();
}
}
}
private unsafe void UpdatePartySize()
{
try
{
int partySize = 0;
GroupManager* groupManager = GroupManager.Instance();
if (groupManager != null)
{
GroupManager.Group* group = groupManager->GetGroup();
if (group != null)
{
partySize = group->MemberCount;
}
}
if (partySize == 0)
{
partySize = 1;
}
if (partySize != CurrentPartySize)
{
CurrentPartySize = partySize;
log.Information($"[DungeonAutomation] Party Size updated: {CurrentPartySize}/{config.AutoDutyPartySize}");
}
}
catch (Exception ex)
{
log.Error("[DungeonAutomation] Error updating party size: " + ex.Message);
}
}
private void OnConditionChanged(ConditionFlag flag, bool value)
{
if (flag == ConditionFlag.BoundByDuty)
{
if (value && !isInDuty)
{
isInDuty = true;
OnDutyEntered();
}
else if (!value && isInDuty)
{
isInDuty = false;
OnDutyExited();
}
}
}
public void OnDutyEntered()
{
if ((DateTime.Now - lastDutyEntryTime).TotalSeconds < 5.0)
{
log.Debug("[DungeonAutomation] OnDutyEntered called too soon - ignoring spam");
return;
}
lastDutyEntryTime = DateTime.Now;
log.Information("[DungeonAutomation] Entered duty");
if (expectingDutyEntry)
{
log.Information("[DungeonAutomation] Duty started by DungeonAutomation - enabling automation commands");
expectingDutyEntry = false;
hasStoppedAD = false;
dutyEntryTime = DateTime.Now;
if (!hasSentAtY)
{
commandManager.ProcessCommand("/at y");
log.Information("[DungeonAutomation] Sent /at y (duty entered)");
hasSentAtY = true;
}
}
else
{
log.Information("[DungeonAutomation] Duty NOT started by DungeonAutomation (Solo Duty/Quest Battle) - skipping automation commands");
}
}
public void OnDutyExited()
{
if ((DateTime.Now - lastDutyExitTime).TotalSeconds < 2.0)
{
log.Debug("[DungeonAutomation] OnDutyExited called too soon - ignoring spam");
return;
}
lastDutyExitTime = DateTime.Now;
log.Information("[DungeonAutomation] Exited duty");
if (isAutomationActive)
{
commandManager.ProcessCommand("/at n");
log.Information("[DungeonAutomation] Sent /at n (duty exited)");
hasSentAtY = false;
log.Information("[DungeonAutomation] Waiting 8s, then disband + restart quest");
Task.Run(async delegate
{
await EnsureSoloPartyAsync();
});
StopDungeonAutomation();
}
else
{
log.Information("[DungeonAutomation] Exited non-automated duty - no cleanup needed");
}
}
private async Task EnsureSoloPartyAsync()
{
TimeSpan timeout = TimeSpan.FromSeconds(60L);
DateTime start = DateTime.Now;
while (CurrentPartySize > 1 && DateTime.Now - start < timeout)
{
await framework.RunOnFrameworkThread(delegate
{
commandManager.ProcessCommand("/leave");
});
log.Information("[DungeonAutomation] Forced /leave sent, rechecking party size...");
await Task.Delay(1500);
UpdatePartySize();
}
if (CurrentPartySize > 1)
{
log.Warning("[DungeonAutomation] Still not solo after leave spam!");
}
else
{
log.Information("[DungeonAutomation] Party reduced to solo after duty exit.");
}
}
public void DisbandParty()
{
try
{
log.Information("[DungeonAutomation] Disbanding party");
framework.RunOnFrameworkThread(delegate
{
memoryHelper.SendChatMessage("/leave");
log.Information("[DungeonAutomation] /leave command sent via UIModule");
});
}
catch (Exception ex)
{
log.Error("[DungeonAutomation] Failed to disband party: " + ex.Message);
}
}
public void Reset()
{
isWaitingForParty = false;
partyInviteTime = DateTime.MinValue;
inviteAttempts = 0;
CurrentPartySize = 1;
isInvitingHelpers = false;
helperInviteTime = DateTime.MinValue;
isAutomationActive = false;
log.Information("[DungeonAutomation] State reset");
}
public void Dispose()
{
Reset();
condition.ConditionChange -= OnConditionChanged;
}
}

View file

@ -0,0 +1,609 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Dalamud.Game.ClientState.Conditions;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game;
using Newtonsoft.Json.Linq;
namespace QuestionableCompanion.Services;
public class EventQuestExecutionService : IDisposable
{
private readonly AutoRetainerIPC autoRetainerIpc;
private readonly QuestionableIPC questionableIPC;
private readonly IPluginLog log;
private readonly IFramework framework;
private readonly ICommandManager commandManager;
private readonly ICondition condition;
private readonly Configuration configuration;
private readonly EventQuestResolver eventQuestResolver;
private EventQuestState currentState = new EventQuestState();
private Dictionary<string, List<string>> eventQuestCompletionByCharacter = new Dictionary<string, List<string>>();
private DateTime lastCheckTime = DateTime.MinValue;
private const double CheckIntervalMs = 250.0;
private bool isRotationActive;
private string? lastTerritoryWaitDetected;
private DateTime lastTerritoryTeleportTime = DateTime.MinValue;
private Action? onDataChanged;
public bool IsRotationActive => isRotationActive;
public EventQuestExecutionService(AutoRetainerIPC autoRetainerIpc, QuestionableIPC questionableIPC, IPluginLog log, IFramework framework, ICommandManager commandManager, ICondition condition, Configuration configuration, IDataManager dataManager, Action? onDataChanged = null)
{
this.autoRetainerIpc = autoRetainerIpc;
this.questionableIPC = questionableIPC;
this.log = log;
this.framework = framework;
this.commandManager = commandManager;
this.condition = condition;
this.configuration = configuration;
this.onDataChanged = onDataChanged;
eventQuestResolver = new EventQuestResolver(dataManager, log);
framework.Update += OnFrameworkUpdate;
log.Information("[EventQuest] Service initialized");
}
public bool StartEventQuestRotation(string eventQuestId, List<string> characters)
{
if (characters == null || characters.Count == 0)
{
log.Error("[EventQuest] Cannot start rotation: No characters selected");
return false;
}
if (string.IsNullOrEmpty(eventQuestId))
{
log.Error("[EventQuest] Cannot start rotation: Event Quest ID is empty");
return false;
}
List<string> dependencies = eventQuestResolver.ResolveEventQuestDependencies(eventQuestId);
List<string> remainingChars = new List<string>();
List<string> completedChars = new List<string>();
foreach (string character in characters)
{
if (HasCharacterCompletedEventQuest(eventQuestId, character))
{
completedChars.Add(character);
log.Debug("[EventQuest] " + character + " already completed event quest " + eventQuestId);
}
else
{
remainingChars.Add(character);
log.Debug("[EventQuest] " + character + " needs to complete event quest " + eventQuestId);
}
}
if (remainingChars.Count == 0)
{
log.Information("[EventQuest] All characters have already completed event quest " + eventQuestId);
return false;
}
string currentLoggedInChar = autoRetainerIpc.GetCurrentCharacter();
bool isAlreadyLoggedIn = !string.IsNullOrEmpty(currentLoggedInChar) && remainingChars.Contains(currentLoggedInChar);
currentState = new EventQuestState
{
EventQuestId = eventQuestId,
EventQuestName = eventQuestResolver.GetQuestName(eventQuestId),
SelectedCharacters = new List<string>(characters),
RemainingCharacters = remainingChars,
CompletedCharacters = completedChars,
DependencyQuests = dependencies,
Phase = ((!isAlreadyLoggedIn) ? EventQuestPhase.InitializingFirstCharacter : EventQuestPhase.CheckingQuestCompletion),
CurrentCharacter = (isAlreadyLoggedIn ? currentLoggedInChar : ""),
PhaseStartTime = DateTime.Now,
RotationStartTime = DateTime.Now
};
isRotationActive = true;
log.Information("[EventQuest] ═══ Starting Event Quest Rotation ═══");
log.Information($"[EventQuest] Event Quest: {currentState.EventQuestName} ({eventQuestId})");
log.Information($"[EventQuest] Total Characters: {characters.Count}");
log.Information($"[EventQuest] Remaining: {remainingChars.Count} | Completed: {completedChars.Count}");
log.Information($"[EventQuest] Dependencies to resolve: {dependencies.Count}");
if (dependencies.Count > 0)
{
log.Information("[EventQuest] Prerequisites: " + string.Join(", ", dependencies.Select((string id) => eventQuestResolver.GetQuestName(id))));
}
if (isAlreadyLoggedIn)
{
log.Information("[EventQuest] User already logged in as " + currentLoggedInChar + " - starting immediately");
}
return true;
}
public EventQuestState GetCurrentState()
{
return currentState;
}
public void LoadEventQuestCompletionData(Dictionary<string, List<string>> data)
{
if (data != null && data.Count > 0)
{
eventQuestCompletionByCharacter = new Dictionary<string, List<string>>(data);
log.Information($"[EventQuest] Loaded completion data for {data.Count} event quests");
}
}
public Dictionary<string, List<string>> GetEventQuestCompletionData()
{
return new Dictionary<string, List<string>>(eventQuestCompletionByCharacter);
}
public void AbortRotation()
{
log.Information("[EventQuest] Aborting Event Quest rotation");
currentState = new EventQuestState
{
Phase = EventQuestPhase.Idle
};
isRotationActive = false;
}
private void MarkEventQuestCompleted(string eventQuestId, string characterName)
{
if (!eventQuestCompletionByCharacter.ContainsKey(eventQuestId))
{
eventQuestCompletionByCharacter[eventQuestId] = new List<string>();
}
if (!eventQuestCompletionByCharacter[eventQuestId].Contains(characterName))
{
eventQuestCompletionByCharacter[eventQuestId].Add(characterName);
log.Debug("[EventQuest] Marked " + characterName + " as completed event quest " + eventQuestId);
onDataChanged?.Invoke();
}
}
private bool HasCharacterCompletedEventQuest(string eventQuestId, string characterName)
{
if (eventQuestCompletionByCharacter.TryGetValue(eventQuestId, out List<string> characters))
{
return characters.Contains(characterName);
}
return false;
}
private void OnFrameworkUpdate(IFramework framework)
{
if (!isRotationActive)
{
return;
}
DateTime now = DateTime.Now;
if (!((now - lastCheckTime).TotalMilliseconds < 250.0))
{
lastCheckTime = now;
CheckForTerritoryWait();
switch (currentState.Phase)
{
case EventQuestPhase.InitializingFirstCharacter:
HandleInitializingFirstCharacter();
break;
case EventQuestPhase.WaitingForCharacterLogin:
HandleWaitingForCharacterLogin();
break;
case EventQuestPhase.CheckingQuestCompletion:
HandleCheckingQuestCompletion();
break;
case EventQuestPhase.ResolvingDependencies:
HandleResolvingDependencies();
break;
case EventQuestPhase.ExecutingDependencies:
HandleExecutingDependencies();
break;
case EventQuestPhase.WaitingForQuestStart:
case EventQuestPhase.QuestActive:
HandleQuestMonitoring();
break;
case EventQuestPhase.WaitingBeforeCharacterSwitch:
HandleWaitingBeforeCharacterSwitch();
break;
case EventQuestPhase.Completed:
HandleCompleted();
break;
}
}
}
private void CheckForTerritoryWait()
{
if (!questionableIPC.IsRunning())
{
return;
}
object task = questionableIPC.GetCurrentTask();
if (task == null)
{
return;
}
try
{
if (!(task is JObject jObject))
{
return;
}
JToken taskNameToken = jObject["TaskName"];
if (taskNameToken == null)
{
return;
}
string taskName = taskNameToken.ToString();
if (string.IsNullOrEmpty(taskName))
{
return;
}
Match waitTerritoryMatch = new Regex("Wait\\(territory:\\s*(.+?)\\s*\\((\\d+)\\)\\)").Match(taskName);
if (!waitTerritoryMatch.Success)
{
return;
}
string territoryName = waitTerritoryMatch.Groups[1].Value.Trim();
uint territoryId = uint.Parse(waitTerritoryMatch.Groups[2].Value);
string territoryKey = $"{territoryName}_{territoryId}";
double timeSinceLastTeleport = (DateTime.Now - lastTerritoryTeleportTime).TotalSeconds;
if (lastTerritoryWaitDetected == territoryKey && timeSinceLastTeleport < 60.0)
{
return;
}
log.Information($"[EventQuest] Wait(territory) detected: {territoryName} (ID: {territoryId})");
log.Information("[EventQuest] Auto-teleporting via Lifestream...");
lastTerritoryWaitDetected = territoryKey;
lastTerritoryTeleportTime = DateTime.Now;
framework.RunOnFrameworkThread(delegate
{
try
{
string text = "/li " + territoryName;
commandManager.ProcessCommand(text);
log.Information("[EventQuest] Sent teleport command: " + text);
}
catch (Exception ex2)
{
log.Error("[EventQuest] Failed to teleport to " + territoryName + ": " + ex2.Message);
}
});
}
catch (Exception ex)
{
log.Error("[EventQuest] Error checking Wait(territory) task: " + ex.Message);
}
}
private void HandleInitializingFirstCharacter()
{
if (currentState.RemainingCharacters.Count == 0)
{
log.Information("[EventQuest] No remaining characters - rotation complete");
currentState.Phase = EventQuestPhase.Completed;
isRotationActive = false;
return;
}
string firstChar = currentState.RemainingCharacters[0];
currentState.CurrentCharacter = firstChar;
log.Information("[EventQuest] >>> Initializing first character: " + firstChar);
if (autoRetainerIpc.SwitchCharacter(firstChar))
{
currentState.Phase = EventQuestPhase.WaitingForCharacterLogin;
currentState.PhaseStartTime = DateTime.Now;
log.Information("[EventQuest] Character switch initiated to " + firstChar);
}
else
{
log.Error("[EventQuest] Failed to switch to " + firstChar);
currentState.Phase = EventQuestPhase.Error;
currentState.ErrorMessage = "Failed to switch to " + firstChar;
}
}
private void HandleWaitingForCharacterLogin()
{
if ((DateTime.Now - currentState.PhaseStartTime).TotalSeconds > 60.0)
{
log.Error("[EventQuest] Login timeout for " + currentState.CurrentCharacter);
SkipToNextCharacter();
return;
}
string currentLoggedInChar = autoRetainerIpc.GetCurrentCharacter();
if (!string.IsNullOrEmpty(currentLoggedInChar) && currentLoggedInChar == currentState.CurrentCharacter && !((DateTime.Now - currentState.PhaseStartTime).TotalSeconds < 5.0))
{
log.Information("[EventQuest] Successfully logged in as " + currentLoggedInChar);
currentState.Phase = EventQuestPhase.CheckingQuestCompletion;
currentState.PhaseStartTime = DateTime.Now;
}
}
private void HandleCheckingQuestCompletion()
{
string eventQuestId = currentState.EventQuestId;
string rawId = QuestIdParser.ParseQuestId(eventQuestId).rawId;
QuestIdType questType = QuestIdParser.ClassifyQuestId(eventQuestId);
log.Debug($"[EventQuest] Checking completion for {eventQuestId} (Type: {questType}, RawId: {rawId})");
if (!uint.TryParse(rawId, out var questIdUint))
{
log.Error($"[EventQuest] Invalid quest ID: {eventQuestId} (cannot parse numeric part: {rawId})");
SkipToNextCharacter();
return;
}
bool isQuestComplete = false;
try
{
isQuestComplete = QuestManager.IsQuestComplete(questIdUint);
}
catch (Exception ex)
{
log.Error("[EventQuest] Error checking quest completion: " + ex.Message);
}
if (isQuestComplete)
{
log.Information("[EventQuest] " + currentState.CurrentCharacter + " already completed event quest " + eventQuestId);
List<string> completedList = currentState.CompletedCharacters;
if (!completedList.Contains(currentState.CurrentCharacter))
{
completedList.Add(currentState.CurrentCharacter);
currentState.CompletedCharacters = completedList;
}
MarkEventQuestCompleted(eventQuestId, currentState.CurrentCharacter);
SkipToNextCharacter();
}
else
{
log.Information("[EventQuest] " + currentState.CurrentCharacter + " needs to complete event quest " + eventQuestId);
log.Information($"[EventQuest] >>> Starting event quest with {currentState.DependencyQuests.Count} prerequisites");
StartEventQuest();
}
}
private void HandleResolvingDependencies()
{
log.Information("[EventQuest] All prerequisites completed - starting event quest");
StartEventQuest();
}
private void HandleExecutingDependencies()
{
string depQuestId = currentState.CurrentExecutingQuest;
if (!uint.TryParse(depQuestId, out var questIdUint))
{
log.Error("[EventQuest] Invalid dependency quest ID: " + depQuestId);
currentState.DependencyIndex++;
currentState.Phase = EventQuestPhase.ResolvingDependencies;
return;
}
bool isDependencyComplete = false;
try
{
isDependencyComplete = QuestManager.IsQuestComplete(questIdUint);
}
catch
{
}
if (isDependencyComplete)
{
log.Information("[EventQuest] Dependency " + eventQuestResolver.GetQuestName(depQuestId) + " already completed");
currentState.DependencyIndex++;
currentState.Phase = EventQuestPhase.ResolvingDependencies;
return;
}
try
{
commandManager.ProcessCommand("/qst start");
log.Information("[EventQuest] Started dependency quest: " + eventQuestResolver.GetQuestName(depQuestId));
}
catch (Exception ex)
{
log.Error("[EventQuest] Failed to start dependency: " + ex.Message);
}
currentState.Phase = EventQuestPhase.QuestActive;
currentState.HasEventQuestBeenAccepted = false;
currentState.PhaseStartTime = DateTime.Now;
}
private void HandleQuestMonitoring()
{
string eventQuestId = currentState.EventQuestId;
try
{
if (questionableIPC.IsQuestComplete(eventQuestId))
{
log.Information("[EventQuest] Event quest " + eventQuestId + " completed by " + currentState.CurrentCharacter);
MarkEventQuestCompleted(currentState.EventQuestId, currentState.CurrentCharacter);
List<string> completedList = currentState.CompletedCharacters;
if (!completedList.Contains(currentState.CurrentCharacter))
{
completedList.Add(currentState.CurrentCharacter);
currentState.CompletedCharacters = completedList;
}
try
{
commandManager.ProcessCommand("/qst stop");
log.Information("[EventQuest] Sent /qst stop");
}
catch
{
}
currentState.Phase = EventQuestPhase.WaitingBeforeCharacterSwitch;
currentState.PhaseStartTime = DateTime.Now;
}
}
catch (Exception ex)
{
log.Error("[EventQuest] Error checking quest completion via IPC: " + ex.Message);
}
}
private void HandleWaitingBeforeCharacterSwitch()
{
if (!condition[ConditionFlag.BetweenAreas] && (DateTime.Now - currentState.PhaseStartTime).TotalSeconds >= 2.0)
{
PerformCharacterSwitch();
}
}
private void HandleCompleted()
{
log.Information("[EventQuest] ═══ EVENT QUEST ROTATION COMPLETED ═══");
log.Information($"[EventQuest] All {currentState.CompletedCharacters.Count} characters completed the event quest");
if (questionableIPC.IsAvailable)
{
try
{
questionableIPC.ClearQuestPriority();
log.Information("[EventQuest] Cleared quest priority queue after completion");
}
catch (Exception ex)
{
log.Warning("[EventQuest] Failed to clear quest priority: " + ex.Message);
}
}
isRotationActive = false;
currentState.Phase = EventQuestPhase.Idle;
}
private void StartEventQuest()
{
List<string> allQuests = new List<string>();
if (currentState.DependencyQuests.Count > 0)
{
foreach (string dep in currentState.DependencyQuests)
{
allQuests.Add(dep);
QuestIdType questType = QuestIdParser.ClassifyQuestId(dep);
log.Information($"[EventQuest] Adding dependency: {dep} (Type: {questType})");
}
}
string mainQuestId = currentState.EventQuestId;
allQuests.Add(mainQuestId);
QuestIdType mainQuestType = QuestIdParser.ClassifyQuestId(mainQuestId);
log.Information($"[EventQuest] Adding main event quest: {mainQuestId} (Type: {mainQuestType})");
log.Information($"[EventQuest] Setting {allQuests.Count} quests as Questionable priority");
if (questionableIPC.IsAvailable)
{
try
{
questionableIPC.ClearQuestPriority();
log.Information("[EventQuest] Cleared existing quest priority queue");
}
catch (Exception ex)
{
log.Warning("[EventQuest] Failed to clear quest priority: " + ex.Message);
}
foreach (string questId in allQuests)
{
try
{
bool result = questionableIPC.AddQuestPriority(questId);
log.Information($"[EventQuest] Added quest {questId} to priority: {result}");
}
catch (Exception ex2)
{
log.Warning("[EventQuest] Failed to add quest " + questId + " to priority: " + ex2.Message);
}
}
}
else
{
log.Warning("[EventQuest] Questionable IPC not available - cannot set priority");
}
if (condition[ConditionFlag.BetweenAreas])
{
log.Debug("[EventQuest] Character is between areas - waiting before starting quest");
return;
}
if (questionableIPC.IsAvailable && questionableIPC.IsRunning())
{
log.Debug("[EventQuest] Questionable is busy - waiting before starting quest");
return;
}
try
{
commandManager.ProcessCommand("/qst start");
log.Information("[EventQuest] Sent /qst start for event quest");
currentState.Phase = EventQuestPhase.QuestActive;
currentState.CurrentExecutingQuest = currentState.EventQuestId;
currentState.HasEventQuestBeenAccepted = false;
currentState.PhaseStartTime = DateTime.Now;
}
catch (Exception ex3)
{
log.Error("[EventQuest] Failed to start quest: " + ex3.Message);
}
}
private void SkipToNextCharacter()
{
try
{
commandManager.ProcessCommand("/qst stop");
log.Information("[EventQuest] Sent /qst stop before character switch");
}
catch
{
}
List<string> remainingList = currentState.RemainingCharacters;
List<string> completedList = currentState.CompletedCharacters;
if (remainingList.Contains(currentState.CurrentCharacter))
{
remainingList.Remove(currentState.CurrentCharacter);
currentState.RemainingCharacters = remainingList;
}
if (!completedList.Contains(currentState.CurrentCharacter))
{
completedList.Add(currentState.CurrentCharacter);
currentState.CompletedCharacters = completedList;
log.Information("[EventQuest] Character " + currentState.CurrentCharacter + " marked as completed (skipped)");
}
currentState.Phase = EventQuestPhase.WaitingBeforeCharacterSwitch;
currentState.PhaseStartTime = DateTime.Now;
}
private void PerformCharacterSwitch()
{
List<string> remainingList = currentState.RemainingCharacters;
if (remainingList.Contains(currentState.CurrentCharacter))
{
remainingList.Remove(currentState.CurrentCharacter);
currentState.RemainingCharacters = remainingList;
}
if (currentState.RemainingCharacters.Count == 0)
{
currentState.Phase = EventQuestPhase.Completed;
return;
}
string nextChar = currentState.RemainingCharacters[0];
currentState.CurrentCharacter = nextChar;
currentState.NextCharacter = nextChar;
log.Information("[EventQuest] Switching to next character: " + nextChar);
log.Information($"[EventQuest] Progress: {currentState.CompletedCharacters.Count}/{currentState.SelectedCharacters.Count} completed");
if (autoRetainerIpc.SwitchCharacter(nextChar))
{
currentState.Phase = EventQuestPhase.WaitingForCharacterLogin;
currentState.PhaseStartTime = DateTime.Now;
}
else
{
log.Error("[EventQuest] Failed to switch to " + nextChar);
currentState.Phase = EventQuestPhase.Error;
currentState.ErrorMessage = "Failed to switch character";
}
}
public void Dispose()
{
framework.Update -= OnFrameworkUpdate;
log.Information("[EventQuest] Service disposed");
}
}

View file

@ -0,0 +1,16 @@
namespace QuestionableCompanion.Services;
public enum EventQuestPhase
{
Idle,
InitializingFirstCharacter,
WaitingForCharacterLogin,
CheckingQuestCompletion,
ResolvingDependencies,
ExecutingDependencies,
WaitingForQuestStart,
QuestActive,
WaitingBeforeCharacterSwitch,
Completed,
Error
}

View file

@ -0,0 +1,230 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Dalamud.Plugin.Services;
using Lumina.Excel;
using Lumina.Excel.Sheets;
namespace QuestionableCompanion.Services;
public class EventQuestResolver
{
private readonly IDataManager dataManager;
private readonly IPluginLog log;
public EventQuestResolver(IDataManager dataManager, IPluginLog log)
{
this.dataManager = dataManager;
this.log = log;
}
public List<string> ResolveEventQuestDependencies(string eventQuestId)
{
List<string> dependencies = new List<string>();
ExcelSheet<Quest> questSheet = dataManager.GetExcelSheet<Quest>();
log.Information("[EventQuestResolver] Searching for quest with ID string: '" + eventQuestId + "'");
Quest? foundQuest = null;
foreach (Quest q in questSheet)
{
if (q.RowId != 0)
{
string questIdField = q.Id.ExtractText();
if (questIdField == eventQuestId || questIdField.EndsWith("_" + eventQuestId) || questIdField.EndsWith("_" + eventQuestId.PadLeft(5, '0')))
{
foundQuest = q;
log.Information($"[EventQuestResolver] Found quest by ID field: '{questIdField}' (searched for '{eventQuestId}')");
break;
}
}
}
if (!foundQuest.HasValue || foundQuest.Value.RowId == 0)
{
log.Error("[EventQuestResolver] Quest with ID '" + eventQuestId + "' not found in Lumina");
return dependencies;
}
Quest quest = foundQuest.Value;
string questName = quest.Name.ExtractText();
log.Information($"[EventQuestResolver] Found quest: RowId={quest.RowId}, Name='{questName}', ID='{quest.Id.ExtractText()}'");
try
{
foreach (RowRef<Quest> prevQuestRef in quest.PreviousQuest)
{
if (prevQuestRef.RowId == 0)
{
continue;
}
Quest prevQuest = questSheet.GetRow(prevQuestRef.RowId);
if (prevQuest.RowId != 0)
{
string prevQuestName = prevQuest.Name.ExtractText();
string prevQuestIdString = prevQuest.Id.ExtractText();
string[] idParts = prevQuestIdString.Split('_');
string questIdNumber = ((idParts.Length > 1) ? idParts[1].TrimStart('0') : prevQuestIdString);
if (string.IsNullOrEmpty(questIdNumber))
{
questIdNumber = "0";
}
dependencies.Add(questIdNumber);
log.Information($"[EventQuestResolver] Found previous quest: RowId={prevQuestRef.RowId}, Name='{prevQuestName}', ID='{prevQuestIdString}' -> '{questIdNumber}'");
}
}
}
catch (Exception ex)
{
log.Warning("[EventQuestResolver] Error reading PreviousQuest: " + ex.Message);
}
try
{
foreach (RowRef<Quest> questLockRef in quest.QuestLock)
{
if (questLockRef.RowId == 0)
{
continue;
}
Quest lockQuest = questSheet.GetRow(questLockRef.RowId);
if (lockQuest.RowId != 0)
{
string lockQuestIdString = lockQuest.Id.ExtractText();
string[] idParts2 = lockQuestIdString.Split('_');
string questIdNumber2 = ((idParts2.Length > 1) ? idParts2[1].TrimStart('0') : lockQuestIdString);
if (string.IsNullOrEmpty(questIdNumber2))
{
questIdNumber2 = "0";
}
dependencies.Add(questIdNumber2);
log.Information($"[EventQuestResolver] Found quest lock: RowId={questLockRef.RowId}, ID='{lockQuestIdString}' -> '{questIdNumber2}'");
}
}
}
catch (Exception ex2)
{
log.Warning("[EventQuestResolver] Error reading QuestLock: " + ex2.Message);
}
dependencies = dependencies.Distinct().ToList();
log.Information($"[EventQuestResolver] Found {dependencies.Count} direct prerequisites");
if (dependencies.Count > 0)
{
log.Information("[EventQuestResolver] Event Quest " + eventQuestId + " requires: " + string.Join(", ", dependencies));
}
else
{
log.Information("[EventQuestResolver] Event Quest " + eventQuestId + " has no prerequisites");
}
return dependencies;
}
public bool IsValidQuest(string questId, out string classification)
{
string rawId = QuestIdParser.ParseQuestId(questId).rawId;
if (QuestIdParser.ClassifyQuestId(questId) == QuestIdType.EventQuest)
{
classification = "EventQuest";
log.Debug("[EventQuestResolver] Quest " + questId + " recognized as Event Quest (prefix detected)");
return true;
}
if (!uint.TryParse(rawId, out var questIdUint))
{
classification = "Invalid";
return false;
}
try
{
Quest quest = dataManager.GetExcelSheet<Quest>().GetRow(questIdUint);
if (quest.RowId != 0)
{
classification = "Standard";
log.Debug($"[EventQuestResolver] Quest {questId} found in Excel Sheet (RowId: {quest.RowId})");
return true;
}
classification = "NotFound";
return false;
}
catch (Exception ex)
{
log.Debug("[EventQuestResolver] Error checking quest availability: " + ex.Message);
classification = "Error";
return false;
}
}
public bool IsQuestAvailable(string questId)
{
string classification;
return IsValidQuest(questId, out classification);
}
public string GetQuestName(string questId)
{
string rawId = QuestIdParser.ParseQuestId(questId).rawId;
QuestIdType questType = QuestIdParser.ClassifyQuestId(questId);
if (!uint.TryParse(rawId, out var questIdUint))
{
if (questType == QuestIdType.EventQuest)
{
return "Event Quest " + questId;
}
return "Unknown Quest (" + questId + ")";
}
try
{
Quest quest = dataManager.GetExcelSheet<Quest>().GetRow(questIdUint);
if (quest.RowId != 0)
{
string name = quest.Name.ExtractText();
if (!string.IsNullOrEmpty(name))
{
if (questType == QuestIdType.EventQuest)
{
return name + " (" + questId + ")";
}
return name;
}
}
if (questType == QuestIdType.EventQuest)
{
return "Event Quest " + questId;
}
return "Quest " + questId;
}
catch (Exception)
{
if (questType == QuestIdType.EventQuest)
{
return "Event Quest " + questId;
}
return "Quest " + questId;
}
}
public List<(string QuestId, string QuestName)> GetAvailableEventQuests()
{
List<(string, string)> eventQuests = new List<(string, string)>();
try
{
ExcelSheet<Quest> questSheet = dataManager.GetExcelSheet<Quest>();
if (questSheet == null)
{
log.Error("[EventQuestResolver] Failed to load Quest sheet");
return eventQuests;
}
foreach (Quest quest in questSheet)
{
if (quest.RowId != 0 && quest.JournalGenre.RowId == 9)
{
string questName = quest.Name.ExtractText();
if (!string.IsNullOrEmpty(questName))
{
eventQuests.Add((quest.RowId.ToString(), questName));
}
}
}
log.Information($"[EventQuestResolver] Found {eventQuests.Count} event quests");
}
catch (Exception ex)
{
log.Error("[EventQuestResolver] Error getting event quests: " + ex.Message);
}
return eventQuests.OrderBy(((string, string) q) => q.Item2).ToList();
}
}

View file

@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
namespace QuestionableCompanion.Services;
public class EventQuestState
{
public EventQuestPhase Phase { get; set; }
public string EventQuestId { get; set; } = string.Empty;
public string EventQuestName { get; set; } = string.Empty;
public List<string> SelectedCharacters { get; set; } = new List<string>();
public List<string> RemainingCharacters { get; set; } = new List<string>();
public List<string> CompletedCharacters { get; set; } = new List<string>();
public string CurrentCharacter { get; set; } = string.Empty;
public string NextCharacter { get; set; } = string.Empty;
public List<string> DependencyQuests { get; set; } = new List<string>();
public string CurrentExecutingQuest { get; set; } = string.Empty;
public int DependencyIndex { get; set; }
public DateTime PhaseStartTime { get; set; } = DateTime.Now;
public DateTime RotationStartTime { get; set; } = DateTime.Now;
public string ErrorMessage { get; set; } = string.Empty;
public bool HasEventQuestBeenAccepted { get; set; }
}

View file

@ -0,0 +1,655 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Dalamud.Game.NativeWrapper;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.UI;
using QuestionableCompanion.Models;
namespace QuestionableCompanion.Services;
public class ExecutionService : IDisposable
{
private readonly IPluginLog log;
private readonly Configuration config;
private readonly QuestionableIPC questionableIPC;
private readonly AutoRetainerIPC autoRetainerIPC;
private readonly QuestDetectionService questDetection;
private readonly IClientState clientState;
private readonly IGameGui gameGui;
private readonly IFramework framework;
private CharacterSafeWaitService? safeWaitService;
private QuestPreCheckService? preCheckService;
private DCTravelService? dcTravelService;
private int currentCharacterIndex;
private readonly HashSet<string> completedCharacters = new HashSet<string>();
private readonly HashSet<string> failedCharacters = new HashSet<string>();
private bool waitingForRelog;
private string targetRelogCharacter = string.Empty;
private DateTime relogStartTime = DateTime.MinValue;
private bool wasLoggedOutDuringRelog;
public ExecutionState CurrentState { get; private set; } = new ExecutionState();
public bool IsRunning { get; private set; }
public bool IsPaused { get; private set; }
public List<LogEntry> Logs { get; } = new List<LogEntry>();
public event Action<LogEntry>? LogAdded;
public event Action<ExecutionState>? StateChanged;
public ExecutionService(IPluginLog log, Configuration config, QuestionableIPC questionableIPC, AutoRetainerIPC autoRetainerIPC, QuestDetectionService questDetection, IClientState clientState, IGameGui gameGui, IFramework framework)
{
this.log = log;
this.config = config;
this.questionableIPC = questionableIPC;
this.autoRetainerIPC = autoRetainerIPC;
this.questDetection = questDetection;
this.clientState = clientState;
this.gameGui = gameGui;
this.framework = framework;
questDetection.QuestAccepted += OnQuestAccepted;
questDetection.QuestCompleted += OnQuestCompleted;
framework.Update += OnFrameworkUpdate;
AddLog(LogLevel.Info, "Execution service initialized");
}
private void OnFrameworkUpdate(IFramework framework)
{
if (!waitingForRelog)
{
return;
}
try
{
if ((DateTime.Now - relogStartTime).TotalSeconds > 60.0)
{
AddLog(LogLevel.Warning, "Relog timeout - moving to next character");
waitingForRelog = false;
failedCharacters.Add(targetRelogCharacter);
SwitchToNextCharacter();
}
else if (!clientState.IsLoggedIn)
{
if (!wasLoggedOutDuringRelog)
{
AddLog(LogLevel.Info, "[ExecutionService] Character logged out, waiting for relog...");
wasLoggedOutDuringRelog = true;
}
}
else
{
if (!clientState.IsLoggedIn || clientState.LocalPlayer == null || !IsNamePlateReady())
{
return;
}
string currentChar = autoRetainerIPC.GetCurrentCharacter();
if (string.IsNullOrEmpty(currentChar) || !(currentChar == targetRelogCharacter))
{
return;
}
AddLog(LogLevel.Success, "[ExecutionService] Relog confirmed: " + currentChar);
AddLog(LogLevel.Info, "[ExecutionService] NamePlate ready, character fully loaded");
if (config.EnableSafeWaitAfterCharacterSwitch && safeWaitService != null)
{
AddLog(LogLevel.Info, "[SafeWait] Stabilizing after character switch...");
safeWaitService.PerformQuickSafeWait();
AddLog(LogLevel.Info, "[SafeWait] Post-switch stabilization complete");
}
if (config.EnableQuestPreCheck && preCheckService != null)
{
AddLog(LogLevel.Info, "[PreCheck] Scanning quest status for current character...");
preCheckService.ScanCurrentCharacterQuestStatus();
}
waitingForRelog = false;
CurrentState.CurrentCharacter = currentChar;
questDetection.ResetTracking();
AddLog(LogLevel.Info, "[ExecutionService] Refreshing quest cache...");
questDetection.RefreshQuestCache();
NotifyStateChanged();
if (dcTravelService != null && dcTravelService.ShouldPerformDCTravel())
{
AddLog(LogLevel.Warning, "[DCTravel] DC Travel required for this character!");
AddLog(LogLevel.Info, "[DCTravel] Initiating DC travel before quest execution...");
Task.Run(async delegate
{
try
{
if (await dcTravelService.PerformDCTravel())
{
AddLog(LogLevel.Success, "[DCTravel] DC travel completed - starting quests");
ExecuteQuestsForCurrentCharacter();
}
else
{
AddLog(LogLevel.Error, "[DCTravel] DC travel failed - switching character");
SwitchToNextCharacter();
}
}
catch (Exception ex2)
{
AddLog(LogLevel.Error, "[DCTravel] Error: " + ex2.Message);
SwitchToNextCharacter();
}
});
}
else
{
ExecuteQuestsForCurrentCharacter();
}
}
}
catch (Exception ex)
{
log.Error("[ExecutionService] Error in relog monitoring: " + ex.Message);
}
}
public bool Start()
{
QuestProfile profile = config.GetActiveProfile();
if (profile == null)
{
AddLog(LogLevel.Error, "No active profile selected");
return false;
}
if (profile.Characters.Count == 0)
{
AddLog(LogLevel.Error, "No characters configured in profile");
return false;
}
IsRunning = true;
IsPaused = false;
CurrentState.ActiveProfile = profile.Name;
CurrentState.Status = ExecutionStatus.Running;
AddLog(LogLevel.Success, "Started profile: " + profile.Name);
AddLog(LogLevel.Info, $"Characters in rotation: {profile.Characters.Count}");
currentCharacterIndex = 0;
SwitchToCharacter(profile.Characters[0]);
NotifyStateChanged();
return true;
}
public void Stop()
{
IsRunning = false;
IsPaused = false;
waitingForRelog = false;
if (questionableIPC.IsRunning())
{
questionableIPC.Stop();
}
CurrentState.Status = ExecutionStatus.Idle;
CurrentState.CurrentQuestId = 0u;
CurrentState.CurrentQuestName = string.Empty;
CurrentState.CurrentSequence = string.Empty;
AddLog(LogLevel.Info, "Execution stopped");
NotifyStateChanged();
}
public void Pause()
{
IsPaused = true;
questionableIPC.Stop();
CurrentState.Status = ExecutionStatus.Waiting;
AddLog(LogLevel.Warning, "Execution paused");
NotifyStateChanged();
}
public void Resume()
{
IsPaused = false;
CurrentState.Status = ExecutionStatus.Running;
AddLog(LogLevel.Info, "Execution resumed");
NotifyStateChanged();
}
private void OnQuestAccepted(uint questId, string questName)
{
if (!IsRunning || IsPaused)
{
AddLog(LogLevel.Debug, $"Quest {questId} accepted but execution not running");
return;
}
QuestProfile profile = config.GetActiveProfile();
if (profile == null)
{
AddLog(LogLevel.Warning, "No active profile found");
return;
}
QuestConfig questConfig = profile.Quests.FirstOrDefault((QuestConfig q) => q.QuestId == questId && q.TriggerType == TriggerType.OnAccept);
if (questConfig != null)
{
AddLog(LogLevel.Success, $"Quest accepted trigger matched: {questName} (ID: {questId})");
ExecuteSequence(questConfig);
}
else
{
AddLog(LogLevel.Info, $"Quest {questId} ({questName}) accepted but no OnAccept trigger configured");
AddLog(LogLevel.Info, "Add this quest to your profile with TriggerType=OnAccept to auto-execute");
}
}
private void OnQuestCompleted(uint questId, string questName)
{
if (!IsRunning || IsPaused)
{
return;
}
QuestProfile profile = config.GetActiveProfile();
if (profile != null)
{
QuestConfig questConfig = profile.Quests.FirstOrDefault((QuestConfig q) => q.QuestId == questId && q.TriggerType == TriggerType.OnComplete);
if (questConfig != null)
{
AddLog(LogLevel.Success, "Quest completed trigger matched: " + questName);
ExecuteSequence(questConfig);
}
}
}
private async void ExecuteSequence(QuestConfig questConfig)
{
CurrentState.CurrentQuestId = questConfig.QuestId;
CurrentState.CurrentQuestName = questConfig.QuestName;
CurrentState.CurrentSequence = questConfig.SequenceAfterQuest.Value;
CurrentState.Status = ExecutionStatus.Running;
NotifyStateChanged();
if (config.EnableDryRun)
{
AddLog(LogLevel.Debug, "[DRY RUN] Would execute sequence: " + questConfig.SequenceAfterQuest.Value);
await Task.Delay(2000);
OnSequenceComplete(questConfig);
return;
}
switch (questConfig.SequenceAfterQuest.Type)
{
case SequenceType.QuestionableProfile:
await ExecuteQuestionableProfile(questConfig);
break;
case SequenceType.InternalAction:
await ExecuteInternalAction(questConfig);
break;
}
}
private async Task ExecuteQuestionableProfile(QuestConfig questConfig)
{
AddLog(LogLevel.Info, $"Monitoring Quest {questConfig.QuestId} for completion...");
AddLog(LogLevel.Info, "Questionable will handle quest progression automatically");
CurrentState.CurrentSequence = $"Monitoring Quest {questConfig.QuestId}";
NotifyStateChanged();
await WaitForQuestCompletion(questConfig.QuestId);
OnSequenceComplete(questConfig);
}
private async Task WaitForQuestAcceptanceThenNext(QuestConfig questConfig)
{
int maxWaitSeconds = 3600;
int waited = 0;
AddLog(LogLevel.Info, $"Waiting for quest {questConfig.QuestId} to be accepted...");
while (waited < maxWaitSeconds && IsRunning)
{
await Task.Delay(5000);
waited += 5;
if (questDetection.IsQuestAccepted(questConfig.QuestId))
{
AddLog(LogLevel.Success, $"Quest {questConfig.QuestId} accepted!");
OnSequenceComplete(questConfig);
return;
}
if (questDetection.IsQuestCompletedDirect(questConfig.QuestId))
{
AddLog(LogLevel.Success, $"Quest {questConfig.QuestId} already completed!");
OnSequenceComplete(questConfig);
return;
}
if (waited % 60 == 0)
{
AddLog(LogLevel.Debug, $"Still waiting for quest {questConfig.QuestId} acceptance... ({waited}s)");
}
}
if (!IsRunning)
{
AddLog(LogLevel.Info, "Monitoring stopped - execution not running");
return;
}
AddLog(LogLevel.Warning, $"Quest {questConfig.QuestId} acceptance timeout after {maxWaitSeconds}s");
OnSequenceFailed(questConfig);
}
private async Task WaitForQuestCompletion(uint questId)
{
int maxWaitSeconds = 600;
int waited = 0;
while (waited < maxWaitSeconds && IsRunning)
{
await Task.Delay(5000);
waited += 5;
if (questDetection.IsQuestCompletedDirect(questId))
{
AddLog(LogLevel.Success, $"Quest {questId} completed!");
return;
}
if (waited % 30 == 0)
{
AddLog(LogLevel.Debug, $"Still waiting for quest {questId}... ({waited}s)");
}
}
if (!IsRunning)
{
AddLog(LogLevel.Info, "Monitoring stopped - execution not running");
return;
}
AddLog(LogLevel.Warning, $"Quest {questId} completion timeout after {maxWaitSeconds}s");
}
private async Task WaitForQuestionableCompletion()
{
AddLog(LogLevel.Warning, "Waiting for Questionable to complete...");
while (questionableIPC.IsRunning())
{
await Task.Delay(1000);
}
AddLog(LogLevel.Success, "Questionable completed");
}
private async Task ExecuteInternalAction(QuestConfig questConfig)
{
AddLog(LogLevel.Warning, "Internal action not yet implemented: " + questConfig.SequenceAfterQuest.Value);
await Task.Delay(1000);
OnSequenceComplete(questConfig);
}
private void OnSequenceComplete(QuestConfig questConfig)
{
AddLog(LogLevel.Success, "Sequence completed: " + questConfig.SequenceAfterQuest.Value);
CurrentState.Status = ExecutionStatus.Complete;
CurrentState.Progress = 100;
NotifyStateChanged();
if (questConfig.NextCharacter == "auto_next")
{
SwitchToNextCharacter();
}
else if (!string.IsNullOrEmpty(questConfig.NextCharacter))
{
SwitchToCharacter(questConfig.NextCharacter);
}
}
private void OnSequenceFailed(QuestConfig questConfig)
{
AddLog(LogLevel.Error, "Sequence failed: " + questConfig.SequenceAfterQuest.Value);
CurrentState.Status = ExecutionStatus.Failed;
NotifyStateChanged();
if (!string.IsNullOrEmpty(CurrentState.CurrentCharacter))
{
failedCharacters.Add(CurrentState.CurrentCharacter);
}
SwitchToNextCharacter();
}
private async Task SwitchToCharacter(string characterName)
{
if (config.EnableDryRun)
{
AddLog(LogLevel.Debug, "[DRY RUN] Would switch to character: " + characterName);
CurrentState.CurrentCharacter = characterName;
NotifyStateChanged();
return;
}
AddLog(LogLevel.Info, "Switching to character: " + characterName);
if (questionableIPC.IsRunning())
{
questionableIPC.Stop();
}
string originalChar = autoRetainerIPC.GetCurrentCharacter();
AddLog(LogLevel.Debug, "Current character before switch: " + (originalChar ?? "null"));
if (!string.IsNullOrEmpty(originalChar) && originalChar == characterName)
{
AddLog(LogLevel.Info, "Already on character: " + characterName);
AddLog(LogLevel.Info, "Refreshing quest cache...");
questDetection.RefreshQuestCache();
CurrentState.CurrentCharacter = characterName;
NotifyStateChanged();
await Task.Delay(2000);
ExecuteQuestsForCurrentCharacter();
return;
}
if (string.IsNullOrEmpty(originalChar))
{
AddLog(LogLevel.Warning, "Could not get current character - might be in transition");
}
AddLog(LogLevel.Info, "[AutoRetainerIPC] Relog request sent for: " + characterName);
if (autoRetainerIPC.SwitchCharacter(characterName))
{
waitingForRelog = true;
targetRelogCharacter = characterName;
relogStartTime = DateTime.Now;
wasLoggedOutDuringRelog = false;
AddLog(LogLevel.Info, "Relog command sent, monitoring via Framework.Update...");
}
else
{
AddLog(LogLevel.Error, "Failed to send relog request for character: " + characterName);
failedCharacters.Add(characterName);
SwitchToNextCharacter();
}
}
private void ExecuteQuestsForCurrentCharacter()
{
AddLog(LogLevel.Info, "=== ExecuteQuestsForCurrentCharacter called ===");
QuestProfile profile = config.GetActiveProfile();
if (profile == null)
{
AddLog(LogLevel.Error, "No active profile");
SwitchToNextCharacter();
return;
}
string currentChar = CurrentState.CurrentCharacter;
if (string.IsNullOrEmpty(currentChar))
{
AddLog(LogLevel.Warning, "No current character set");
SwitchToNextCharacter();
return;
}
AddLog(LogLevel.Info, "Current character: " + currentChar);
AddLog(LogLevel.Info, $"Total quests in profile: {profile.Quests.Count}");
List<QuestConfig> characterQuests = (from q in profile.Quests
where string.IsNullOrEmpty(q.AssignedCharacter) || q.AssignedCharacter == currentChar
orderby q.QuestId
select q).ToList();
if (characterQuests.Count == 0)
{
AddLog(LogLevel.Warning, "No quests configured for " + currentChar);
AddLog(LogLevel.Info, "Please add quests to your profile using /qstcomp");
completedCharacters.Add(currentChar);
SwitchToNextCharacter();
return;
}
AddLog(LogLevel.Info, $"Found {characterQuests.Count} quests for {currentChar}");
foreach (QuestConfig quest in characterQuests)
{
AddLog(LogLevel.Debug, $"Checking quest {quest.QuestId}: {quest.QuestName}");
bool isCompleted = questDetection.IsQuestCompletedDirect(quest.QuestId);
AddLog(LogLevel.Debug, $"Quest {quest.QuestId} completed status: {isCompleted}");
if (!isCompleted)
{
AddLog(LogLevel.Success, $"Starting quest {quest.QuestId}: {quest.QuestName}");
CurrentState.CurrentQuestId = quest.QuestId;
CurrentState.CurrentQuestName = quest.QuestName;
CurrentState.CurrentSequence = $"Quest {quest.QuestId}";
NotifyStateChanged();
if (quest.TriggerType == TriggerType.OnAccept)
{
AddLog(LogLevel.Info, $"Quest {quest.QuestId} has OnAccept trigger - waiting for acceptance");
WaitForQuestAcceptanceThenNext(quest);
}
else
{
AddLog(LogLevel.Info, $"Quest {quest.QuestId} has OnComplete trigger - monitoring for completion");
ExecuteSequence(quest);
}
return;
}
AddLog(LogLevel.Info, $"Quest {quest.QuestId} already completed, skipping");
}
AddLog(LogLevel.Success, "All quests completed for " + currentChar + "!");
completedCharacters.Add(currentChar);
SwitchToNextCharacter();
}
public void SetSafeWaitService(CharacterSafeWaitService service)
{
safeWaitService = service;
}
public void SetPreCheckService(QuestPreCheckService service)
{
preCheckService = service;
}
public void SetDCTravelService(DCTravelService service)
{
dcTravelService = service;
}
private void SwitchToNextCharacter()
{
QuestProfile profile = config.GetActiveProfile();
if (profile == null || profile.Characters.Count == 0)
{
AddLog(LogLevel.Error, "No profile or characters available");
Stop();
return;
}
if (config.EnableQuestPreCheck && preCheckService != null)
{
AddLog(LogLevel.Info, "Logging completed quests before logout...");
preCheckService.LogCompletedQuestsBeforeLogout();
}
if (dcTravelService != null && dcTravelService.IsDCTravelCompleted())
{
AddLog(LogLevel.Info, "[DCTravel] Returning to homeworld before character switch...");
dcTravelService.ReturnToHomeworld();
Thread.Sleep(2000);
AddLog(LogLevel.Info, "[DCTravel] Returned to homeworld");
}
if (config.EnableSafeWaitBeforeCharacterSwitch && safeWaitService != null)
{
AddLog(LogLevel.Info, "[SafeWait] Stabilizing character before switch...");
safeWaitService.PerformSafeWait();
AddLog(LogLevel.Info, "[SafeWait] Stabilization complete");
}
if (!string.IsNullOrEmpty(CurrentState.CurrentCharacter))
{
completedCharacters.Add(CurrentState.CurrentCharacter);
}
int attempts = 0;
while (attempts < profile.Characters.Count)
{
currentCharacterIndex = (currentCharacterIndex + 1) % profile.Characters.Count;
string nextChar = profile.Characters[currentCharacterIndex];
if (config.EnableQuestPreCheck && preCheckService != null)
{
List<StopPoint> stopPoints = config.StopPoints;
if (stopPoints != null && stopPoints.Count > 0)
{
uint firstStopQuest = stopPoints[0].QuestId;
if (preCheckService.ShouldSkipCharacter(nextChar, firstStopQuest))
{
AddLog(LogLevel.Info, "[PreCheck] Skipping " + nextChar + " - quest already completed");
completedCharacters.Add(nextChar);
attempts++;
continue;
}
}
}
if (!completedCharacters.Contains(nextChar) && !failedCharacters.Contains(nextChar))
{
SwitchToCharacter(nextChar);
return;
}
attempts++;
}
AddLog(LogLevel.Success, "All characters completed!");
Stop();
}
private void AddLog(LogLevel level, string message)
{
LogEntry entry = new LogEntry
{
Level = level,
Message = message,
Timestamp = DateTime.Now
};
Logs.Add(entry);
while (Logs.Count > config.MaxLogEntries)
{
Logs.RemoveAt(0);
}
log.Information("[ExecutionService] " + message);
this.LogAdded?.Invoke(entry);
}
private void NotifyStateChanged()
{
CurrentState.LastUpdate = DateTime.Now;
this.StateChanged?.Invoke(CurrentState);
}
private unsafe bool IsNamePlateReady()
{
try
{
AtkUnitBasePtr namePlatePtr = gameGui.GetAddonByName("NamePlate");
if (namePlatePtr == IntPtr.Zero)
{
return false;
}
AddonNamePlate* namePlate = (AddonNamePlate*)namePlatePtr.Address;
if (namePlate != null && namePlate->AtkUnitBase.IsVisible && namePlate->AtkUnitBase.IsReady)
{
return true;
}
return false;
}
catch
{
return false;
}
}
public void Dispose()
{
questDetection.QuestAccepted -= OnQuestAccepted;
questDetection.QuestCompleted -= OnQuestCompleted;
framework.Update -= OnFrameworkUpdate;
Stop();
AddLog(LogLevel.Info, "Execution service disposed");
}
}

View file

@ -0,0 +1,14 @@
namespace QuestionableCompanion.Services;
public class ExpansionInfo
{
public string Name { get; set; } = "";
public string ShortName { get; set; } = "";
public uint MinQuestId { get; set; }
public uint MaxQuestId { get; set; }
public int ExpectedQuestCount { get; set; }
}

View file

@ -0,0 +1,14 @@
namespace QuestionableCompanion.Services;
public class ExpansionProgressInfo
{
public string ExpansionName { get; set; } = "";
public string ShortName { get; set; } = "";
public int TotalQuests { get; set; }
public int CompletedQuests { get; set; }
public float Percentage { get; set; }
}

View file

@ -0,0 +1,444 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Dalamud.Game.ClientState.Conditions;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game.Group;
namespace QuestionableCompanion.Services;
public class HelperManager : IDisposable
{
private readonly Configuration configuration;
private readonly IPluginLog log;
private readonly ICommandManager commandManager;
private readonly ICondition condition;
private readonly IClientState clientState;
private readonly IFramework framework;
private readonly PartyInviteService partyInviteService;
private readonly MultiClientIPC multiClientIPC;
private readonly CrossProcessIPC crossProcessIPC;
private readonly PartyInviteAutoAccept partyInviteAutoAccept;
private readonly MemoryHelper memoryHelper;
private bool isInDuty;
private List<(string Name, ushort WorldId)> availableHelpers = new List<(string, ushort)>();
private Dictionary<(string, ushort), bool> helperReadyStatus = new Dictionary<(string, ushort), bool>();
public HelperManager(Configuration configuration, IPluginLog log, ICommandManager commandManager, ICondition condition, IClientState clientState, IFramework framework, PartyInviteService partyInviteService, MultiClientIPC multiClientIPC, CrossProcessIPC crossProcessIPC, PartyInviteAutoAccept partyInviteAutoAccept, MemoryHelper memoryHelper)
{
this.configuration = configuration;
this.log = log;
this.commandManager = commandManager;
this.condition = condition;
this.clientState = clientState;
this.framework = framework;
this.partyInviteService = partyInviteService;
this.multiClientIPC = multiClientIPC;
this.crossProcessIPC = crossProcessIPC;
this.memoryHelper = memoryHelper;
this.partyInviteAutoAccept = partyInviteAutoAccept;
condition.ConditionChange += OnConditionChanged;
multiClientIPC.OnHelperRequested += OnHelperRequested;
multiClientIPC.OnHelperDismissed += OnHelperDismissed;
multiClientIPC.OnHelperAvailable += OnHelperAvailable;
crossProcessIPC.OnHelperRequested += OnHelperRequested;
crossProcessIPC.OnHelperDismissed += OnHelperDismissed;
crossProcessIPC.OnHelperAvailable += OnHelperAvailable;
crossProcessIPC.OnHelperReady += OnHelperReady;
crossProcessIPC.OnHelperInParty += OnHelperInParty;
crossProcessIPC.OnHelperInDuty += OnHelperInDuty;
crossProcessIPC.OnRequestHelperAnnouncements += OnRequestHelperAnnouncements;
if (configuration.IsHighLevelHelper)
{
log.Information("[HelperManager] Will announce helper availability on next frame");
}
log.Information("[HelperManager] Initialized");
}
public void AnnounceIfHelper()
{
if (configuration.IsHighLevelHelper)
{
IPlayerCharacter localPlayer = clientState.LocalPlayer;
if (localPlayer == null)
{
log.Warning("[HelperManager] LocalPlayer is null, cannot announce helper");
return;
}
string localName = localPlayer.Name.ToString();
ushort localWorldId = (ushort)localPlayer.HomeWorld.RowId;
multiClientIPC.AnnounceHelperAvailable(localName, localWorldId);
crossProcessIPC.AnnounceHelper();
log.Information($"[HelperManager] Announced as helper: {localName}@{localWorldId} (both IPC systems)");
}
}
public void InviteHelpers()
{
if (!configuration.IsQuester)
{
log.Debug("[HelperManager] Not a Quester, skipping helper invites");
return;
}
log.Information("[HelperManager] Requesting helper announcements...");
RequestHelperAnnouncements();
Task.Run(async delegate
{
await Task.Delay(1000);
if (availableHelpers.Count == 0)
{
log.Warning("[HelperManager] No helpers available via IPC!");
log.Warning("[HelperManager] Make sure helper clients are running with 'I'm a High-Level Helper' enabled");
}
else
{
log.Information($"[HelperManager] Inviting {availableHelpers.Count} AUTO-DISCOVERED helper(s)...");
DisbandParty();
await Task.Delay(500);
foreach (var (name, worldId) in availableHelpers)
{
if (string.IsNullOrEmpty(name) || worldId == 0)
{
log.Warning($"[HelperManager] Invalid helper: {name}@{worldId}");
}
else
{
log.Information($"[HelperManager] Requesting helper: {name}@{worldId}");
helperReadyStatus[(name, worldId)] = false;
multiClientIPC.RequestHelper(name, worldId);
crossProcessIPC.RequestHelper(name, worldId);
log.Information("[HelperManager] Waiting for " + name + " to be ready...");
DateTime timeout = DateTime.Now.AddSeconds(10.0);
while (!helperReadyStatus.GetValueOrDefault((name, worldId), defaultValue: false) && DateTime.Now < timeout)
{
await Task.Delay(100);
}
if (!helperReadyStatus.GetValueOrDefault((name, worldId), defaultValue: false))
{
log.Warning("[HelperManager] Timeout waiting for " + name + " to be ready!");
}
else
{
log.Information("[HelperManager] " + name + " is ready! Sending invite...");
if (partyInviteService.InviteToParty(name, worldId))
{
log.Information("[HelperManager] Successfully invited " + name);
}
else
{
log.Error("[HelperManager] Failed to invite " + name);
}
await Task.Delay(500);
}
}
}
}
});
}
public List<(string Name, ushort WorldId)> GetAvailableHelpers()
{
return new List<(string, ushort)>(availableHelpers);
}
private void LeaveParty()
{
try
{
log.Information("[HelperManager] Leaving party");
framework.RunOnFrameworkThread(delegate
{
memoryHelper.SendChatMessage("/leave");
log.Information("[HelperManager] /leave command sent via UIModule");
});
}
catch (Exception ex)
{
log.Error("[HelperManager] Failed to leave party: " + ex.Message);
}
}
public void DisbandParty()
{
try
{
log.Information("[HelperManager] Disbanding party");
framework.RunOnFrameworkThread(delegate
{
memoryHelper.SendChatMessage("/leave");
log.Information("[HelperManager] /leave command sent via UIModule");
});
multiClientIPC.DismissHelper();
crossProcessIPC.DismissHelper();
}
catch (Exception ex)
{
log.Error("[HelperManager] Failed to disband party: " + ex.Message);
}
}
private void OnConditionChanged(ConditionFlag flag, bool value)
{
if (flag == ConditionFlag.BoundByDuty)
{
if (value && !isInDuty)
{
isInDuty = true;
OnDutyEnter();
}
else if (!value && isInDuty)
{
isInDuty = false;
OnDutyLeave();
}
}
}
private void OnDutyEnter()
{
log.Information("[HelperManager] Entered duty");
if (!configuration.IsHighLevelHelper)
{
return;
}
configuration.CurrentHelperStatus = HelperStatus.InDungeon;
configuration.Save();
log.Information("[HelperManager] Helper status: InDungeon");
IPlayerCharacter localPlayer = clientState.LocalPlayer;
if (localPlayer != null)
{
string helperName = localPlayer.Name.ToString();
ushort helperWorld = (ushort)localPlayer.HomeWorld.RowId;
crossProcessIPC.BroadcastHelperStatus(helperName, helperWorld, "InDungeon");
}
log.Information("[HelperManager] Starting AutoDuty (High-Level Helper)");
Task.Run(async delegate
{
log.Information("[HelperManager] Waiting 5s before starting AutoDuty...");
await Task.Delay(5000);
framework.RunOnFrameworkThread(delegate
{
try
{
commandManager.ProcessCommand("/ad start");
log.Information("[HelperManager] AutoDuty started");
}
catch (Exception ex)
{
log.Error("[HelperManager] Failed to start AutoDuty: " + ex.Message);
}
});
});
}
private unsafe void OnDutyLeave()
{
log.Information("[HelperManager] Left duty");
if (configuration.IsHighLevelHelper)
{
if (configuration.CurrentHelperStatus == HelperStatus.InDungeon)
{
configuration.CurrentHelperStatus = HelperStatus.Available;
configuration.Save();
log.Information("[HelperManager] Helper status: Available");
IPlayerCharacter localPlayer = clientState.LocalPlayer;
if (localPlayer != null)
{
string helperName = localPlayer.Name.ToString();
ushort helperWorld = (ushort)localPlayer.HomeWorld.RowId;
crossProcessIPC.BroadcastHelperStatus(helperName, helperWorld, "Available");
}
}
log.Information("[HelperManager] Stopping AutoDuty (High-Level Helper)");
framework.RunOnFrameworkThread(delegate
{
try
{
commandManager.ProcessCommand("/ad stop");
log.Information("[HelperManager] AutoDuty stopped");
}
catch (Exception ex)
{
log.Error("[HelperManager] Failed to stop AutoDuty: " + ex.Message);
}
});
log.Information("[HelperManager] Leaving party after duty (High-Level Helper)");
Task.Run(async delegate
{
log.Information("[HelperManager] Waiting 4 seconds for duty to fully complete...");
await Task.Delay(4000);
for (int attempt = 1; attempt <= 3; attempt++)
{
bool inParty = false;
GroupManager* groupManager = GroupManager.Instance();
if (groupManager != null)
{
GroupManager.Group* group = groupManager->GetGroup();
if (group != null && group->MemberCount > 1)
{
inParty = true;
}
}
if (!inParty)
{
log.Information("[HelperManager] Successfully left party or already solo");
break;
}
log.Information($"[HelperManager] Attempt {attempt}/3: Still in party - sending /leave command");
LeaveParty();
if (attempt < 3)
{
await Task.Delay(2000);
}
}
});
}
if (configuration.IsQuester)
{
log.Information("[HelperManager] Disbanding party after duty (Quester)");
DisbandParty();
}
}
private unsafe void OnHelperRequested(string characterName, ushort worldId)
{
if (!configuration.IsHighLevelHelper)
{
log.Debug("[HelperManager] Not a High-Level Helper, ignoring request");
return;
}
IPlayerCharacter localPlayer = clientState.LocalPlayer;
if (localPlayer == null)
{
log.Warning("[HelperManager] Local player is null!");
return;
}
string localName = localPlayer.Name.ToString();
ushort localWorldId = (ushort)localPlayer.HomeWorld.RowId;
if (!(localName == characterName) || localWorldId != worldId)
{
return;
}
log.Information("[HelperManager] Helper request is for me! Checking status...");
Task.Run(async delegate
{
bool needsToLeaveParty = false;
bool isInDuty = false;
GroupManager* groupManager = GroupManager.Instance();
if (groupManager != null)
{
GroupManager.Group* group = groupManager->GetGroup();
if (group != null && group->MemberCount > 0)
{
needsToLeaveParty = true;
log.Information("[HelperManager] Currently in party, notifying quester...");
crossProcessIPC.NotifyHelperInParty(localName, localWorldId);
if (condition[ConditionFlag.BoundByDuty])
{
isInDuty = true;
log.Information("[HelperManager] Currently in duty, notifying quester...");
crossProcessIPC.NotifyHelperInDuty(localName, localWorldId);
}
}
}
if (!isInDuty)
{
if (needsToLeaveParty)
{
LeaveParty();
await Task.Delay(1000);
}
log.Information("[HelperManager] Ready to accept invite!");
partyInviteAutoAccept.EnableAutoAccept();
crossProcessIPC.NotifyHelperReady(localName, localWorldId);
}
});
}
private void OnHelperDismissed()
{
if (configuration.IsHighLevelHelper)
{
log.Information("[HelperManager] Received dismiss signal, leaving party...");
DisbandParty();
}
}
private void OnHelperAvailable(string characterName, ushort worldId)
{
if (configuration.IsQuester && !availableHelpers.Any<(string, ushort)>(((string Name, ushort WorldId) h) => h.Name == characterName && h.WorldId == worldId))
{
availableHelpers.Add((characterName, worldId));
log.Information($"[HelperManager] Helper discovered: {characterName}@{worldId} (Total: {availableHelpers.Count})");
}
}
private void OnHelperReady(string characterName, ushort worldId)
{
if (configuration.IsQuester)
{
log.Information($"[HelperManager] Helper {characterName}@{worldId} is ready!");
helperReadyStatus[(characterName, worldId)] = true;
}
}
private void OnHelperInParty(string characterName, ushort worldId)
{
if (configuration.IsQuester)
{
log.Information($"[HelperManager] Helper {characterName}@{worldId} is in a party, waiting for them to leave...");
}
}
private void OnHelperInDuty(string characterName, ushort worldId)
{
if (configuration.IsQuester)
{
log.Warning($"[HelperManager] Helper {characterName}@{worldId} is in a duty! Cannot invite until they leave.");
}
}
private void OnRequestHelperAnnouncements()
{
if (configuration.IsHighLevelHelper)
{
log.Information("[HelperManager] Received request for helper announcements, announcing...");
AnnounceIfHelper();
}
}
public void RequestHelperAnnouncements()
{
crossProcessIPC.RequestHelperAnnouncements();
}
public void Dispose()
{
condition.ConditionChange -= OnConditionChanged;
multiClientIPC.OnHelperRequested -= OnHelperRequested;
multiClientIPC.OnHelperDismissed -= OnHelperDismissed;
multiClientIPC.OnHelperAvailable -= OnHelperAvailable;
crossProcessIPC.OnHelperRequested -= OnHelperRequested;
crossProcessIPC.OnHelperDismissed -= OnHelperDismissed;
crossProcessIPC.OnHelperAvailable -= OnHelperAvailable;
crossProcessIPC.OnHelperReady -= OnHelperReady;
crossProcessIPC.OnHelperInParty -= OnHelperInParty;
crossProcessIPC.OnHelperInDuty -= OnHelperInDuty;
crossProcessIPC.OnRequestHelperAnnouncements -= OnRequestHelperAnnouncements;
}
}

View file

@ -0,0 +1,262 @@
using System;
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using Dalamud.Plugin.Services;
namespace QuestionableCompanion.Services;
public class LifestreamIPC : IDisposable
{
private readonly IPluginLog log;
private readonly IDalamudPluginInterface pluginInterface;
private ICallGateSubscriber<bool>? isBusySubscriber;
private ICallGateSubscriber<string, bool>? changeWorldSubscriber;
private ICallGateSubscriber<uint, bool>? changeWorldByIdSubscriber;
private ICallGateSubscriber<object>? abortSubscriber;
private bool _isAvailable;
private bool _ipcInitialized;
private DateTime lastAvailabilityCheck = DateTime.MinValue;
private const int AvailabilityCheckCooldownSeconds = 5;
private bool hasPerformedInitialCheck;
public bool IsAvailable
{
get
{
return _isAvailable;
}
private set
{
_isAvailable = value;
}
}
public LifestreamIPC(IPluginLog log, IDalamudPluginInterface pluginInterface)
{
this.log = log;
this.pluginInterface = pluginInterface;
}
private void InitializeIPC()
{
if (_ipcInitialized)
{
return;
}
try
{
isBusySubscriber = pluginInterface.GetIpcSubscriber<bool>("Lifestream.IsBusy");
changeWorldSubscriber = pluginInterface.GetIpcSubscriber<string, bool>("Lifestream.ChangeWorld");
changeWorldByIdSubscriber = pluginInterface.GetIpcSubscriber<uint, bool>("Lifestream.ChangeWorldById");
abortSubscriber = pluginInterface.GetIpcSubscriber<object>("Lifestream.Abort");
_ipcInitialized = true;
log.Debug("[LifestreamIPC] IPC subscribers initialized (lazy-loading enabled)");
}
catch (Exception ex)
{
log.Error("[LifestreamIPC] Failed to initialize subscribers: " + ex.Message);
_isAvailable = false;
_ipcInitialized = false;
}
}
private bool TryEnsureAvailable(bool forceCheck = false)
{
if (_isAvailable)
{
return true;
}
if (!_ipcInitialized)
{
InitializeIPC();
}
if (!_ipcInitialized)
{
return false;
}
DateTime now = DateTime.Now;
if (!forceCheck && hasPerformedInitialCheck && (now - lastAvailabilityCheck).TotalSeconds < 5.0)
{
log.Debug($"[LifestreamIPC] Cooldown active - skipping check (last check: {(now - lastAvailabilityCheck).TotalSeconds:F1}s ago)");
return false;
}
if (forceCheck)
{
log.Information("[LifestreamIPC] FORCED availability check requested");
}
lastAvailabilityCheck = now;
hasPerformedInitialCheck = true;
try
{
if (isBusySubscriber == null)
{
log.Debug("[LifestreamIPC] isBusySubscriber is NULL - cannot check availability");
_isAvailable = false;
return false;
}
log.Debug("[LifestreamIPC] Attempting to invoke Lifestream.IsBusy()...");
bool testBusy = isBusySubscriber.InvokeFunc();
if (!_isAvailable)
{
_isAvailable = true;
log.Information($"[LifestreamIPC] Lifestream is now available (Busy: {testBusy})");
}
else
{
log.Debug($"[LifestreamIPC] Lifestream still available (Busy: {testBusy})");
}
return true;
}
catch (Exception ex)
{
if (!hasPerformedInitialCheck)
{
log.Warning("[LifestreamIPC] First availability check FAILED: " + ex.GetType().Name + ": " + ex.Message);
}
else
{
log.Debug("[LifestreamIPC] Lifestream not yet available: " + ex.Message);
}
_isAvailable = false;
return false;
}
}
public bool IsBusy()
{
TryEnsureAvailable();
if (!_isAvailable || isBusySubscriber == null)
{
return false;
}
try
{
return isBusySubscriber.InvokeFunc();
}
catch (Exception ex)
{
log.Error("[LifestreamIPC] Error checking busy status: " + ex.Message);
return false;
}
}
public bool ForceCheckAvailability()
{
log.Information("[LifestreamIPC] ========================================");
log.Information("[LifestreamIPC] === FORCING AVAILABILITY CHECK ===");
log.Information("[LifestreamIPC] ========================================");
bool result = TryEnsureAvailable(forceCheck: true);
log.Information($"[LifestreamIPC] Force check result: {result}");
return result;
}
public bool ChangeWorld(string worldName)
{
TryEnsureAvailable();
log.Information("[LifestreamIPC] ========================================");
log.Information("[LifestreamIPC] === CHANGE WORLD REQUEST ===");
log.Information("[LifestreamIPC] ========================================");
log.Information("[LifestreamIPC] Target World: '" + worldName + "'");
log.Information($"[LifestreamIPC] IsAvailable: {_isAvailable}");
log.Information($"[LifestreamIPC] changeWorldSubscriber != null: {changeWorldSubscriber != null}");
if (!_isAvailable || changeWorldSubscriber == null)
{
log.Error("[LifestreamIPC] CANNOT CHANGE WORLD - Lifestream not available!");
log.Error("[LifestreamIPC] Make sure Lifestream plugin is installed and enabled!");
return false;
}
try
{
log.Information("[LifestreamIPC] Invoking Lifestream.ChangeWorld('" + worldName + "')...");
bool num = changeWorldSubscriber.InvokeFunc(worldName);
if (num)
{
log.Information("[LifestreamIPC] ========================================");
log.Information("[LifestreamIPC] WORLD CHANGE ACCEPTED: " + worldName);
log.Information("[LifestreamIPC] ========================================");
}
else
{
log.Warning("[LifestreamIPC] ========================================");
log.Warning("[LifestreamIPC] WORLD CHANGE REJECTED: " + worldName);
log.Warning("[LifestreamIPC] ========================================");
log.Warning("[LifestreamIPC] Possible reasons:");
log.Warning("[LifestreamIPC] - Lifestream is busy");
log.Warning("[LifestreamIPC] - World name is invalid");
log.Warning("[LifestreamIPC] - Cannot visit this world");
}
return num;
}
catch (Exception ex)
{
log.Error("[LifestreamIPC] ========================================");
log.Error("[LifestreamIPC] ERROR REQUESTING WORLD CHANGE!");
log.Error("[LifestreamIPC] ========================================");
log.Error("[LifestreamIPC] Error: " + ex.Message);
log.Error("[LifestreamIPC] Stack: " + ex.StackTrace);
return false;
}
}
public bool ChangeWorldById(uint worldId)
{
TryEnsureAvailable();
if (!_isAvailable || changeWorldByIdSubscriber == null)
{
log.Warning("[LifestreamIPC] Lifestream not available for world change");
return false;
}
try
{
log.Information($"[LifestreamIPC] Requesting world change to ID: {worldId}");
bool num = changeWorldByIdSubscriber.InvokeFunc(worldId);
if (num)
{
log.Information($"[LifestreamIPC] World change request accepted for ID: {worldId}");
}
else
{
log.Warning($"[LifestreamIPC] World change request rejected for ID: {worldId}");
}
return num;
}
catch (Exception ex)
{
log.Error("[LifestreamIPC] Error requesting world change by ID: " + ex.Message);
return false;
}
}
public void Abort()
{
TryEnsureAvailable();
if (!_isAvailable || abortSubscriber == null)
{
return;
}
try
{
abortSubscriber.InvokeAction();
log.Information("[LifestreamIPC] Abort request sent to Lifestream");
}
catch (Exception ex)
{
log.Error("[LifestreamIPC] Error aborting Lifestream: " + ex.Message);
}
}
public void Dispose()
{
log.Information("[LifestreamIPC] Service disposed");
}
}

View file

@ -0,0 +1,602 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game;
using Lumina.Excel;
using Lumina.Excel.Sheets;
using QuestionableCompanion.Data;
namespace QuestionableCompanion.Services;
public class MSQProgressionService
{
private readonly IDataManager dataManager;
private readonly IPluginLog log;
private readonly QuestDetectionService questDetectionService;
private readonly IClientState clientState;
private readonly IFramework framework;
private List<Quest>? mainScenarioQuests;
private Dictionary<uint, string> questNameCache = new Dictionary<uint, string>();
private Dictionary<string, List<Quest>> questsByExpansion = new Dictionary<string, List<Quest>>();
private static readonly uint[] MSQ_JOURNAL_GENRE_IDS = new uint[14]
{
1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u,
11u, 12u, 13u, 14u
};
private const uint LAST_ARR_QUEST_ID = 65964u;
private static readonly Dictionary<uint, MSQExpansionData.Expansion> JournalGenreToExpansion = new Dictionary<uint, MSQExpansionData.Expansion>
{
{
1u,
MSQExpansionData.Expansion.ARealmReborn
},
{
2u,
MSQExpansionData.Expansion.ARealmReborn
},
{
3u,
MSQExpansionData.Expansion.Heavensward
},
{
4u,
MSQExpansionData.Expansion.Heavensward
},
{
5u,
MSQExpansionData.Expansion.Heavensward
},
{
6u,
MSQExpansionData.Expansion.Stormblood
},
{
7u,
MSQExpansionData.Expansion.Stormblood
},
{
8u,
MSQExpansionData.Expansion.Shadowbringers
},
{
9u,
MSQExpansionData.Expansion.Shadowbringers
},
{
10u,
MSQExpansionData.Expansion.Shadowbringers
},
{
11u,
MSQExpansionData.Expansion.Endwalker
},
{
12u,
MSQExpansionData.Expansion.Endwalker
},
{
13u,
MSQExpansionData.Expansion.Dawntrail
},
{
14u,
MSQExpansionData.Expansion.Dawntrail
}
};
public MSQProgressionService(IDataManager dataManager, IPluginLog log, QuestDetectionService questDetectionService, IClientState clientState, IFramework framework)
{
this.dataManager = dataManager;
this.log = log;
this.questDetectionService = questDetectionService;
this.clientState = clientState;
this.framework = framework;
InitializeMSQData();
framework.RunOnTick(delegate
{
DebugCurrentCharacterQuest();
}, default(TimeSpan), 60);
}
private void InitializeMSQData()
{
try
{
log.Information("[MSQProgression] === INITIALIZING MSQ DATA ===");
ExcelSheet<Quest> questSheet = dataManager.GetExcelSheet<Quest>();
if (questSheet == null)
{
log.Error("[MSQProgression] Failed to load Quest sheet from Lumina!");
return;
}
int totalQuests = questSheet.Count();
log.Information($"[MSQProgression] ✓ Lumina Quest Sheet loaded: {totalQuests} total quests");
int manualCount = 0;
foreach (Quest item in questSheet)
{
_ = item;
manualCount++;
}
log.Information($"[MSQProgression] Manual iteration count: {manualCount} quests");
List<Quest> highIdQuests = questSheet.Where((Quest q) => q.RowId > 66000).ToList();
log.Information($"[MSQProgression] Quests with RowId > 66000: {highIdQuests.Count}");
if (highIdQuests.Count > 0)
{
Quest firstHighId = highIdQuests.First();
log.Information($"[MSQProgression] First High ID Quest: {firstHighId.RowId}");
log.Information($"[MSQProgression] - Name: {firstHighId.Name}");
log.Information($"[MSQProgression] - Expansion.RowId: {firstHighId.Expansion.RowId}");
log.Information($"[MSQProgression] - JournalGenre.RowId: {firstHighId.JournalGenre.RowId}");
}
log.Information("[MSQProgression] Analyzing JournalGenre distribution...");
foreach (IGrouping<uint, Quest> group in (from q in questSheet
where q.RowId != 0
group q by q.JournalGenre.RowId into g
orderby g.Key
select g).Take(10))
{
log.Information($"[MSQProgression] Genre {group.Key}: {group.Count()} quests");
}
log.Information("[MSQProgression] Filtering MSQ quests by JournalGenre categories (1-14)...");
mainScenarioQuests = (from q in questSheet
where ((ReadOnlySpan<uint>)MSQ_JOURNAL_GENRE_IDS).Contains(q.JournalGenre.RowId)
orderby q.RowId
select q).ToList();
log.Information($"[MSQProgression] ✓ Found {mainScenarioQuests.Count} total MSQ quests across all expansions!");
if (mainScenarioQuests.Count == 0)
{
log.Error("[MSQProgression] No MSQ quests found! JournalGenre filter may be incorrect.");
return;
}
log.Information("[MSQProgression] === DETAILED MSQ QUEST ANALYSIS (First 20) ===");
foreach (Quest quest in mainScenarioQuests.Take(20))
{
log.Information($"[MSQProgression] Quest {quest.RowId}:");
log.Information($"[MSQProgression] - Name: {quest.Name}");
log.Information($"[MSQProgression] - Expansion.RowId: {quest.Expansion.RowId}");
try
{
string expansionName = quest.Expansion.Value.Name.ToString();
log.Information("[MSQProgression] - Expansion.Name: " + expansionName);
}
catch (Exception ex)
{
log.Information("[MSQProgression] - Expansion.Name: ERROR - " + ex.Message);
}
log.Information($"[MSQProgression] - JournalGenre.RowId: {quest.JournalGenre.RowId}");
}
log.Information("[MSQProgression] === MSQ QUESTS BY JOURNALGENRE (EXPANSION) ===");
foreach (IGrouping<uint, Quest> group2 in from q in mainScenarioQuests
group q by q.JournalGenre.RowId into g
orderby g.Key
select g)
{
uint genreId = group2.Key;
MSQExpansionData.Expansion expansion = JournalGenreToExpansion.GetValueOrDefault(genreId, MSQExpansionData.Expansion.ARealmReborn);
string genreName = group2.First().JournalGenre.Value.Name.ToString();
string sampleQuests = string.Join(", ", from q in group2.Take(3)
select q.RowId);
log.Information($"[MSQProgression] JournalGenre {genreId} ({expansion}):");
log.Information("[MSQProgression] - Name: " + genreName);
log.Information($"[MSQProgression] - Count: {group2.Count()} quests");
log.Information("[MSQProgression] - Samples: " + sampleQuests + "...");
}
log.Information("[MSQProgression] === MSQ QUESTS BY EXPANSION (GROUPED) ===");
foreach (IGrouping<MSQExpansionData.Expansion, Quest> group3 in from q in mainScenarioQuests
group q by JournalGenreToExpansion.GetValueOrDefault(q.JournalGenre.RowId, MSQExpansionData.Expansion.ARealmReborn) into g
orderby g.Key
select g)
{
log.Information($"[MSQProgression] {group3.Key}: {group3.Count()} quests total");
}
MSQExpansionData.ClearQuests();
log.Information("[MSQProgression] Building expansion quest mappings...");
foreach (Quest quest2 in mainScenarioQuests)
{
string name = quest2.Name.ToString();
if (!string.IsNullOrEmpty(name))
{
questNameCache[quest2.RowId] = name;
}
MSQExpansionData.Expansion expansion2 = JournalGenreToExpansion.GetValueOrDefault(quest2.JournalGenre.RowId, MSQExpansionData.Expansion.ARealmReborn);
if (quest2.JournalGenre.RowId != 2 || quest2.RowId <= 65964)
{
MSQExpansionData.RegisterQuest(quest2.RowId, expansion2);
string shortName = MSQExpansionData.GetExpansionShortName(expansion2);
if (!questsByExpansion.ContainsKey(shortName))
{
questsByExpansion[shortName] = new List<Quest>();
}
questsByExpansion[shortName].Add(quest2);
}
}
log.Information("[MSQProgression] === EXPANSION BREAKDOWN ===");
foreach (MSQExpansionData.Expansion exp in MSQExpansionData.GetAllExpansions())
{
string shortName2 = MSQExpansionData.GetExpansionShortName(exp);
List<Quest> quests = questsByExpansion.GetValueOrDefault(shortName2);
int count = quests?.Count ?? 0;
if (count > 0 && quests != null)
{
string sampleIds = string.Join(", ", from q in quests.Take(5)
select q.RowId);
log.Information($"[MSQProgression] ✓ {MSQExpansionData.GetExpansionName(exp)} ({shortName2}): {count} quests (IDs: {sampleIds}...)");
}
else
{
log.Warning($"[MSQProgression] ⚠ {MSQExpansionData.GetExpansionName(exp)} ({shortName2}): {count} quests (EMPTY!)");
}
}
log.Information("[MSQProgression] === MSQ DATA INITIALIZATION COMPLETE ===");
}
catch (Exception ex2)
{
log.Error("[MSQProgression] EXCEPTION during MSQ data initialization: " + ex2.Message);
log.Error("[MSQProgression] Stack trace: " + ex2.StackTrace);
}
}
public (uint questId, string questName) GetLastCompletedMSQ(string characterName)
{
if (mainScenarioQuests == null || mainScenarioQuests.Count == 0)
{
return (questId: 0u, questName: "—");
}
try
{
List<uint> completedQuests = questDetectionService.GetAllCompletedQuestIds();
Quest lastMSQ = (from q in mainScenarioQuests
where completedQuests.Contains(q.RowId)
orderby q.RowId descending
select q).FirstOrDefault();
if (lastMSQ.RowId != 0)
{
string questName = questNameCache.GetValueOrDefault(lastMSQ.RowId, "Unknown Quest");
return (questId: lastMSQ.RowId, questName: questName);
}
}
catch (Exception ex)
{
log.Error("[MSQProgression] Failed to get last completed MSQ: " + ex.Message);
}
return (questId: 0u, questName: "—");
}
public float GetMSQCompletionPercentage()
{
if (mainScenarioQuests == null || mainScenarioQuests.Count == 0)
{
return 0f;
}
try
{
List<uint> completedQuests = questDetectionService.GetAllCompletedQuestIds();
return (float)mainScenarioQuests.Count((Quest q) => completedQuests.Contains(q.RowId)) / (float)mainScenarioQuests.Count * 100f;
}
catch (Exception ex)
{
log.Error("[MSQProgression] Failed to calculate MSQ completion: " + ex.Message);
return 0f;
}
}
public int GetTotalMSQCount()
{
return mainScenarioQuests?.Count ?? 0;
}
public int GetCompletedMSQCount()
{
if (mainScenarioQuests == null || mainScenarioQuests.Count == 0)
{
return 0;
}
try
{
List<uint> completedQuests = questDetectionService.GetAllCompletedQuestIds();
return mainScenarioQuests.Count((Quest q) => completedQuests.Contains(q.RowId));
}
catch (Exception ex)
{
log.Error("[MSQProgression] Failed to get completed MSQ count: " + ex.Message);
return 0;
}
}
public string GetQuestName(uint questId)
{
return questNameCache.GetValueOrDefault(questId, "Unknown Quest");
}
public bool IsMSQ(uint questId)
{
return mainScenarioQuests?.Any((Quest q) => q.RowId == questId) ?? false;
}
public ExpansionInfo? GetExpansionForQuest(uint questId)
{
MSQExpansionData.Expansion expansion = MSQExpansionData.GetExpansionForQuest(questId);
return new ExpansionInfo
{
Name = MSQExpansionData.GetExpansionName(expansion),
ShortName = MSQExpansionData.GetExpansionShortName(expansion),
MinQuestId = 0u,
MaxQuestId = 0u,
ExpectedQuestCount = MSQExpansionData.GetExpectedQuestCount(expansion)
};
}
public List<ExpansionInfo> GetExpansions()
{
return (from exp in MSQExpansionData.GetAllExpansions()
select new ExpansionInfo
{
Name = MSQExpansionData.GetExpansionName(exp),
ShortName = MSQExpansionData.GetExpansionShortName(exp),
MinQuestId = 0u,
MaxQuestId = 0u,
ExpectedQuestCount = MSQExpansionData.GetExpectedQuestCount(exp)
}).ToList();
}
public (int completed, int total) GetExpansionProgress(string expansionShortName)
{
List<uint> completedQuests = questDetectionService.GetAllCompletedQuestIds();
List<Quest>? obj = questsByExpansion.GetValueOrDefault(expansionShortName) ?? new List<Quest>();
int completed = obj.Count((Quest q) => completedQuests.Contains(q.RowId));
int total = obj.Count;
return (completed: completed, total: total);
}
public ExpansionInfo? GetCurrentExpansion()
{
try
{
log.Information("[MSQProgression] ========================================");
log.Information("[MSQProgression] === DETECTING CURRENT EXPANSION ===");
log.Information("[MSQProgression] ========================================");
List<uint> completedQuests = questDetectionService.GetAllCompletedQuestIds();
log.Information($"[MSQProgression] Total completed quests: {completedQuests.Count}");
log.Information("[MSQProgression] METHOD 1: Using AgentScenarioTree (Game Data)");
log.Information("[MSQProgression] ------------------------------------------------");
(MSQExpansionData.Expansion expansion, string debugInfo) currentExpansionFromGameWithDebug = MSQExpansionData.GetCurrentExpansionFromGameWithDebug();
MSQExpansionData.Expansion gameExpansion = currentExpansionFromGameWithDebug.expansion;
string[] array = currentExpansionFromGameWithDebug.debugInfo.Split('\n');
foreach (string line in array)
{
if (!string.IsNullOrWhiteSpace(line))
{
log.Information("[MSQProgression] " + line);
}
}
log.Information("[MSQProgression] Game Data Result: " + MSQExpansionData.GetExpansionName(gameExpansion));
log.Information("[MSQProgression] METHOD 2: Using Completed Quests Analysis");
log.Information("[MSQProgression] ------------------------------------------------");
MSQExpansionData.Expansion analysisExpansion = MSQExpansionData.GetCurrentExpansion(completedQuests);
log.Information("[MSQProgression] Analysis Result: " + MSQExpansionData.GetExpansionName(analysisExpansion));
array = MSQExpansionData.GetExpansionDetectionDebugInfo(completedQuests).Split('\n');
foreach (string line2 in array)
{
if (!string.IsNullOrWhiteSpace(line2))
{
log.Debug("[MSQProgression] " + line2);
}
}
log.Information("[MSQProgression] COMPARISON:");
log.Information("[MSQProgression] Game Data: " + MSQExpansionData.GetExpansionName(gameExpansion));
log.Information("[MSQProgression] Analysis: " + MSQExpansionData.GetExpansionName(analysisExpansion));
MSQExpansionData.Expansion finalExpansion = gameExpansion;
if (gameExpansion == MSQExpansionData.Expansion.ARealmReborn && analysisExpansion != MSQExpansionData.Expansion.ARealmReborn)
{
log.Warning("[MSQProgression] Game data returned ARR but analysis found higher expansion!");
log.Warning("[MSQProgression] Using analysis result: " + MSQExpansionData.GetExpansionName(analysisExpansion));
finalExpansion = analysisExpansion;
}
log.Information("[MSQProgression] ========================================");
log.Information("[MSQProgression] >>> FINAL EXPANSION: " + MSQExpansionData.GetExpansionName(finalExpansion) + " <<<");
log.Information("[MSQProgression] ========================================");
return new ExpansionInfo
{
Name = MSQExpansionData.GetExpansionName(finalExpansion),
ShortName = MSQExpansionData.GetExpansionShortName(finalExpansion),
MinQuestId = 0u,
MaxQuestId = 0u,
ExpectedQuestCount = MSQExpansionData.GetExpectedQuestCount(finalExpansion)
};
}
catch (Exception ex)
{
log.Error("[MSQProgression] Error detecting expansion: " + ex.Message);
log.Error("[MSQProgression] Stack: " + ex.StackTrace);
return GetExpansions().FirstOrDefault();
}
}
public Dictionary<string, (int completed, int total)> GetExpansionProgressForCharacter(List<uint> completedQuestIds)
{
Dictionary<string, (int, int)> result = new Dictionary<string, (int, int)>();
foreach (ExpansionInfo exp in GetExpansions())
{
List<Quest> expansionQuests = questsByExpansion.GetValueOrDefault(exp.ShortName) ?? new List<Quest>();
int completed = expansionQuests.Count((Quest q) => completedQuestIds.Contains(q.RowId));
result[exp.ShortName] = (completed, expansionQuests.Count);
}
return result;
}
public List<Quest> GetAllMSQQuests()
{
return mainScenarioQuests ?? new List<Quest>();
}
public Dictionary<string, ExpansionProgressInfo> GetExpansionProgress()
{
Dictionary<string, ExpansionProgressInfo> result = new Dictionary<string, ExpansionProgressInfo>();
List<uint> completedQuests = questDetectionService.GetAllCompletedQuestIds();
foreach (MSQExpansionData.Expansion expansion in MSQExpansionData.GetAllExpansions())
{
ExpansionProgress progress = MSQExpansionData.GetExpansionProgress(completedQuests, expansion);
result[progress.ExpansionName] = new ExpansionProgressInfo
{
ExpansionName = progress.ExpansionName,
ShortName = progress.ExpansionShortName,
TotalQuests = progress.ExpectedCount,
CompletedQuests = progress.CompletedCount,
Percentage = progress.Percentage
};
}
return result;
}
public Dictionary<string, ExpansionProgressInfo> GetExpansionProgressForCharacter(List<string> completedQuestIds)
{
Dictionary<string, ExpansionProgressInfo> result = new Dictionary<string, ExpansionProgressInfo>();
uint result2;
List<uint> completedQuestIdsUint = (from id in completedQuestIds
select uint.TryParse(id, out result2) ? result2 : 0u into id
where id != 0
select id).ToList();
foreach (MSQExpansionData.Expansion expansion in MSQExpansionData.GetAllExpansions())
{
ExpansionProgress progress = MSQExpansionData.GetExpansionProgress(completedQuestIdsUint, expansion);
result[progress.ExpansionName] = new ExpansionProgressInfo
{
ExpansionName = progress.ExpansionName,
ShortName = progress.ExpansionShortName,
TotalQuests = progress.ExpectedCount,
CompletedQuests = progress.CompletedCount,
Percentage = progress.Percentage
};
}
return result;
}
public ExpansionInfo? GetCurrentExpansion(uint lastCompletedQuestId)
{
MSQExpansionData.Expansion expansion = MSQExpansionData.GetExpansionForQuest(lastCompletedQuestId);
return new ExpansionInfo
{
Name = MSQExpansionData.GetExpansionName(expansion),
ShortName = MSQExpansionData.GetExpansionShortName(expansion),
MinQuestId = 0u,
MaxQuestId = 0u,
ExpectedQuestCount = MSQExpansionData.GetExpectedQuestCount(expansion)
};
}
private MSQExpansionData.Expansion ConvertLuminaExpansionToOurs(uint luminaExpansionId)
{
return luminaExpansionId switch
{
0u => MSQExpansionData.Expansion.ARealmReborn,
1u => MSQExpansionData.Expansion.Heavensward,
2u => MSQExpansionData.Expansion.Stormblood,
3u => MSQExpansionData.Expansion.Shadowbringers,
4u => MSQExpansionData.Expansion.Endwalker,
5u => MSQExpansionData.Expansion.Dawntrail,
_ => MSQExpansionData.Expansion.ARealmReborn,
};
}
public void DebugCurrentCharacterQuest()
{
try
{
log.Information("[MSQProgression] === DEBUG CURRENT CHARACTER QUEST ===");
IPlayerCharacter player = clientState.LocalPlayer;
if (player == null)
{
log.Warning("[MSQProgression] LocalPlayer is null - not logged in yet?");
framework.RunOnTick(delegate
{
DebugCurrentCharacterQuest();
}, default(TimeSpan), 60);
return;
}
string characterName = player.Name.TextValue;
string worldName = player.HomeWorld.Value.Name.ToString();
log.Information("[MSQProgression] Character: " + characterName + " @ " + worldName);
ExcelSheet<Quest> questSheet = dataManager.GetExcelSheet<Quest>();
if (questSheet == null)
{
log.Error("[MSQProgression] Failed to load Quest sheet!");
return;
}
List<Quest> completedMSQQuests = new List<Quest>();
log.Information("[MSQProgression] Checking MSQ quest completion...");
foreach (Quest quest in questSheet)
{
if (((ReadOnlySpan<uint>)MSQ_JOURNAL_GENRE_IDS).Contains(quest.JournalGenre.RowId) && QuestManager.IsQuestComplete((ushort)quest.RowId))
{
completedMSQQuests.Add(quest);
}
}
log.Information($"[MSQProgression] Character has {completedMSQQuests.Count} completed MSQ quests");
if (completedMSQQuests.Count == 0)
{
log.Warning("[MSQProgression] No completed MSQ quests found!");
return;
}
Quest latestMSQQuest = completedMSQQuests.OrderByDescending((Quest quest2) => quest2.RowId).First();
log.Information($"[MSQProgression] Latest completed MSQ quest ID: {latestMSQQuest.RowId}");
Quest questData = latestMSQQuest;
log.Information("[MSQProgression] === LATEST MSQ QUEST DETAILS ===");
log.Information($"[MSQProgression] Quest ID: {questData.RowId}");
log.Information($"[MSQProgression] Quest Name: {questData.Name}");
log.Information($"[MSQProgression] JournalGenre.RowId: {questData.JournalGenre.RowId}");
try
{
string genreName = questData.JournalGenre.Value.Name.ToString();
log.Information("[MSQProgression] JournalGenre.Name: " + genreName);
}
catch
{
log.Information("[MSQProgression] JournalGenre.Name: ERROR");
}
log.Information($"[MSQProgression] Expansion.RowId: {questData.Expansion.RowId}");
try
{
string expansionName = questData.Expansion.Value.Name.ToString();
log.Information("[MSQProgression] Expansion.Name: " + expansionName);
}
catch
{
log.Information("[MSQProgression] Expansion.Name: ERROR");
}
log.Information("[MSQProgression] === CHARACTER IS IN THIS EXPANSION ===");
log.Information($"[MSQProgression] Character is at Quest {questData.RowId} which is in:");
log.Information($"[MSQProgression] - JournalGenre: {questData.JournalGenre.RowId}");
log.Information($"[MSQProgression] - Expansion: {questData.Expansion.RowId}");
log.Information("[MSQProgression] === RECENT COMPLETED MSQ QUESTS (Last 10) ===");
foreach (Quest q in completedMSQQuests.OrderByDescending((Quest quest2) => quest2.RowId).Take(10).ToList())
{
log.Information($"[MSQProgression] Quest {q.RowId}: {q.Name} (Genre: {q.JournalGenre.RowId}, Exp: {q.Expansion.RowId})");
}
log.Information("[MSQProgression] === COMPLETED MSQ QUESTS BY EXPANSION ===");
foreach (IGrouping<MSQExpansionData.Expansion, Quest> group in from quest2 in completedMSQQuests
group quest2 by JournalGenreToExpansion.GetValueOrDefault(quest2.JournalGenre.RowId, MSQExpansionData.Expansion.ARealmReborn) into g
orderby g.Key
select g)
{
log.Information($"[MSQProgression] {group.Key}: {group.Count()} quests completed");
}
}
catch (Exception ex)
{
log.Error("[MSQProgression] ERROR in DebugCurrentCharacterQuest: " + ex.Message);
log.Error("[MSQProgression] Stack trace: " + ex.StackTrace);
}
}
}

View file

@ -0,0 +1,119 @@
using System;
using System.Runtime.InteropServices;
using System.Text;
using Dalamud.Hooking;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.System.String;
using FFXIVClientStructs.FFXIV.Client.UI;
namespace QuestionableCompanion.Services;
public class MemoryHelper : IDisposable
{
public unsafe delegate void RidePillionDelegate(BattleChara* target, int seatIndex);
private readonly IPluginLog log;
private Hook<RidePillionDelegate>? ridePillionHook;
private const string RidePillionSignature = "48 85 C9 0F 84 ?? ?? ?? ?? 48 89 6C 24 ?? 56 48 83 EC";
public RidePillionDelegate? RidePillion { get; private set; }
public unsafe MemoryHelper(IPluginLog log, IGameInteropProvider gameInterop)
{
this.log = log;
try
{
ridePillionHook = gameInterop.HookFromSignature<RidePillionDelegate>("48 85 C9 0F 84 ?? ?? ?? ?? 48 89 6C 24 ?? 56 48 83 EC", RidePillionDetour);
if (ridePillionHook != null && ridePillionHook.Address != IntPtr.Zero)
{
log.Information($"[MemoryHelper] RidePillion function found at 0x{ridePillionHook.Address:X}");
RidePillion = ridePillionHook.Original;
log.Information("[MemoryHelper] RidePillion function initialized successfully");
}
else
{
log.Warning("[MemoryHelper] RidePillion function not found - will fall back to commands");
}
}
catch (Exception ex)
{
log.Error("[MemoryHelper] Error initializing RidePillion: " + ex.Message);
}
}
private unsafe void RidePillionDetour(BattleChara* target, int seatIndex)
{
ridePillionHook?.Original(target, seatIndex);
}
public unsafe bool ExecuteRidePillion(BattleChara* target, int seatIndex = 10)
{
if (RidePillion == null)
{
log.Warning("[MemoryHelper] RidePillion function not available");
return false;
}
if (target == null)
{
log.Error("[MemoryHelper] RidePillion target is null");
return false;
}
try
{
log.Information($"[MemoryHelper] Executing RidePillion on target (seat {seatIndex})");
RidePillion(target, seatIndex);
return true;
}
catch (Exception ex)
{
log.Error("[MemoryHelper] RidePillion execution error: " + ex.Message);
return false;
}
}
public unsafe bool SendChatMessage(string message)
{
try
{
UIModule* uiModule = UIModule.Instance();
if (uiModule == null)
{
log.Error("[MemoryHelper] UIModule is null!");
return false;
}
byte[] bytes = Encoding.UTF8.GetBytes(message);
nint mem1 = Marshal.AllocHGlobal(400);
nint mem2 = Marshal.AllocHGlobal(bytes.Length + 30);
try
{
Marshal.Copy(bytes, 0, mem2, bytes.Length);
Marshal.WriteByte(mem2 + bytes.Length, 0);
Marshal.WriteInt64(mem1, ((IntPtr)mem2).ToInt64());
Marshal.WriteInt64(mem1 + 8, 64L);
Marshal.WriteInt64(mem1 + 8 + 8, bytes.Length + 1);
Marshal.WriteInt64(mem1 + 8 + 8 + 8, 0L);
uiModule->ProcessChatBoxEntry((Utf8String*)mem1);
log.Information("[MemoryHelper] Chat message sent: " + message);
return true;
}
finally
{
Marshal.FreeHGlobal(mem1);
Marshal.FreeHGlobal(mem2);
}
}
catch (Exception ex)
{
log.Error("[MemoryHelper] SendChatMessage error: " + ex.Message);
return false;
}
}
public void Dispose()
{
ridePillionHook?.Dispose();
}
}

View file

@ -0,0 +1,167 @@
using System;
using System.Numerics;
using Dalamud.Plugin.Services;
namespace QuestionableCompanion.Services;
public class MovementMonitorService : IDisposable
{
private readonly IClientState clientState;
private readonly IPluginLog log;
private readonly ICommandManager commandManager;
private readonly IFramework framework;
private readonly Configuration config;
private ChauffeurModeService? chauffeurMode;
private Vector3 lastPosition = Vector3.Zero;
private DateTime lastMovementTime = DateTime.Now;
private DateTime lastCheckTime = DateTime.MinValue;
private bool isMonitoring;
private const float MovementThreshold = 0.1f;
public bool IsMonitoring => isMonitoring;
public MovementMonitorService(IClientState clientState, IPluginLog log, ICommandManager commandManager, IFramework framework, Configuration config)
{
this.clientState = clientState;
this.log = log;
this.commandManager = commandManager;
this.framework = framework;
this.config = config;
log.Information("[MovementMonitor] Service initialized");
}
public void SetChauffeurMode(ChauffeurModeService service)
{
chauffeurMode = service;
log.Information("[MovementMonitor] ChauffeurMode service linked for failsafe");
}
public void StartMonitoring()
{
if (!isMonitoring)
{
isMonitoring = true;
lastMovementTime = DateTime.Now;
lastCheckTime = DateTime.Now;
lastPosition = Vector3.Zero;
framework.Update += OnFrameworkUpdate;
log.Information("[MovementMonitor] Movement monitoring started");
}
}
public void StopMonitoring()
{
if (isMonitoring)
{
isMonitoring = false;
framework.Update -= OnFrameworkUpdate;
log.Information("[MovementMonitor] Movement monitoring stopped");
}
}
public void ResetMovementTimer()
{
lastMovementTime = DateTime.Now;
if (clientState.LocalPlayer != null)
{
lastPosition = clientState.LocalPlayer.Position;
}
}
private void OnFrameworkUpdate(IFramework framework)
{
if (!isMonitoring)
{
return;
}
DateTime now = DateTime.Now;
if ((now - lastCheckTime).TotalSeconds < (double)config.MovementCheckInterval)
{
return;
}
lastCheckTime = now;
if (clientState.LocalPlayer == null || !clientState.IsLoggedIn)
{
return;
}
try
{
Vector3 currentPosition = clientState.LocalPlayer.Position;
if (lastPosition == Vector3.Zero)
{
lastPosition = currentPosition;
lastMovementTime = now;
return;
}
if (Vector3.Distance(lastPosition, currentPosition) > 0.1f)
{
lastMovementTime = now;
lastPosition = currentPosition;
return;
}
double timeSinceMovement = (now - lastMovementTime).TotalSeconds;
if (!(timeSinceMovement >= (double)config.MovementStuckThreshold))
{
return;
}
log.Warning("[MovementMonitor] ========================================");
log.Warning("[MovementMonitor] === PLAYER STUCK DETECTED ===");
log.Warning("[MovementMonitor] ========================================");
log.Warning($"[MovementMonitor] No movement for {timeSinceMovement:F1} seconds");
log.Warning($"[MovementMonitor] Position: {currentPosition}");
if (chauffeurMode != null && (chauffeurMode.IsWaitingForHelper || chauffeurMode.IsTransportingQuester))
{
log.Warning("[MovementMonitor] FAILSAFE: Resetting Chauffeur Mode due to stuck detection!");
chauffeurMode.ResetChauffeurState();
}
log.Warning("[MovementMonitor] Sending /qst reload command...");
framework.RunOnTick(delegate
{
try
{
commandManager.ProcessCommand("/qst reload");
log.Information("[MovementMonitor] /qst reload command sent");
}
catch (Exception ex2)
{
log.Error("[MovementMonitor] Failed to send reload command: " + ex2.Message);
}
}, TimeSpan.FromMilliseconds(100L, 0L));
framework.RunOnTick(delegate
{
try
{
commandManager.ProcessCommand("/qst start");
log.Information("[MovementMonitor] /qst start command sent");
}
catch (Exception ex2)
{
log.Error("[MovementMonitor] Failed to send start command: " + ex2.Message);
}
}, TimeSpan.FromSeconds(1L));
lastMovementTime = now;
lastPosition = currentPosition;
log.Information("[MovementMonitor] Movement timer reset - monitoring continues...");
}
catch (Exception ex)
{
log.Error("[MovementMonitor] Error checking movement: " + ex.Message);
}
}
public void Dispose()
{
StopMonitoring();
log.Information("[MovementMonitor] Service disposed");
}
}

View file

@ -0,0 +1,230 @@
using System;
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using Dalamud.Plugin.Services;
namespace QuestionableCompanion.Services;
public class MultiClientIPC : IDisposable
{
private readonly IDalamudPluginInterface pluginInterface;
private readonly IPluginLog log;
private readonly ICallGateProvider<string, ushort, object?> requestHelperProvider;
private readonly ICallGateProvider<object?> dismissHelperProvider;
private readonly ICallGateProvider<string, ushort, object?> helperAvailableProvider;
private readonly ICallGateProvider<string, object?> chatMessageProvider;
private readonly ICallGateProvider<string, ushort, object?> passengerMountedProvider;
private readonly ICallGateSubscriber<string, ushort, object?> requestHelperSubscriber;
private readonly ICallGateSubscriber<object?> dismissHelperSubscriber;
private readonly ICallGateSubscriber<string, ushort, object?> helperAvailableSubscriber;
private readonly ICallGateSubscriber<string, object?> chatMessageSubscriber;
private readonly ICallGateSubscriber<string, ushort, object?> passengerMountedSubscriber;
public event Action<string, ushort>? OnHelperRequested;
public event Action? OnHelperDismissed;
public event Action<string, ushort>? OnHelperAvailable;
public event Action<string>? OnChatMessageReceived;
public event Action<string, ushort>? OnPassengerMounted;
public MultiClientIPC(IDalamudPluginInterface pluginInterface, IPluginLog log)
{
this.pluginInterface = pluginInterface;
this.log = log;
requestHelperProvider = pluginInterface.GetIpcProvider<string, ushort, object>("QSTCompanion.RequestHelper");
dismissHelperProvider = pluginInterface.GetIpcProvider<object>("QSTCompanion.DismissHelper");
helperAvailableProvider = pluginInterface.GetIpcProvider<string, ushort, object>("QSTCompanion.HelperAvailable");
chatMessageProvider = pluginInterface.GetIpcProvider<string, object>("QSTCompanion.ChatMessage");
passengerMountedProvider = pluginInterface.GetIpcProvider<string, ushort, object>("QSTCompanion.PassengerMounted");
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");
requestHelperProvider.RegisterFunc(delegate(string name, ushort worldId)
{
OnRequestHelperReceived(name, worldId);
return (object?)null;
});
dismissHelperProvider.RegisterFunc(delegate
{
OnDismissHelperReceived();
return (object?)null;
});
helperAvailableProvider.RegisterFunc(delegate(string name, ushort worldId)
{
OnHelperAvailableReceived(name, worldId);
return (object?)null;
});
chatMessageProvider.RegisterFunc(delegate(string message)
{
OnChatMessageReceivedInternal(message);
return (object?)null;
});
passengerMountedProvider.RegisterFunc(delegate(string questerName, ushort questerWorld)
{
OnPassengerMountedReceived(questerName, questerWorld);
return (object?)null;
});
log.Information("[MultiClientIPC] ✅ IPC initialized successfully");
}
public void RequestHelper(string characterName, ushort worldId)
{
try
{
log.Information($"[MultiClientIPC] Broadcasting helper request: {characterName}@{worldId}");
requestHelperSubscriber.InvokeFunc(characterName, worldId);
}
catch (Exception ex)
{
log.Error("[MultiClientIPC] Failed to send helper request: " + ex.Message);
}
}
public void DismissHelper()
{
try
{
log.Information("[MultiClientIPC] Broadcasting helper dismiss");
dismissHelperSubscriber.InvokeFunc();
}
catch (Exception ex)
{
log.Error("[MultiClientIPC] Failed to send helper dismiss: " + ex.Message);
}
}
private void OnRequestHelperReceived(string characterName, ushort worldId)
{
try
{
log.Information($"[MultiClientIPC] Received helper request: {characterName}@{worldId}");
this.OnHelperRequested?.Invoke(characterName, worldId);
}
catch (Exception ex)
{
log.Error("[MultiClientIPC] Error handling helper request: " + ex.Message);
}
}
private void OnDismissHelperReceived()
{
try
{
log.Information("[MultiClientIPC] Received helper dismiss");
this.OnHelperDismissed?.Invoke();
}
catch (Exception ex)
{
log.Error("[MultiClientIPC] Error handling helper dismiss: " + ex.Message);
}
}
public void AnnounceHelperAvailable(string characterName, ushort worldId)
{
try
{
log.Information($"[MultiClientIPC] Broadcasting helper availability: {characterName}@{worldId}");
helperAvailableSubscriber.InvokeFunc(characterName, worldId);
}
catch (Exception ex)
{
log.Error("[MultiClientIPC] Failed to announce helper: " + ex.Message);
}
}
private void OnHelperAvailableReceived(string characterName, ushort worldId)
{
try
{
log.Information($"[MultiClientIPC] Received helper available: {characterName}@{worldId}");
this.OnHelperAvailable?.Invoke(characterName, worldId);
}
catch (Exception ex)
{
log.Error("[MultiClientIPC] Error handling helper available: " + ex.Message);
}
}
public void SendChatMessage(string message)
{
try
{
log.Information("[MultiClientIPC] Broadcasting chat message: " + message);
chatMessageSubscriber.InvokeFunc(message);
}
catch (Exception ex)
{
log.Error("[MultiClientIPC] Failed to send chat message: " + ex.Message);
}
}
private void OnChatMessageReceivedInternal(string message)
{
try
{
log.Information("[MultiClientIPC] Received chat message: " + message);
this.OnChatMessageReceived?.Invoke(message);
}
catch (Exception ex)
{
log.Error("[MultiClientIPC] Error handling chat message: " + ex.Message);
}
}
public void SendPassengerMounted(string questerName, ushort questerWorld)
{
try
{
log.Information($"[MultiClientIPC] Broadcasting passenger mounted: {questerName}@{questerWorld}");
passengerMountedSubscriber.InvokeFunc(questerName, questerWorld);
}
catch (Exception ex)
{
log.Error("[MultiClientIPC] Failed to send passenger mounted: " + ex.Message);
}
}
private void OnPassengerMountedReceived(string questerName, ushort questerWorld)
{
try
{
log.Information($"[MultiClientIPC] Received passenger mounted: {questerName}@{questerWorld}");
this.OnPassengerMounted?.Invoke(questerName, questerWorld);
}
catch (Exception ex)
{
log.Error("[MultiClientIPC] Error handling passenger mounted: " + ex.Message);
}
}
public void Dispose()
{
try
{
requestHelperProvider.UnregisterFunc();
dismissHelperProvider.UnregisterFunc();
helperAvailableProvider.UnregisterFunc();
chatMessageProvider.UnregisterFunc();
}
catch (Exception ex)
{
log.Error("[MultiClientIPC] Error during dispose: " + ex.Message);
}
}
}

View file

@ -0,0 +1,148 @@
using System;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Component.GUI;
namespace QuestionableCompanion.Services;
public class PartyInviteAutoAccept : IDisposable
{
private readonly IPluginLog log;
private readonly IFramework framework;
private readonly IGameGui gameGui;
private readonly IPartyList partyList;
private readonly Configuration configuration;
private bool shouldAutoAccept;
private DateTime autoAcceptUntil = DateTime.MinValue;
public PartyInviteAutoAccept(IPluginLog log, IFramework framework, IGameGui gameGui, IPartyList partyList, Configuration configuration)
{
this.log = log;
this.framework = framework;
this.gameGui = gameGui;
this.partyList = partyList;
this.configuration = configuration;
framework.Update += OnFrameworkUpdate;
log.Information("[PartyInviteAutoAccept] Initialized");
}
public void EnableAutoAccept()
{
if (!configuration.IsHighLevelHelper && !configuration.IsQuester)
{
log.Debug("[PartyInviteAutoAccept] Not a helper or quester, ignoring auto-accept request");
return;
}
shouldAutoAccept = true;
autoAcceptUntil = DateTime.Now.AddSeconds(30.0);
string role = (configuration.IsHighLevelHelper ? "Helper" : "Quester");
log.Information("[PartyInviteAutoAccept] Auto-accept enabled for 30 seconds (" + role + ")");
log.Information($"[PartyInviteAutoAccept] Will accept until: {autoAcceptUntil:HH:mm:ss}");
log.Information("[PartyInviteAutoAccept] Will accept ALL party invites during this time!");
}
private unsafe void OnFrameworkUpdate(IFramework framework)
{
if (!shouldAutoAccept)
{
return;
}
if (DateTime.Now > autoAcceptUntil)
{
shouldAutoAccept = false;
log.Information("[PartyInviteAutoAccept] Auto-accept window expired");
return;
}
try
{
string[] obj = new string[6] { "SelectYesno", "SelectYesNo", "_PartyInvite", "PartyInvite", "SelectString", "_Notification" };
nint addonPtr = IntPtr.Zero;
string[] array = obj;
foreach (string name in array)
{
addonPtr = (nint)gameGui.GetAddonByName(name);
if (addonPtr != IntPtr.Zero)
{
break;
}
}
if (addonPtr == IntPtr.Zero)
{
if (DateTime.Now.Second % 5 != 0)
{
return;
}
log.Debug($"[PartyInviteAutoAccept] Still waiting for party invite dialog... ({(autoAcceptUntil - DateTime.Now).TotalSeconds:F0}s remaining)");
if (DateTime.Now.Second % 10 != 0)
{
return;
}
log.Warning("[PartyInviteAutoAccept] === DUMPING ALL VISIBLE ADDONS ===");
RaptureAtkUnitManager* atkStage = RaptureAtkUnitManager.Instance();
if (atkStage != null)
{
AtkUnitManager* unitManager = &atkStage->AtkUnitManager;
for (int j = 0; j < unitManager->AllLoadedUnitsList.Count; j++)
{
AtkUnitBase* addon = unitManager->AllLoadedUnitsList.Entries[j].Value;
if (addon != null && addon->IsVisible)
{
string name2 = addon->NameString;
log.Warning("[PartyInviteAutoAccept] Visible addon: " + name2);
}
}
}
log.Warning("[PartyInviteAutoAccept] === END ADDON DUMP ===");
}
else
{
AtkUnitBase* addon2 = (AtkUnitBase*)addonPtr;
if (addon2 == null)
{
log.Warning("[PartyInviteAutoAccept] Addon pointer is null!");
return;
}
if (!addon2->IsVisible)
{
log.Debug("[PartyInviteAutoAccept] Addon exists but not visible yet");
return;
}
AtkValue* values = stackalloc AtkValue[1];
*values = new AtkValue
{
Type = FFXIVClientStructs.FFXIV.Component.GUI.ValueType.Int,
Int = 0
};
addon2->FireCallback(1u, values);
AtkValue* values2 = stackalloc AtkValue[2];
*values2 = new AtkValue
{
Type = FFXIVClientStructs.FFXIV.Component.GUI.ValueType.Int,
Int = 0
};
values2[1] = new AtkValue
{
Type = FFXIVClientStructs.FFXIV.Component.GUI.ValueType.UInt,
UInt = 0u
};
addon2->FireCallback(2u, values2);
}
}
catch (Exception ex)
{
log.Error("[PartyInviteAutoAccept] Error: " + ex.Message);
log.Error("[PartyInviteAutoAccept] Stack: " + ex.StackTrace);
}
}
public void Dispose()
{
framework.Update -= OnFrameworkUpdate;
}
}

View file

@ -0,0 +1,193 @@
using System;
using System.Text;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game.Group;
using FFXIVClientStructs.FFXIV.Client.System.String;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Client.UI.Info;
using FFXIVClientStructs.FFXIV.Client.UI.Shell;
namespace QuestionableCompanion.Services;
public class PartyInviteService
{
private readonly IPluginLog log;
private readonly IObjectTable objectTable;
private readonly IClientState clientState;
public PartyInviteService(IPluginLog log, IObjectTable objectTable, IClientState clientState)
{
this.log = log;
this.objectTable = objectTable;
this.clientState = clientState;
}
public unsafe bool InviteToParty(string characterName, ushort worldId)
{
if (string.IsNullOrWhiteSpace(characterName))
{
log.Error("[PartyInvite] Character name is null or empty!");
return false;
}
if (worldId == 0)
{
log.Error("[PartyInvite] World ID is 0 (invalid)!");
return false;
}
characterName = characterName.Trim();
try
{
InfoModule* infoModule = InfoModule.Instance();
if (infoModule == null)
{
log.Error("[PartyInvite] InfoModule is null!");
return false;
}
InfoProxyPartyInvite* partyInviteProxy = (InfoProxyPartyInvite*)infoModule->GetInfoProxyById(InfoProxyId.PartyInvite);
if (partyInviteProxy == null)
{
log.Error("[PartyInvite] InfoProxyPartyInvite is null!");
return false;
}
ulong contentId = 0uL;
log.Information($"[PartyInvite] Using name-based invite (ContentId=0, Name={characterName}, World={worldId})");
log.Information($"[PartyInvite] Sending invite to {characterName}@{worldId} (ContentId: {contentId})");
fixed (byte* namePtr = Encoding.UTF8.GetBytes(characterName + "\0"))
{
bool num = partyInviteProxy->InviteToParty(contentId, namePtr, worldId);
if (num)
{
log.Information($"[PartyInvite] ✓ Successfully sent invite to {characterName}@{worldId}");
}
else
{
log.Warning($"[PartyInvite] ✗ Failed to send invite to {characterName}@{worldId}");
}
return num;
}
}
catch (Exception ex)
{
log.Error("[PartyInvite] Exception: " + ex.Message);
log.Error("[PartyInvite] StackTrace: " + ex.StackTrace);
return false;
}
}
public unsafe bool InviteToPartyByContentId(ulong contentId, ushort worldId)
{
try
{
InfoModule* infoModule = InfoModule.Instance();
if (infoModule == null)
{
log.Error("[PartyInvite] InfoModule is null!");
return false;
}
InfoProxyPartyInvite* partyInviteProxy = (InfoProxyPartyInvite*)infoModule->GetInfoProxyById(InfoProxyId.PartyInvite);
if (partyInviteProxy == null)
{
log.Error("[PartyInvite] InfoProxyPartyInvite is null!");
return false;
}
log.Information($"[PartyInvite] Sending invite to ContentID {contentId}@{worldId}");
bool num = partyInviteProxy->InviteToPartyContentId(contentId, worldId);
if (num)
{
log.Information($"[PartyInvite] Successfully sent invite to ContentID {contentId}@{worldId}");
}
else
{
log.Warning($"[PartyInvite] Failed to send invite to ContentID {contentId}@{worldId}");
}
return num;
}
catch (Exception ex)
{
log.Error("[PartyInvite] Exception: " + ex.Message);
log.Error("[PartyInvite] StackTrace: " + ex.StackTrace);
return false;
}
}
public unsafe bool InviteToPartyInInstanceByContentId(ulong contentId)
{
try
{
InfoModule* infoModule = InfoModule.Instance();
if (infoModule == null)
{
log.Error("[PartyInvite] InfoModule is null!");
return false;
}
InfoProxyPartyInvite* partyInviteProxy = (InfoProxyPartyInvite*)infoModule->GetInfoProxyById(InfoProxyId.PartyInvite);
if (partyInviteProxy == null)
{
log.Error("[PartyInvite] InfoProxyPartyInvite is null!");
return false;
}
log.Information($"[PartyInvite] Sending instance invite to ContentID {contentId}");
bool num = partyInviteProxy->InviteToPartyInInstanceByContentId(contentId);
if (num)
{
log.Information($"[PartyInvite] Successfully sent instance invite to ContentID {contentId}");
}
else
{
log.Warning($"[PartyInvite] Failed to send instance invite to ContentID {contentId}");
}
return num;
}
catch (Exception ex)
{
log.Error("[PartyInvite] Exception: " + ex.Message);
log.Error("[PartyInvite] StackTrace: " + ex.StackTrace);
return false;
}
}
public unsafe bool LeaveParty()
{
try
{
GroupManager* groupManager = GroupManager.Instance();
if (groupManager == null)
{
log.Error("[PartyInvite] GroupManager is null!");
return false;
}
GroupManager.Group* group = groupManager->GetGroup();
if (group == null || group->MemberCount == 0)
{
log.Debug("[PartyInvite] Not in a party");
return true;
}
log.Information($"[PartyInvite] Leaving party (Members: {group->MemberCount})");
RaptureShellModule* shellModule = RaptureShellModule.Instance();
if (shellModule == null)
{
log.Error("[PartyInvite] RaptureShellModule is null!");
return false;
}
UIModule* uiModule = UIModule.Instance();
if (uiModule == null)
{
log.Error("[PartyInvite] UIModule is null!");
return false;
}
Utf8String* leaveCommand = Utf8String.FromString("/leave");
shellModule->ExecuteCommandInner(leaveCommand, uiModule);
leaveCommand->Dtor();
log.Information("[PartyInvite] Leave command executed successfully");
return true;
}
catch (Exception ex)
{
log.Error("[PartyInvite] Exception: " + ex.Message);
log.Error("[PartyInvite] StackTrace: " + ex.StackTrace);
return false;
}
}
}

View file

@ -0,0 +1,39 @@
using System;
using Dalamud.Plugin.Services;
namespace QuestionableCompanion.Services;
public class PluginLogger
{
private readonly IPluginLog dalamudLog;
public PluginLogger(IPluginLog dalamudLog)
{
this.dalamudLog = dalamudLog;
}
public void Debug(string message, string component = "Plugin")
{
dalamudLog.Debug(message);
}
public void Information(string message, string component = "Plugin")
{
dalamudLog.Information(message);
}
public void Warning(string message, string component = "Plugin")
{
dalamudLog.Warning(message);
}
public void Error(string message, string component = "Plugin")
{
dalamudLog.Error(message);
}
public void Error(Exception ex, string message, string component = "Plugin")
{
dalamudLog.Error(ex, message);
}
}

View file

@ -0,0 +1,256 @@
using System;
using System.Collections.Generic;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Application.Network.WorkDefinitions;
using FFXIVClientStructs.FFXIV.Client.Game;
namespace QuestionableCompanion.Services;
public class QuestDetectionService : IDisposable
{
private readonly IFramework framework;
private readonly IPluginLog log;
private readonly IClientState clientState;
private readonly HashSet<uint> acceptedQuests = new HashSet<uint>();
private readonly HashSet<uint> completedQuests = new HashSet<uint>();
private HashSet<uint> completedQuestCache = new HashSet<uint>();
private DateTime lastCacheRefresh = DateTime.MinValue;
private const int CACHE_REFRESH_MINUTES = 5;
public event Action<uint, string>? QuestAccepted;
public event Action<uint, string>? QuestCompleted;
public QuestDetectionService(IFramework framework, IPluginLog log, IClientState clientState)
{
this.framework = framework;
this.log = log;
this.clientState = clientState;
framework.Update += OnFrameworkUpdate;
log.Information("[QuestDetection] Service initialized");
}
private void OnFrameworkUpdate(IFramework framework)
{
if (!clientState.IsLoggedIn)
{
return;
}
try
{
CheckQuestUpdates();
}
catch (Exception ex)
{
log.Debug("[QuestDetection] Error in framework update: " + ex.Message);
}
}
private unsafe void CheckQuestUpdates()
{
QuestManager* questManager = QuestManager.Instance();
if (questManager == null)
{
log.Debug("[QuestDetection] QuestManager instance is null");
return;
}
try
{
Span<QuestWork> normalQuests = questManager->NormalQuests;
if (normalQuests.Length == 0)
{
log.Debug("[QuestDetection] NormalQuests array is empty");
return;
}
int maxSlots = Math.Min(normalQuests.Length, 30);
for (int i = 0; i < maxSlots; i++)
{
try
{
QuestWork quest = normalQuests[i];
if (quest.QuestId == 0)
{
continue;
}
uint questId = quest.QuestId;
if (!acceptedQuests.Contains(questId))
{
if (!IsQuestComplete(questId))
{
acceptedQuests.Add(questId);
string questName = GetQuestName(questId);
log.Information($"[QuestDetection] Quest Accepted: {questId} - {questName}");
this.QuestAccepted?.Invoke(questId, questName);
}
}
else if (!completedQuests.Contains(questId) && IsQuestComplete(questId))
{
completedQuests.Add(questId);
string questName2 = GetQuestName(questId);
log.Information($"[QuestDetection] Quest Completed: {questId} - {questName2}");
this.QuestCompleted?.Invoke(questId, questName2);
}
}
catch (IndexOutOfRangeException)
{
log.Debug($"[QuestDetection] Index {i} out of range, stopping quest check");
break;
}
catch (Exception ex2)
{
log.Debug($"[QuestDetection] Error checking quest slot {i}: {ex2.Message}");
}
}
}
catch (Exception ex3)
{
log.Warning("[QuestDetection] Error accessing quest data: " + ex3.Message);
}
}
private bool IsQuestComplete(uint questId)
{
try
{
return QuestManager.IsQuestComplete(questId);
}
catch
{
return false;
}
}
public unsafe bool IsQuestCompletedDirect(uint questId)
{
try
{
if (QuestManager.Instance() == null)
{
log.Warning("[QuestDetection] QuestManager instance not available");
return false;
}
bool isComplete = QuestManager.IsQuestComplete(questId);
log.Debug($"[QuestDetection] Quest {questId} completion status: {isComplete}");
return isComplete;
}
catch (Exception ex)
{
log.Error($"[QuestDetection] Failed to check quest {questId}: {ex.Message}");
return false;
}
}
public unsafe List<uint> GetAllCompletedQuestIds()
{
List<uint> completed = new List<uint>();
try
{
if (QuestManager.Instance() == null)
{
log.Warning("[QuestDetection] QuestManager instance not available");
return completed;
}
log.Information("[QuestDetection] Scanning for completed quests...");
foreach (var item in new List<(uint, uint)>
{
(1u, 3000u),
(65000u, 71000u)
})
{
uint start = item.Item1;
uint end = item.Item2;
for (uint i = start; i <= end; i++)
{
try
{
if (QuestManager.IsQuestComplete(i))
{
completed.Add(i);
}
}
catch
{
}
}
}
log.Information($"[QuestDetection] Retrieved {completed.Count} completed quests");
}
catch (Exception ex)
{
log.Error("[QuestDetection] Error while fetching completed quests: " + ex.Message);
}
return completed;
}
public void RefreshQuestCache()
{
try
{
log.Information("[QuestDetection] Refreshing quest cache...");
List<uint> allCompleted = GetAllCompletedQuestIds();
completedQuestCache = new HashSet<uint>(allCompleted);
lastCacheRefresh = DateTime.Now;
log.Information($"[QuestDetection] Quest cache refreshed with {completedQuestCache.Count} completed quests");
}
catch (Exception ex)
{
log.Error("[QuestDetection] Failed to refresh quest cache: " + ex.Message);
}
}
public bool IsQuestCompletedCached(uint questId)
{
if (completedQuestCache.Count == 0 || (DateTime.Now - lastCacheRefresh).TotalMinutes > 5.0)
{
RefreshQuestCache();
}
return completedQuestCache.Contains(questId);
}
private string GetQuestName(uint questId)
{
try
{
return $"Quest {questId}";
}
catch
{
return $"Quest {questId}";
}
}
public void ResetTracking()
{
acceptedQuests.Clear();
completedQuests.Clear();
completedQuestCache.Clear();
lastCacheRefresh = DateTime.MinValue;
log.Information("[QuestDetection] Tracking reset");
}
public bool IsQuestAccepted(uint questId)
{
return acceptedQuests.Contains(questId);
}
public bool IsQuestCompleted(uint questId)
{
return completedQuests.Contains(questId);
}
public void Dispose()
{
framework.Update -= OnFrameworkUpdate;
acceptedQuests.Clear();
completedQuests.Clear();
completedQuestCache.Clear();
log.Information("[QuestDetection] Service disposed");
}
}

View file

@ -0,0 +1,68 @@
using System.Text.RegularExpressions;
namespace QuestionableCompanion.Services;
public static class QuestIdParser
{
private static readonly Regex EventQuestPattern = new Regex("^([A-Z])(\\d+)$", RegexOptions.Compiled);
public static (string rawId, string eventQuestId) ParseQuestId(string questInput)
{
if (string.IsNullOrWhiteSpace(questInput))
{
return (rawId: questInput, eventQuestId: questInput);
}
Match match = EventQuestPattern.Match(questInput);
if (match.Success)
{
_ = match.Groups[1].Value;
return (rawId: match.Groups[2].Value, eventQuestId: questInput);
}
return (rawId: questInput, eventQuestId: questInput);
}
public static bool HasEventQuestPrefix(string questId)
{
if (string.IsNullOrWhiteSpace(questId))
{
return false;
}
return EventQuestPattern.IsMatch(questId);
}
public static string? GetEventQuestPrefix(string questId)
{
if (string.IsNullOrWhiteSpace(questId))
{
return null;
}
Match match = EventQuestPattern.Match(questId);
if (!match.Success)
{
return null;
}
return match.Groups[1].Value;
}
public static string GetNumericPart(string questId)
{
return ParseQuestId(questId).rawId;
}
public static QuestIdType ClassifyQuestId(string questId)
{
if (string.IsNullOrWhiteSpace(questId))
{
return QuestIdType.Invalid;
}
if (HasEventQuestPrefix(questId))
{
return QuestIdType.EventQuest;
}
if (uint.TryParse(questId, out var _))
{
return QuestIdType.Standard;
}
return QuestIdType.Unknown;
}
}

View file

@ -0,0 +1,9 @@
namespace QuestionableCompanion.Services;
public enum QuestIdType
{
Standard,
EventQuest,
Unknown,
Invalid
}

View file

@ -0,0 +1,301 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game;
using Newtonsoft.Json;
namespace QuestionableCompanion.Services;
public class QuestPreCheckService : IDisposable
{
private readonly IPluginLog log;
private readonly IClientState clientState;
private readonly Configuration config;
private readonly AutoRetainerIPC autoRetainerIPC;
private Dictionary<string, bool> preCheckResults = new Dictionary<string, bool>();
private Dictionary<string, Dictionary<uint, bool>> questDatabase = new Dictionary<string, Dictionary<uint, bool>>();
private Dictionary<string, DateTime> lastRefreshByCharacter = new Dictionary<string, DateTime>();
private readonly TimeSpan refreshInterval = TimeSpan.FromMinutes(30L);
private string QuestDatabasePath
{
get
{
global::_003C_003Ey__InlineArray5<string> buffer = default(global::_003C_003Ey__InlineArray5<string>);
global::_003CPrivateImplementationDetails_003E.InlineArrayElementRef<global::_003C_003Ey__InlineArray5<string>, string>(ref buffer, 0) = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
global::_003CPrivateImplementationDetails_003E.InlineArrayElementRef<global::_003C_003Ey__InlineArray5<string>, string>(ref buffer, 1) = "XIVLauncher";
global::_003CPrivateImplementationDetails_003E.InlineArrayElementRef<global::_003C_003Ey__InlineArray5<string>, string>(ref buffer, 2) = "pluginConfigs";
global::_003CPrivateImplementationDetails_003E.InlineArrayElementRef<global::_003C_003Ey__InlineArray5<string>, string>(ref buffer, 3) = "QuestionableCompanion";
global::_003CPrivateImplementationDetails_003E.InlineArrayElementRef<global::_003C_003Ey__InlineArray5<string>, string>(ref buffer, 4) = "QuestDatabase.json";
return Path.Combine(global::_003CPrivateImplementationDetails_003E.InlineArrayAsReadOnlySpan<global::_003C_003Ey__InlineArray5<string>, string>(in buffer, 5));
}
}
public QuestPreCheckService(IPluginLog log, IClientState clientState, Configuration config, AutoRetainerIPC autoRetainerIPC)
{
this.log = log;
this.clientState = clientState;
this.config = config;
this.autoRetainerIPC = autoRetainerIPC;
LoadQuestDatabase();
}
private void LoadQuestDatabase()
{
try
{
EnsureQuestDatabasePath();
if (!File.Exists(QuestDatabasePath))
{
log.Information("[QuestPreCheck] Creating new quest database...");
questDatabase = new Dictionary<string, Dictionary<uint, bool>>();
return;
}
string json = File.ReadAllText(QuestDatabasePath);
if (string.IsNullOrEmpty(json))
{
questDatabase = new Dictionary<string, Dictionary<uint, bool>>();
return;
}
questDatabase = JsonConvert.DeserializeObject<Dictionary<string, Dictionary<uint, bool>>>(json) ?? new Dictionary<string, Dictionary<uint, bool>>();
log.Information($"[QuestPreCheck] Loaded quest database for {questDatabase.Count} characters");
}
catch (Exception ex)
{
log.Error("[QuestPreCheck] Error loading quest database: " + ex.Message);
questDatabase = new Dictionary<string, Dictionary<uint, bool>>();
}
}
private void SaveQuestDatabase()
{
try
{
EnsureQuestDatabasePath();
string json = JsonConvert.SerializeObject(questDatabase, Formatting.Indented);
File.WriteAllText(QuestDatabasePath, json);
log.Information($"[QuestPreCheck] Quest database saved ({questDatabase.Count} characters)");
}
catch (Exception ex)
{
log.Error("[QuestPreCheck] Error saving quest database: " + ex.Message);
}
}
private void EnsureQuestDatabasePath()
{
string directory = Path.GetDirectoryName(QuestDatabasePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
}
public unsafe void ScanCurrentCharacterQuestStatus(bool verbose = false)
{
if (clientState.LocalPlayer == null)
{
log.Warning("[QuestPreCheck] No local player found");
return;
}
string worldName = clientState.LocalPlayer.HomeWorld.Value.Name.ToString();
string charName = $"{clientState.LocalPlayer.Name}@{worldName}";
if (verbose)
{
log.Information("[QuestPreCheck] Scanning quest status for: " + charName);
}
if (!questDatabase.ContainsKey(charName))
{
questDatabase[charName] = new Dictionary<uint, bool>();
}
if (QuestManager.Instance() == null)
{
log.Error("[QuestPreCheck] QuestManager not available");
return;
}
int questsScanned = 0;
int questsCompleted = 0;
int questsChanged = 0;
List<uint> newlyCompleted = new List<uint>();
List<uint> questsToScan = config.QuestPreCheckRange ?? new List<uint>();
if (questsToScan.Count == 0)
{
for (uint questId = 1u; questId <= 4500; questId++)
{
questsToScan.Add(questId);
}
}
foreach (uint questId2 in questsToScan)
{
try
{
bool num = QuestManager.IsQuestComplete((ushort)(questId2 % 65536));
questsScanned++;
if (num)
{
questsCompleted++;
if (!questDatabase[charName].GetValueOrDefault(questId2, defaultValue: false))
{
questDatabase[charName][questId2] = true;
questsChanged++;
newlyCompleted.Add(questId2);
if (verbose)
{
log.Debug($"[QuestPreCheck] {charName} - Quest {questId2}: ✓ NEWLY COMPLETED");
}
}
else
{
questDatabase[charName][questId2] = true;
}
}
if (verbose && questId2 % 500 == 0)
{
log.Debug($"[QuestPreCheck] Progress: {questId2}/{questsToScan.Count} quests scanned...");
}
}
catch (Exception ex)
{
log.Error($"[QuestPreCheck] Error checking quest {questId2}: {ex.Message}");
}
}
if (verbose)
{
log.Information($"[QuestPreCheck] Scan complete: {questsScanned} checked, {questsCompleted} completed, {questsChanged} changed");
if (newlyCompleted.Count > 0)
{
log.Information("[QuestPreCheck] NEWLY COMPLETED: " + string.Join(", ", newlyCompleted));
}
}
lastRefreshByCharacter[charName] = DateTime.Now;
SaveQuestDatabase();
}
public void RefreshQuestDatabasePeriodic()
{
if (clientState.LocalPlayer != null && clientState.IsLoggedIn)
{
string worldName = clientState.LocalPlayer.HomeWorld.Value.Name.ToString();
string charName = $"{clientState.LocalPlayer.Name}@{worldName}";
if (!lastRefreshByCharacter.TryGetValue(charName, out var lastRefresh) || DateTime.Now - lastRefresh >= refreshInterval)
{
log.Information("[QuestDB] === 30-MINUTE REFRESH TRIGGERED ===");
log.Information("[QuestDB] Updating quest status for: " + charName);
ScanCurrentCharacterQuestStatus(verbose: true);
log.Information("[QuestDB] === 30-MINUTE REFRESH COMPLETE ===");
}
}
}
public void LogCompletedQuestsBeforeLogout()
{
if (clientState.LocalPlayer != null)
{
string worldName = clientState.LocalPlayer.HomeWorld.Value.Name.ToString();
string charName = $"{clientState.LocalPlayer.Name}@{worldName}";
log.Information("[QuestDB] Logging final quest status before logout: " + charName);
ScanCurrentCharacterQuestStatus();
log.Information("[QuestDB] Final quest state saved for: " + charName);
}
}
public Dictionary<string, bool> PerformPreRotationCheck(uint stopQuestId, List<string> characters)
{
log.Information("[QuestPreCheck] === STARTING PRE-ROTATION QUEST VERIFICATION ===");
log.Information($"[QuestPreCheck] Checking {characters.Count} characters for quest {stopQuestId}...");
preCheckResults.Clear();
foreach (string character in characters)
{
try
{
if (questDatabase.ContainsKey(character) && questDatabase[character].ContainsKey(stopQuestId))
{
bool isCompleted = questDatabase[character][stopQuestId];
preCheckResults[character] = isCompleted;
string status = (isCompleted ? "✓ COMPLETED" : "○ PENDING");
log.Information($"[QuestPreCheck] {character}: {status} (from database)");
}
else
{
log.Debug("[QuestPreCheck] " + character + ": Not in database, will check during rotation");
preCheckResults[character] = false;
}
}
catch (Exception ex)
{
log.Error("[QuestPreCheck] Error checking " + character + ": " + ex.Message);
preCheckResults[character] = false;
}
}
log.Information("[QuestPreCheck] === PRE-ROTATION CHECK COMPLETE ===");
return preCheckResults;
}
public bool ShouldSkipCharacter(string characterName, uint questId)
{
if (preCheckResults.TryGetValue(characterName, out var isCompleted) && isCompleted)
{
log.Information($"[QuestPreCheck] Character {characterName} already completed quest {questId} - SKIPPING");
return true;
}
bool completed = default(bool);
if (questDatabase.TryGetValue(characterName, out Dictionary<uint, bool> quests) && quests.TryGetValue(questId, out completed) && completed)
{
log.Information($"[QuestPreCheck] Character {characterName} already completed quest {questId} (from DB) - SKIPPING");
return true;
}
return false;
}
public bool? GetQuestStatus(string characterName, uint questId)
{
if (questDatabase.TryGetValue(characterName, out Dictionary<uint, bool> quests) && quests.TryGetValue(questId, out var isCompleted))
{
return isCompleted;
}
return null;
}
public List<uint> GetCompletedQuests(string characterName)
{
if (!questDatabase.TryGetValue(characterName, out Dictionary<uint, bool> quests))
{
return new List<uint>();
}
return (from kvp in quests
where kvp.Value
select kvp.Key).ToList();
}
public void MarkQuestCompleted(string characterName, uint questId)
{
if (!questDatabase.ContainsKey(characterName))
{
questDatabase[characterName] = new Dictionary<uint, bool>();
}
questDatabase[characterName][questId] = true;
SaveQuestDatabase();
log.Information($"[QuestPreCheck] Marked quest {questId} as completed for {characterName}");
}
public void ClearPreCheckResults()
{
preCheckResults.Clear();
log.Information("[QuestPreCheck] Pre-check results cleared");
}
public void Dispose()
{
SaveQuestDatabase();
log.Information("[QuestPreCheck] Service disposed");
}
}

View file

@ -0,0 +1,87 @@
using System;
using System.Collections.Generic;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game;
namespace QuestionableCompanion.Services;
public class QuestTrackingService : IDisposable
{
private readonly IPluginLog log;
private readonly Dictionary<string, HashSet<uint>> characterQuestCache = new Dictionary<string, HashSet<uint>>();
public QuestTrackingService(IPluginLog log)
{
this.log = log;
}
public HashSet<uint> GetCharacterCompletedQuests(string characterName)
{
if (string.IsNullOrEmpty(characterName))
{
return new HashSet<uint>();
}
if (characterQuestCache.TryGetValue(characterName, out HashSet<uint> quests))
{
return quests;
}
return new HashSet<uint>();
}
public void UpdateCurrentCharacterQuests(string characterName)
{
if (string.IsNullOrEmpty(characterName))
{
return;
}
try
{
HashSet<uint> completedQuests = new HashSet<uint>();
for (uint questId = 66000u; questId < 72000; questId++)
{
try
{
if (QuestManager.IsQuestComplete(questId))
{
completedQuests.Add(questId);
}
}
catch
{
}
}
characterQuestCache[characterName] = completedQuests;
}
catch (Exception ex)
{
log.Error("[QuestTracking] Failed to update quests for " + characterName + ": " + ex.Message);
}
}
public bool IsQuestCompleted(string characterName, uint questId)
{
return GetCharacterCompletedQuests(characterName).Contains(questId);
}
public void ClearCharacterCache(string characterName)
{
if (characterQuestCache.ContainsKey(characterName))
{
characterQuestCache.Remove(characterName);
log.Debug("[QuestTracking] Cleared cache for " + characterName);
}
}
public void ClearAllCache()
{
characterQuestCache.Clear();
log.Information("[QuestTracking] Cleared all cached quest data");
}
public void Dispose()
{
characterQuestCache.Clear();
log.Information("[QuestTracking] Service disposed");
}
}

View file

@ -0,0 +1,959 @@
using System;
using System.Collections.Generic;
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using Dalamud.Plugin.Services;
namespace QuestionableCompanion.Services;
public class QuestionableIPC : IDisposable
{
private readonly IDalamudPluginInterface pluginInterface;
private readonly IPluginLog log;
private ICallGateSubscriber<string, bool>? importQuestPrioritySubscriber;
private ICallGateSubscriber<string?>? getCurrentQuestIdSubscriber;
private ICallGateSubscriber<StepData?>? getCurrentStepDataSubscriber;
private ICallGateSubscriber<bool>? isRunningSubscriber;
private ICallGateSubscriber<object?>? getCurrentTaskSubscriber;
private ICallGateSubscriber<string, bool>? isQuestCompleteSubscriber;
private ICallGateSubscriber<string, bool>? isReadyToAcceptQuestSubscriber;
private ICallGateSubscriber<string, bool>? addQuestPrioritySubscriber;
private ICallGateSubscriber<bool>? clearQuestPrioritySubscriber;
private ICallGateSubscriber<List<string>>? getCurrentlyActiveEventQuestsSubscriber;
private ICallGateSubscriber<int>? getAlliedSocietyRemainingAllowancesSubscriber;
private ICallGateSubscriber<byte, List<string>>? getAlliedSocietyAvailableQuestIdsSubscriber;
private ICallGateSubscriber<Dictionary<byte, int>>? getAlliedSocietyAllAvailableQuestCountsSubscriber;
private ICallGateSubscriber<byte, bool>? getAlliedSocietyIsMaxRankSubscriber;
private ICallGateSubscriber<byte, int>? getAlliedSocietyCurrentRankSubscriber;
private ICallGateSubscriber<List<byte>>? getAlliedSocietiesWithAvailableQuestsSubscriber;
private ICallGateSubscriber<byte, int>? addAlliedSocietyOptimalQuestsSubscriber;
private ICallGateSubscriber<byte, List<string>>? getAlliedSocietyOptimalQuestsSubscriber;
private ICallGateSubscriber<long>? getAlliedSocietyTimeUntilResetSubscriber;
private ICallGateSubscriber<bool>? getStopConditionsEnabledSubscriber;
private ICallGateSubscriber<List<string>>? getStopQuestListSubscriber;
private ICallGateSubscriber<StopConditionData>? getLevelStopConditionSubscriber;
private ICallGateSubscriber<StopConditionData>? getSequenceStopConditionSubscriber;
private ICallGateSubscriber<string, uint, int, object?>? getQuestSequenceStopConditionSubscriber;
private ICallGateSubscriber<string, bool>? removeQuestSequenceStopConditionSubscriber;
private ICallGateSubscriber<Dictionary<string, object>>? getAllQuestSequenceStopConditionsSubscriber;
private ICallGateSubscriber<int>? getDefaultDutyModeSubscriber;
private ICallGateSubscriber<int, bool>? setDefaultDutyModeSubscriber;
private bool subscribersInitialized;
private DateTime lastAvailabilityCheck = DateTime.MinValue;
private const int AvailabilityCheckCooldownSeconds = 5;
public bool IsAvailable { get; private set; }
public QuestionableIPC(IDalamudPluginInterface pluginInterface, IPluginLog log)
{
this.pluginInterface = pluginInterface;
this.log = log;
InitializeIPC();
}
private void InitializeIPC()
{
try
{
getCurrentQuestIdSubscriber = pluginInterface.GetIpcSubscriber<string>("Questionable.GetCurrentQuestId");
getCurrentStepDataSubscriber = pluginInterface.GetIpcSubscriber<StepData>("Questionable.GetCurrentStepData");
isRunningSubscriber = pluginInterface.GetIpcSubscriber<bool>("Questionable.IsRunning");
importQuestPrioritySubscriber = pluginInterface.GetIpcSubscriber<string, bool>("Questionable.ImportQuestPriority");
getCurrentTaskSubscriber = pluginInterface.GetIpcSubscriber<object>("Questionable.GetCurrentTask");
isQuestCompleteSubscriber = pluginInterface.GetIpcSubscriber<string, bool>("Questionable.IsQuestComplete");
isReadyToAcceptQuestSubscriber = pluginInterface.GetIpcSubscriber<string, bool>("Questionable.IsReadyToAcceptQuest");
addQuestPrioritySubscriber = pluginInterface.GetIpcSubscriber<string, bool>("Questionable.AddQuestPriority");
clearQuestPrioritySubscriber = pluginInterface.GetIpcSubscriber<bool>("Questionable.ClearQuestPriority");
getCurrentlyActiveEventQuestsSubscriber = pluginInterface.GetIpcSubscriber<List<string>>("Questionable.GetCurrentlyActiveEventQuests");
getAlliedSocietyRemainingAllowancesSubscriber = pluginInterface.GetIpcSubscriber<int>("Questionable.AlliedSociety.GetRemainingAllowances");
getAlliedSocietyAvailableQuestIdsSubscriber = pluginInterface.GetIpcSubscriber<byte, List<string>>("Questionable.AlliedSociety.GetAvailableQuestIds");
getAlliedSocietyAllAvailableQuestCountsSubscriber = pluginInterface.GetIpcSubscriber<Dictionary<byte, int>>("Questionable.AlliedSociety.GetAllAvailableQuestCounts");
getAlliedSocietyIsMaxRankSubscriber = pluginInterface.GetIpcSubscriber<byte, bool>("Questionable.AlliedSociety.IsMaxRank");
getAlliedSocietyCurrentRankSubscriber = pluginInterface.GetIpcSubscriber<byte, int>("Questionable.AlliedSociety.GetCurrentRank");
getAlliedSocietiesWithAvailableQuestsSubscriber = pluginInterface.GetIpcSubscriber<List<byte>>("Questionable.AlliedSociety.GetSocietiesWithAvailableQuests");
addAlliedSocietyOptimalQuestsSubscriber = pluginInterface.GetIpcSubscriber<byte, int>("Questionable.AlliedSociety.AddOptimalQuests");
getAlliedSocietyOptimalQuestsSubscriber = pluginInterface.GetIpcSubscriber<byte, List<string>>("Questionable.AlliedSociety.GetOptimalQuests");
getAlliedSocietyTimeUntilResetSubscriber = pluginInterface.GetIpcSubscriber<long>("Questionable.AlliedSociety.GetTimeUntilReset");
getStopConditionsEnabledSubscriber = pluginInterface.GetIpcSubscriber<bool>("Questionable.GetStopConditionsEnabled");
getStopQuestListSubscriber = pluginInterface.GetIpcSubscriber<List<string>>("Questionable.GetStopQuestList");
getLevelStopConditionSubscriber = pluginInterface.GetIpcSubscriber<StopConditionData>("Questionable.GetLevelStopCondition");
getSequenceStopConditionSubscriber = pluginInterface.GetIpcSubscriber<StopConditionData>("Questionable.GetSequenceStopCondition");
getQuestSequenceStopConditionSubscriber = pluginInterface.GetIpcSubscriber<string, uint, int, object>("Questionable.GetQuestSequenceStopCondition");
removeQuestSequenceStopConditionSubscriber = pluginInterface.GetIpcSubscriber<string, bool>("Questionable.RemoveQuestSequenceStopCondition");
getAllQuestSequenceStopConditionsSubscriber = pluginInterface.GetIpcSubscriber<Dictionary<string, object>>("Questionable.GetAllQuestSequenceStopConditions");
getDefaultDutyModeSubscriber = pluginInterface.GetIpcSubscriber<int>("Questionable.GetDefaultDutyMode");
setDefaultDutyModeSubscriber = pluginInterface.GetIpcSubscriber<int, bool>("Questionable.SetDefaultDutyMode");
subscribersInitialized = true;
log.Debug("[QuestionableIPC] IPC subscribers initialized (lazy-loading enabled)");
}
catch (Exception ex)
{
IsAvailable = false;
subscribersInitialized = false;
log.Error("[QuestionableIPC] Failed to initialize subscribers: " + ex.Message);
}
}
private bool TryEnsureAvailable()
{
if (IsAvailable)
{
return true;
}
if (!subscribersInitialized)
{
log.Warning("[QuestionableIPC] Subscribers not initialized!");
return false;
}
DateTime now = DateTime.Now;
if ((now - lastAvailabilityCheck).TotalSeconds < 5.0)
{
return false;
}
lastAvailabilityCheck = now;
try
{
if (isRunningSubscriber == null)
{
log.Error("[QuestionableIPC] isRunningSubscriber is NULL!");
return false;
}
isRunningSubscriber.InvokeFunc();
if (!IsAvailable)
{
IsAvailable = true;
}
return true;
}
catch (Exception ex)
{
log.Error("[QuestionableIPC] Failed to connect to Questionable:");
log.Error("[QuestionableIPC] Exception Type: " + ex.GetType().Name);
log.Error("[QuestionableIPC] Message: " + ex.Message);
log.Error("[QuestionableIPC] Stack Trace: " + ex.StackTrace);
IsAvailable = false;
return false;
}
}
public bool ForceCheckAvailability()
{
try
{
if (!subscribersInitialized)
{
log.Error("[QuestionableIPC] Subscribers not initialized!");
return false;
}
if (isRunningSubscriber == null)
{
log.Error("[QuestionableIPC] isRunningSubscriber is NULL!");
return false;
}
log.Information("[QuestionableIPC] Force checking Questionable availability...");
log.Information("[QuestionableIPC] Attempting to call Questionable.IsRunning...");
bool testRunning = isRunningSubscriber.InvokeFunc();
log.Information($"[QuestionableIPC] SUCCESS! Questionable.IsRunning returned: {testRunning}");
IsAvailable = true;
lastAvailabilityCheck = DateTime.Now;
return true;
}
catch (Exception ex)
{
log.Error("[QuestionableIPC] Failed to connect to Questionable:");
log.Error("[QuestionableIPC] Exception Type: " + ex.GetType().Name);
log.Error("[QuestionableIPC] Message: " + ex.Message);
log.Error("[QuestionableIPC] Stack Trace: " + ex.StackTrace);
IsAvailable = false;
return false;
}
}
public bool TryEnsureAvailableSilent()
{
if (IsAvailable)
{
return true;
}
if (!subscribersInitialized)
{
return false;
}
lastAvailabilityCheck = DateTime.MinValue;
try
{
if (isRunningSubscriber == null)
{
return false;
}
isRunningSubscriber.InvokeFunc();
if (!IsAvailable)
{
IsAvailable = true;
}
return true;
}
catch
{
IsAvailable = false;
return false;
}
}
public string? GetCurrentQuestId()
{
TryEnsureAvailable();
if (!IsAvailable || getCurrentQuestIdSubscriber == null)
{
return null;
}
try
{
return getCurrentQuestIdSubscriber.InvokeFunc();
}
catch (Exception ex)
{
log.Debug("[QuestionableIPC] GetCurrentQuestId failed: " + ex.Message);
return null;
}
}
public StepData? GetCurrentStepData()
{
TryEnsureAvailable();
if (!IsAvailable || getCurrentStepDataSubscriber == null)
{
return null;
}
try
{
return getCurrentStepDataSubscriber.InvokeFunc();
}
catch (Exception ex)
{
log.Debug("[QuestionableIPC] GetCurrentStepData failed: " + ex.Message);
return null;
}
}
public byte? GetCurrentSequence()
{
TryEnsureAvailable();
if (!IsAvailable || getCurrentStepDataSubscriber == null)
{
return null;
}
try
{
return getCurrentStepDataSubscriber.InvokeFunc()?.Sequence;
}
catch (Exception ex)
{
log.Debug("[QuestionableIPC] GetCurrentSequence failed: " + ex.Message);
return null;
}
}
public bool IsRunning()
{
TryEnsureAvailable();
if (isRunningSubscriber == null)
{
return false;
}
try
{
bool result = isRunningSubscriber.InvokeFunc();
if (!IsAvailable)
{
IsAvailable = true;
log.Information("[QuestionableIPC] Questionable is now available");
}
return result;
}
catch (Exception ex)
{
if (IsAvailable)
{
IsAvailable = false;
log.Warning("[QuestionableIPC] Questionable is no longer available: " + ex.Message);
}
log.Debug("[QuestionableIPC] IsRunning failed: " + ex.Message);
return false;
}
}
public object? GetCurrentTask()
{
TryEnsureAvailable();
if (getCurrentTaskSubscriber == null)
{
return null;
}
try
{
object? result = getCurrentTaskSubscriber.InvokeFunc();
if (!IsAvailable)
{
IsAvailable = true;
log.Information("[QuestionableIPC] Questionable is now available");
}
return result;
}
catch (Exception ex)
{
if (IsAvailable)
{
IsAvailable = false;
log.Warning("[QuestionableIPC] Questionable is no longer available: " + ex.Message);
}
log.Debug("[QuestionableIPC] GetCurrentTask failed: " + ex.Message);
return null;
}
}
public bool Start()
{
log.Warning("[QuestionableIPC] Start() called - NOT AVAILABLE VIA IPC!");
log.Warning("[QuestionableIPC] Use /qst start command instead");
return false;
}
public bool Stop()
{
log.Warning("[QuestionableIPC] Stop() called - NOT AVAILABLE VIA IPC!");
log.Warning("[QuestionableIPC] Use /qst stop command instead");
return false;
}
public bool ImportQuestPriority(string base64QuestData)
{
TryEnsureAvailable();
if (!IsAvailable || importQuestPrioritySubscriber == null)
{
return false;
}
try
{
bool result = importQuestPrioritySubscriber.InvokeFunc(base64QuestData);
log.Information($"[QuestionableIPC] Imported priority quest: {result}");
return result;
}
catch (Exception ex)
{
log.Error("[QuestionableIPC] ImportQuestPriority failed: " + ex.Message);
return false;
}
}
public bool AddQuestPriority(string questId)
{
TryEnsureAvailable();
if (!IsAvailable || addQuestPrioritySubscriber == null)
{
return false;
}
try
{
bool result = addQuestPrioritySubscriber.InvokeFunc(questId);
log.Debug($"[QuestionableIPC] Added quest {questId} to priority: {result}");
return result;
}
catch (Exception ex)
{
log.Error("[QuestionableIPC] AddQuestPriority failed: " + ex.Message);
return false;
}
}
public bool ClearQuestPriority()
{
TryEnsureAvailable();
if (!IsAvailable || clearQuestPrioritySubscriber == null)
{
return false;
}
try
{
bool result = clearQuestPrioritySubscriber.InvokeFunc();
log.Debug($"[QuestionableIPC] Cleared quest priority: {result}");
return result;
}
catch (Exception ex)
{
log.Error("[QuestionableIPC] ClearQuestPriority failed: " + ex.Message);
return false;
}
}
public bool IsQuestComplete(string questId)
{
TryEnsureAvailable();
if (!IsAvailable || isQuestCompleteSubscriber == null)
{
return false;
}
try
{
bool result = isQuestCompleteSubscriber.InvokeFunc(questId);
log.Debug($"[QuestionableIPC] Quest {questId} complete: {result}");
return result;
}
catch (Exception ex)
{
log.Error("[QuestionableIPC] IsQuestComplete failed: " + ex.Message);
return false;
}
}
public bool IsReadyToAcceptQuest(string questId)
{
TryEnsureAvailable();
if (!IsAvailable || isReadyToAcceptQuestSubscriber == null)
{
return false;
}
try
{
bool result = isReadyToAcceptQuestSubscriber.InvokeFunc(questId);
log.Debug($"[QuestionableIPC] Quest {questId} ready to accept: {result}");
return result;
}
catch (Exception ex)
{
log.Error("[QuestionableIPC] IsReadyToAcceptQuest failed: " + ex.Message);
return false;
}
}
public bool AddQuestsToQueue(List<string> questIds)
{
TryEnsureAvailable();
if (!IsAvailable)
{
log.Warning("[QuestionableIPC] Cannot add quests to queue - Questionable not available");
return false;
}
if (questIds == null || questIds.Count == 0)
{
return true;
}
try
{
log.Information($"[QuestionableIPC] Adding {questIds.Count} quests to priority queue");
foreach (string questId in questIds)
{
if (!string.IsNullOrEmpty(questId))
{
try
{
bool? result = addQuestPrioritySubscriber?.InvokeFunc(questId);
log.Debug($"[QuestionableIPC] Added quest {questId} to queue: {result}");
}
catch (Exception ex)
{
log.Warning("[QuestionableIPC] Failed to add quest " + questId + " to queue: " + ex.Message);
}
}
}
log.Information("[QuestionableIPC] All quests added to priority queue");
return true;
}
catch (Exception ex2)
{
log.Error("[QuestionableIPC] Error adding quests to queue: " + ex2.Message);
return false;
}
}
public List<string> GetCurrentlyActiveEventQuests()
{
TryEnsureAvailable();
if (!IsAvailable || getCurrentlyActiveEventQuestsSubscriber == null)
{
log.Warning("[QuestionableIPC] Cannot get active event quests - Questionable not available");
return new List<string>();
}
try
{
List<string> eventQuests = getCurrentlyActiveEventQuestsSubscriber.InvokeFunc();
log.Debug($"[QuestionableIPC] Retrieved {eventQuests?.Count ?? 0} active event quests");
return eventQuests ?? new List<string>();
}
catch (Exception ex)
{
log.Error("[QuestionableIPC] Error getting active event quests: " + ex.Message);
return new List<string>();
}
}
public int GetAlliedSocietyRemainingAllowances()
{
TryEnsureAvailable();
if (!IsAvailable || getAlliedSocietyRemainingAllowancesSubscriber == null)
{
log.Debug("[AlliedSociety] Cannot get remaining allowances - Questionable not available");
return 12;
}
try
{
int result = getAlliedSocietyRemainingAllowancesSubscriber.InvokeFunc();
log.Debug($"[AlliedSociety] Remaining allowances: {result}");
return result;
}
catch (Exception ex)
{
log.Error("[AlliedSociety] Error getting remaining allowances: " + ex.Message);
return 12;
}
}
public List<string> GetAlliedSocietyAvailableQuestIds(byte societyId)
{
TryEnsureAvailable();
if (!IsAvailable || getAlliedSocietyAvailableQuestIdsSubscriber == null)
{
log.Debug($"[AlliedSociety] Cannot get quest IDs for society {societyId} - Questionable not available");
return new List<string>();
}
try
{
List<string> result = getAlliedSocietyAvailableQuestIdsSubscriber.InvokeFunc(societyId);
log.Debug($"[AlliedSociety] Society {societyId} has {result?.Count ?? 0} available quests");
return result ?? new List<string>();
}
catch (Exception ex)
{
log.Error($"[AlliedSociety] Error getting quest IDs for society {societyId}: {ex.Message}");
return new List<string>();
}
}
public Dictionary<byte, int> GetAlliedSocietyAllAvailableQuestCounts()
{
TryEnsureAvailable();
if (!IsAvailable || getAlliedSocietyAllAvailableQuestCountsSubscriber == null)
{
log.Debug("[AlliedSociety] Cannot get quest counts - Questionable not available");
return new Dictionary<byte, int>();
}
try
{
Dictionary<byte, int> result = getAlliedSocietyAllAvailableQuestCountsSubscriber.InvokeFunc();
log.Debug($"[AlliedSociety] Found {result?.Count ?? 0} societies with available quests");
return result ?? new Dictionary<byte, int>();
}
catch (Exception ex)
{
log.Error("[AlliedSociety] Error getting quest counts: " + ex.Message);
return new Dictionary<byte, int>();
}
}
public bool GetAlliedSocietyIsMaxRank(byte societyId)
{
TryEnsureAvailable();
if (!IsAvailable || getAlliedSocietyIsMaxRankSubscriber == null)
{
log.Debug($"[AlliedSociety] Cannot check max rank for society {societyId} - Questionable not available");
return false;
}
try
{
bool result = getAlliedSocietyIsMaxRankSubscriber.InvokeFunc(societyId);
log.Debug($"[AlliedSociety] Society {societyId} max rank: {result}");
return result;
}
catch (Exception ex)
{
log.Error($"[AlliedSociety] Error checking max rank for society {societyId}: {ex.Message}");
return false;
}
}
public int GetAlliedSocietyCurrentRank(byte societyId)
{
TryEnsureAvailable();
if (!IsAvailable || getAlliedSocietyCurrentRankSubscriber == null)
{
log.Debug($"[AlliedSociety] Cannot get rank for society {societyId} - Questionable not available");
return -1;
}
try
{
int result = getAlliedSocietyCurrentRankSubscriber.InvokeFunc(societyId);
log.Debug($"[AlliedSociety] Society {societyId} current rank: {result}");
return result;
}
catch (Exception ex)
{
log.Error($"[AlliedSociety] Error getting rank for society {societyId}: {ex.Message}");
return -1;
}
}
public List<byte> GetAlliedSocietiesWithAvailableQuests()
{
TryEnsureAvailable();
if (!IsAvailable || getAlliedSocietiesWithAvailableQuestsSubscriber == null)
{
log.Debug("[AlliedSociety] Cannot get societies with quests - Questionable not available");
return new List<byte>();
}
try
{
List<byte> result = getAlliedSocietiesWithAvailableQuestsSubscriber.InvokeFunc();
log.Debug($"[AlliedSociety] Found {result?.Count ?? 0} societies with available quests");
return result ?? new List<byte>();
}
catch (Exception ex)
{
log.Error("[AlliedSociety] Error getting societies with quests: " + ex.Message);
return new List<byte>();
}
}
public int AddAlliedSocietyOptimalQuests(byte societyId)
{
TryEnsureAvailable();
if (!IsAvailable || addAlliedSocietyOptimalQuestsSubscriber == null)
{
log.Debug($"[AlliedSociety] Cannot add optimal quests for society {societyId} - Questionable not available");
return 0;
}
try
{
int result = addAlliedSocietyOptimalQuestsSubscriber.InvokeFunc(societyId);
log.Information($"[AlliedSociety] Added {result} optimal quests for society {societyId}");
return result;
}
catch (Exception ex)
{
log.Error($"[AlliedSociety] Error adding optimal quests for society {societyId}: {ex.Message}");
return 0;
}
}
public List<string> GetAlliedSocietyOptimalQuests(byte societyId)
{
TryEnsureAvailable();
if (!IsAvailable || getAlliedSocietyOptimalQuestsSubscriber == null)
{
log.Debug($"[AlliedSociety] Cannot get optimal quests for society {societyId} - Questionable not available");
return new List<string>();
}
try
{
List<string> result = getAlliedSocietyOptimalQuestsSubscriber.InvokeFunc(societyId);
log.Debug($"[AlliedSociety] Found {result?.Count ?? 0} optimal quests for society {societyId}");
return result ?? new List<string>();
}
catch (Exception ex)
{
log.Error($"[AlliedSociety] Error getting optimal quests for society {societyId}: {ex.Message}");
return new List<string>();
}
}
public TimeSpan GetAlliedSocietyTimeUntilReset()
{
TryEnsureAvailable();
if (!IsAvailable || getAlliedSocietyTimeUntilResetSubscriber == null)
{
log.Debug("[AlliedSociety] Cannot get time until reset - Questionable not available");
return TimeSpan.Zero;
}
try
{
TimeSpan timeSpan = TimeSpan.FromTicks(getAlliedSocietyTimeUntilResetSubscriber.InvokeFunc());
log.Debug($"[AlliedSociety] Time until reset: {timeSpan}");
return timeSpan;
}
catch (Exception ex)
{
log.Error("[AlliedSociety] Error getting time until reset: " + ex.Message);
return TimeSpan.Zero;
}
}
public bool GetStopConditionsEnabled()
{
TryEnsureAvailable();
if (!IsAvailable || getStopConditionsEnabledSubscriber == null)
{
log.Debug("[StopCondition] Cannot get stop conditions enabled - Questionable not available");
return false;
}
try
{
bool result = getStopConditionsEnabledSubscriber.InvokeFunc();
log.Debug($"[StopCondition] Stop conditions enabled: {result}");
return result;
}
catch (Exception ex)
{
log.Error("[StopCondition] Error getting stop conditions enabled: " + ex.Message);
return false;
}
}
public List<string> GetStopQuestList()
{
TryEnsureAvailable();
if (!IsAvailable || getStopQuestListSubscriber == null)
{
return new List<string>();
}
try
{
List<string> result = getStopQuestListSubscriber.InvokeFunc();
log.Debug($"[StopCondition] Found {result?.Count ?? 0} stop quests");
return result ?? new List<string>();
}
catch (Exception ex)
{
log.Error("[StopCondition] Error getting stop quest list: " + ex.Message);
return new List<string>();
}
}
public StopConditionData? GetLevelStopCondition()
{
TryEnsureAvailable();
if (!IsAvailable || getLevelStopConditionSubscriber == null)
{
log.Debug("[StopCondition] Cannot get level stop condition - Questionable not available");
return null;
}
try
{
return getLevelStopConditionSubscriber.InvokeFunc();
}
catch (Exception ex)
{
log.Error("[StopCondition] Error getting level stop condition: " + ex.Message);
return null;
}
}
public StopConditionData? GetSequenceStopCondition()
{
TryEnsureAvailable();
if (!IsAvailable || getSequenceStopConditionSubscriber == null)
{
log.Debug("[StopCondition] Cannot get sequence stop condition - Questionable not available");
return null;
}
try
{
StopConditionData result = getSequenceStopConditionSubscriber.InvokeFunc();
log.Debug($"[StopCondition] Sequence stop condition - Enabled: {result?.Enabled}, Target: {result?.TargetValue}");
return result;
}
catch (Exception ex)
{
log.Error("[StopCondition] Error getting sequence stop condition: " + ex.Message);
return null;
}
}
public object? GetQuestSequenceStopCondition(string questId, uint sequence, int step)
{
TryEnsureAvailable();
if (!IsAvailable || getQuestSequenceStopConditionSubscriber == null)
{
log.Warning("[StopCondition] Cannot get quest sequence stop condition - Questionable not available");
return null;
}
try
{
object result = getQuestSequenceStopConditionSubscriber.InvokeFunc(questId, sequence, step);
if (result == null)
{
log.Information($"[StopCondition] No quest sequence stop condition found for {questId} at {sequence}-{step}");
}
else
{
log.Information($"[StopCondition] Quest sequence stop condition for {questId} at {sequence}-{step}: {result}");
}
return result;
}
catch (Exception ex)
{
log.Error("[StopCondition] Error getting quest sequence stop condition: " + ex.Message);
return null;
}
}
public bool RemoveQuestSequenceStopCondition(string questId)
{
TryEnsureAvailable();
if (!IsAvailable || removeQuestSequenceStopConditionSubscriber == null)
{
log.Warning("[StopCondition] Cannot remove quest sequence stop condition - Questionable not available");
return false;
}
try
{
bool num = removeQuestSequenceStopConditionSubscriber.InvokeFunc(questId);
if (num)
{
log.Information("[StopCondition] ✓ Removed quest sequence stop condition for " + questId);
}
else
{
log.Warning("[StopCondition] ✗ No quest sequence stop condition to remove for " + questId + " (or removal failed)");
}
return num;
}
catch (Exception ex)
{
log.Error("[StopCondition] Error removing quest sequence stop condition: " + ex.Message);
return false;
}
}
public Dictionary<string, object> GetAllQuestSequenceStopConditions()
{
TryEnsureAvailable();
if (!IsAvailable || getAllQuestSequenceStopConditionsSubscriber == null)
{
return new Dictionary<string, object>();
}
try
{
Dictionary<string, object> result = getAllQuestSequenceStopConditionsSubscriber.InvokeFunc();
if (result == null || result.Count == 0)
{
log.Information("[StopCondition] No quest sequence stop conditions configured (empty or null result)");
return new Dictionary<string, object>();
}
log.Information($"[StopCondition] Found {result.Count} quest sequence stop condition(s)");
return result;
}
catch (Exception ex)
{
log.Error("[StopCondition] Error getting all quest sequence stop conditions: " + ex.Message);
return new Dictionary<string, object>();
}
}
public int GetDefaultDutyMode()
{
TryEnsureAvailable();
if (!IsAvailable || getDefaultDutyModeSubscriber == null)
{
log.Debug("[QuestionableIPC] Cannot get default duty mode - Questionable not available");
return 0;
}
try
{
int result = getDefaultDutyModeSubscriber.InvokeFunc();
log.Debug($"[QuestionableIPC] Default Duty Mode: {result}");
return result;
}
catch (Exception ex)
{
log.Error("[QuestionableIPC] GetDefaultDutyMode failed: " + ex.Message);
return 0;
}
}
public bool SetDefaultDutyMode(int dutyMode)
{
TryEnsureAvailable();
if (!IsAvailable || setDefaultDutyModeSubscriber == null)
{
log.Debug("[QuestionableIPC] Cannot set default duty mode - Questionable not available");
return false;
}
try
{
bool result = setDefaultDutyModeSubscriber.InvokeFunc(dutyMode);
log.Information($"[QuestionableIPC] Set Default Duty Mode to {dutyMode}: {result}");
return result;
}
catch (Exception ex)
{
log.Error("[QuestionableIPC] SetDefaultDutyMode failed: " + ex.Message);
return false;
}
}
public bool ValidateFeatureCompatibility()
{
if (!IsAvailable)
{
return false;
}
try
{
if (getAllQuestSequenceStopConditionsSubscriber == null)
{
return false;
}
if (getLevelStopConditionSubscriber == null)
{
return false;
}
if (getAlliedSocietyOptimalQuestsSubscriber == null)
{
return false;
}
if (getDefaultDutyModeSubscriber == null)
{
return false;
}
try
{
getAllQuestSequenceStopConditionsSubscriber.InvokeFunc();
}
catch
{
return false;
}
return true;
}
catch
{
return false;
}
}
public void Dispose()
{
IsAvailable = false;
}
}

View file

@ -0,0 +1,18 @@
using System.Numerics;
namespace QuestionableCompanion.Services;
public class StepData
{
public required string QuestId { get; init; }
public required byte Sequence { get; init; }
public required byte Step { get; init; }
public required string InteractionType { get; init; }
public Vector3? Position { get; init; }
public ushort TerritoryId { get; init; }
}

View file

@ -0,0 +1,168 @@
using System;
using System.Collections.Generic;
using System.Threading;
using Dalamud.Game.ClientState.Conditions;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Plugin.Services;
namespace QuestionableCompanion.Services;
public class StepsOfFaithHandler : IDisposable
{
private readonly ICondition condition;
private readonly IPluginLog log;
private readonly IClientState clientState;
private readonly ICommandManager commandManager;
private readonly IFramework framework;
private readonly Configuration config;
private bool isActive;
private readonly Dictionary<string, bool> characterHandledStatus = new Dictionary<string, bool>();
private const uint StepsOfFaithQuestId = 4591u;
public bool IsActive => isActive;
public bool IsStepsOfFaithQuest(uint questId)
{
return questId == 4591;
}
public StepsOfFaithHandler(ICondition condition, IPluginLog log, IClientState clientState, ICommandManager commandManager, IFramework framework, Configuration config)
{
this.condition = condition;
this.log = log;
this.clientState = clientState;
this.commandManager = commandManager;
this.framework = framework;
this.config = config;
log.Information("[StepsOfFaith] Handler initialized");
}
public bool ShouldActivate(uint questId, bool isInSoloDuty)
{
if (!config.EnableAutoDutyUnsynced)
{
return false;
}
if (isActive)
{
return false;
}
if (questId != 4591)
{
return false;
}
if (!isInSoloDuty)
{
return false;
}
string characterName = GetCurrentCharacterName();
if (string.IsNullOrEmpty(characterName))
{
return false;
}
if (characterHandledStatus.GetValueOrDefault(characterName, defaultValue: false))
{
return false;
}
return true;
}
public void Execute(string characterName)
{
isActive = true;
if (!string.IsNullOrEmpty(characterName))
{
characterHandledStatus[characterName] = true;
log.Information("[StepsOfFaith] Marked " + characterName + " as handled");
}
log.Information("[StepsOfFaith] ========================================");
log.Information("[StepsOfFaith] === STEPS OF FAITH HANDLER ACTIVATED ===");
log.Information("[StepsOfFaith] ========================================");
try
{
log.Information("[StepsOfFaith] Waiting for conditions to clear...");
DateTime startTime = DateTime.Now;
TimeSpan maxWaitTime = TimeSpan.FromSeconds(6000L);
while (DateTime.Now - startTime < maxWaitTime)
{
bool hasCondition29 = condition[ConditionFlag.Occupied];
bool hasCondition63 = condition[ConditionFlag.SufferingStatusAffliction63];
if (!hasCondition29 && !hasCondition63)
{
log.Information("[StepsOfFaith] Conditions cleared!");
break;
}
if ((DateTime.Now - startTime).TotalSeconds % 5.0 < 0.1)
{
log.Information($"[StepsOfFaith] Waiting... (29: {hasCondition29}, 63: {hasCondition63})");
}
Thread.Sleep(200);
}
log.Information("[StepsOfFaith] Waiting 25s for stabilization...");
Thread.Sleep(25000);
log.Information("[StepsOfFaith] Disabling Bossmod Rotation...");
framework.RunOnFrameworkThread(delegate
{
commandManager.ProcessCommand("/vbm ar disable");
});
log.Information("[StepsOfFaith] Moving to target position...");
framework.RunOnFrameworkThread(delegate
{
commandManager.ProcessCommand("/vnav moveto 2.8788917064667 0.0 293.36273193359");
});
log.Information("[StepsOfFaith] Enabling combat commands...");
framework.RunOnFrameworkThread(delegate
{
commandManager.ProcessCommand("/rsr auto");
Thread.Sleep(100);
commandManager.ProcessCommand("/vbmai on");
Thread.Sleep(100);
commandManager.ProcessCommand("/bmrai on");
});
log.Information("[StepsOfFaith] === HANDLER COMPLETE ===");
}
catch (Exception ex)
{
log.Error("[StepsOfFaith] Error: " + ex.Message);
}
finally
{
isActive = false;
}
}
public void Reset()
{
isActive = false;
log.Information("[StepsOfFaith] Active state reset (character completion status preserved)");
}
private string GetCurrentCharacterName()
{
try
{
IPlayerCharacter player = clientState.LocalPlayer;
if (player != null)
{
return $"{player.Name}@{player.HomeWorld.Value.Name}";
}
}
catch (Exception ex)
{
log.Error("[StepsOfFaith] Failed to get character name: " + ex.Message);
}
return string.Empty;
}
public void Dispose()
{
}
}

View file

@ -0,0 +1,8 @@
namespace QuestionableCompanion.Services;
public class StopConditionData
{
public required bool Enabled { get; init; }
public required int TargetValue { get; init; }
}

View file

@ -0,0 +1,388 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using Dalamud.Plugin.Services;
using Newtonsoft.Json.Linq;
namespace QuestionableCompanion.Services;
public class SubmarineManager : IDisposable
{
private readonly IPluginLog log;
private readonly AutoRetainerIPC autoRetainerIPC;
private readonly Configuration config;
private readonly ICommandManager? commandManager;
private readonly IFramework? framework;
private DateTime lastSubmarineCheck = DateTime.MinValue;
private DateTime submarineReloginCooldownEnd = DateTime.MinValue;
private DateTime submarineNoAvailableWaitEnd = DateTime.MinValue;
private bool submarinesPaused;
private bool submarinesWaitingForSeq0;
private bool submarineReloginInProgress;
private bool submarineJustCompleted;
private string? originalCharacterForSubmarines;
public bool IsSubmarinePaused => submarinesPaused;
public bool IsWaitingForSequence0 => submarinesWaitingForSeq0;
public bool IsReloginInProgress => submarineReloginInProgress;
public bool IsSubmarineJustCompleted => submarineJustCompleted;
public SubmarineManager(IPluginLog log, AutoRetainerIPC autoRetainerIPC, Configuration config, ICommandManager? commandManager = null, IFramework? framework = null)
{
this.log = log;
this.autoRetainerIPC = autoRetainerIPC;
this.config = config;
this.commandManager = commandManager;
this.framework = framework;
log.Information("[SubmarineManager] Service initialized");
}
private string? GetConfigPath()
{
try
{
string userProfile = Environment.GetEnvironmentVariable("USERPROFILE");
if (string.IsNullOrEmpty(userProfile))
{
string username = Environment.GetEnvironmentVariable("USERNAME");
if (string.IsNullOrEmpty(username))
{
log.Warning("[SubmarineManager] Could not resolve USERPROFILE or USERNAME");
return null;
}
userProfile = "C:\\Users\\" + username;
}
_003C_003Ey__InlineArray7<string> buffer = default(_003C_003Ey__InlineArray7<string>);
global::_003CPrivateImplementationDetails_003E.InlineArrayElementRef<_003C_003Ey__InlineArray7<string>, string>(ref buffer, 0) = userProfile;
global::_003CPrivateImplementationDetails_003E.InlineArrayElementRef<_003C_003Ey__InlineArray7<string>, string>(ref buffer, 1) = "AppData";
global::_003CPrivateImplementationDetails_003E.InlineArrayElementRef<_003C_003Ey__InlineArray7<string>, string>(ref buffer, 2) = "Roaming";
global::_003CPrivateImplementationDetails_003E.InlineArrayElementRef<_003C_003Ey__InlineArray7<string>, string>(ref buffer, 3) = "XIVLauncher";
global::_003CPrivateImplementationDetails_003E.InlineArrayElementRef<_003C_003Ey__InlineArray7<string>, string>(ref buffer, 4) = "pluginConfigs";
global::_003CPrivateImplementationDetails_003E.InlineArrayElementRef<_003C_003Ey__InlineArray7<string>, string>(ref buffer, 5) = "AutoRetainer";
global::_003CPrivateImplementationDetails_003E.InlineArrayElementRef<_003C_003Ey__InlineArray7<string>, string>(ref buffer, 6) = "DefaultConfig.json";
return Path.Combine(global::_003CPrivateImplementationDetails_003E.InlineArrayAsReadOnlySpan<_003C_003Ey__InlineArray7<string>, string>(in buffer, 7));
}
catch (Exception ex)
{
log.Error("[SubmarineManager] Error resolving config path: " + ex.Message);
return null;
}
}
public bool CheckSubmarines()
{
if (!config.EnableSubmarineCheck)
{
return false;
}
string configPath = GetConfigPath();
if (string.IsNullOrEmpty(configPath))
{
log.Warning("[SubmarineManager] Could not resolve config path");
return false;
}
if (!File.Exists(configPath))
{
log.Debug("[SubmarineManager] Config file not found: " + configPath);
return false;
}
try
{
string content = File.ReadAllText(configPath);
if (string.IsNullOrEmpty(content))
{
log.Warning("[SubmarineManager] Config file is empty");
return false;
}
List<long> returnTimes = ParseReturnTimes(content);
if (returnTimes.Count == 0)
{
return false;
}
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
int available = 0;
long? minDelta = null;
foreach (long item in returnTimes)
{
long delta = item - now;
if (delta <= 0)
{
available++;
}
else if (!minDelta.HasValue || delta < minDelta.Value)
{
minDelta = delta;
}
}
if (available > 0)
{
string plural = ((available == 1) ? "Sub" : "Subs");
log.Information($"[SubmarineManager] {available} {plural} available - pausing quest rotation!");
return true;
}
if (minDelta.HasValue && minDelta.Value > 0)
{
int minutes = Math.Max(0, (int)Math.Ceiling((double)minDelta.Value / 60.0));
string plural2 = ((minutes == 1) ? "minute" : "minutes");
log.Debug($"[SubmarineManager] Next submarine in {minutes} {plural2}");
}
return false;
}
catch (Exception ex)
{
log.Error("[SubmarineManager] Error checking submarines: " + ex.Message);
return false;
}
}
public int CheckSubmarinesSoon()
{
if (!config.EnableSubmarineCheck)
{
return 0;
}
string configPath = GetConfigPath();
if (string.IsNullOrEmpty(configPath) || !File.Exists(configPath))
{
return 0;
}
try
{
string content = File.ReadAllText(configPath);
if (string.IsNullOrEmpty(content))
{
return 0;
}
List<long> returnTimes = ParseReturnTimes(content);
if (returnTimes.Count == 0)
{
return 0;
}
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
long? minDelta = null;
int availableNow = 0;
foreach (long item in returnTimes)
{
long delta = item - now;
if (delta <= 0)
{
availableNow++;
}
else if (delta <= 120 && (!minDelta.HasValue || delta < minDelta.Value))
{
minDelta = delta;
}
}
if (availableNow > 0)
{
log.Debug($"[SubmarineManager] {availableNow} submarines ready NOW - continue Multi-Mode");
return 999;
}
if (minDelta.HasValue)
{
int minutes = (int)Math.Ceiling((double)minDelta.Value / 60.0);
log.Debug($"[SubmarineManager] Submarine will be ready in {minDelta.Value} seconds ({minutes} min) - waiting before character switch");
return (int)minDelta.Value;
}
if (submarineNoAvailableWaitEnd == DateTime.MinValue)
{
submarineNoAvailableWaitEnd = DateTime.Now.AddSeconds(60.0);
log.Information("[SubmarineManager] No submarines available - waiting 60 seconds before relog");
return 60;
}
if (DateTime.Now < submarineNoAvailableWaitEnd)
{
int remaining = (int)(submarineNoAvailableWaitEnd - DateTime.Now).TotalSeconds;
log.Debug($"[SubmarineManager] Waiting {remaining}s before relog...");
return remaining;
}
submarineNoAvailableWaitEnd = DateTime.MinValue;
return 0;
}
catch (Exception ex)
{
log.Error("[SubmarineManager] Error checking submarines soon: " + ex.Message);
return 0;
}
}
private List<long> ParseReturnTimes(string jsonContent)
{
List<long> returnTimes = new List<long>();
try
{
JObject json = JObject.Parse(jsonContent);
FindReturnTimes(json, returnTimes);
}
catch
{
string pattern = "\"ReturnTime\"\\s*:\\s*(\\d+)";
foreach (Match match in Regex.Matches(jsonContent, pattern))
{
if (match.Groups.Count > 1 && long.TryParse(match.Groups[1].Value, out var timestamp))
{
returnTimes.Add(timestamp);
}
}
}
return returnTimes;
}
private void FindReturnTimes(JToken token, List<long> returnTimes)
{
if (token is JObject obj)
{
{
foreach (JProperty property in obj.Properties())
{
if (property.Name == "ReturnTime" && property.Value.Type == JTokenType.Integer)
{
returnTimes.Add(property.Value.Value<long>());
}
else
{
FindReturnTimes(property.Value, returnTimes);
}
}
return;
}
}
if (!(token is JArray array))
{
return;
}
foreach (JToken item in array)
{
FindReturnTimes(item, returnTimes);
}
}
public void StartSubmarineWait(string currentCharacter)
{
submarinesWaitingForSeq0 = true;
originalCharacterForSubmarines = currentCharacter;
log.Information("[SubmarineManager] Waiting for Sequence 0 completion before enabling Multi-Mode");
}
public void EnableMultiMode()
{
if (!autoRetainerIPC.IsAvailable)
{
log.Warning("[SubmarineManager] AutoRetainer not available - cannot enable Multi-Mode");
return;
}
try
{
if (autoRetainerIPC.GetMultiModeEnabled())
{
log.Information("[SubmarineManager] Multi-Mode is already enabled - skipping activation");
submarinesPaused = true;
submarinesWaitingForSeq0 = false;
return;
}
if (commandManager != null && framework != null)
{
log.Information("[SubmarineManager] Sending /ays multi e command...");
framework.RunOnFrameworkThread(delegate
{
commandManager.ProcessCommand("/ays multi e");
}).Wait();
log.Information("[SubmarineManager] ✓ /ays multi e command sent");
}
autoRetainerIPC.SetMultiModeEnabled(enabled: true);
submarinesPaused = true;
submarinesWaitingForSeq0 = false;
log.Information("[SubmarineManager] Multi-Mode enabled - quest automation paused");
}
catch (Exception ex)
{
log.Error("[SubmarineManager] Failed to enable Multi-Mode: " + ex.Message);
}
}
public void DisableMultiModeAndReturn()
{
if (!autoRetainerIPC.IsAvailable)
{
log.Warning("[SubmarineManager] AutoRetainer not available");
return;
}
try
{
if (commandManager != null && framework != null)
{
log.Information("[SubmarineManager] Sending /ays multi d command...");
framework.RunOnFrameworkThread(delegate
{
commandManager.ProcessCommand("/ays multi d");
}).Wait();
log.Information("[SubmarineManager] ✓ /ays multi d command sent");
}
autoRetainerIPC.SetMultiModeEnabled(enabled: false);
log.Information("[SubmarineManager] Multi-Mode disabled - starting return to original character");
submarineNoAvailableWaitEnd = DateTime.MinValue;
if (!string.IsNullOrEmpty(originalCharacterForSubmarines))
{
submarineReloginInProgress = true;
log.Information("[SubmarineManager] Returning to original character: " + originalCharacterForSubmarines);
}
}
catch (Exception ex)
{
log.Error("[SubmarineManager] Failed to disable Multi-Mode: " + ex.Message);
}
}
public void CompleteSubmarineRelog()
{
submarineReloginInProgress = false;
submarinesPaused = false;
submarineJustCompleted = true;
submarineReloginCooldownEnd = DateTime.Now.AddSeconds(config.SubmarineReloginCooldown);
log.Information($"[SubmarineManager] Submarine rotation complete - cooldown active for {config.SubmarineReloginCooldown} seconds");
}
public bool IsSubmarineCooldownActive()
{
return DateTime.Now < submarineReloginCooldownEnd;
}
public void ClearSubmarineJustCompleted()
{
submarineJustCompleted = false;
log.Information("[SubmarineManager] Cooldown expired - submarine checks re-enabled");
}
public void Reset()
{
submarinesPaused = false;
submarinesWaitingForSeq0 = false;
submarineReloginInProgress = false;
submarineJustCompleted = false;
originalCharacterForSubmarines = null;
submarineReloginCooldownEnd = DateTime.MinValue;
log.Information("[SubmarineManager] State reset");
}
public void Dispose()
{
Reset();
log.Information("[SubmarineManager] Service disposed");
}
}

View file

@ -0,0 +1,62 @@
using System;
using System.Numerics;
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
namespace QuestionableCompanion.Services;
public class VNavmeshIPC : IDisposable
{
private readonly ICallGateSubscriber<Vector3, bool, float, Vector3?> pointOnFloorSubscriber;
private readonly ICallGateSubscriber<Vector3, float, float, Vector3?> nearestPointSubscriber;
private readonly ICallGateSubscriber<bool> isReadySubscriber;
public VNavmeshIPC(IDalamudPluginInterface pluginInterface)
{
pointOnFloorSubscriber = pluginInterface.GetIpcSubscriber<Vector3, bool, float, Vector3?>("vnavmesh.Query.Mesh.PointOnFloor");
nearestPointSubscriber = pluginInterface.GetIpcSubscriber<Vector3, float, float, Vector3?>("vnavmesh.Query.Mesh.NearestPoint");
isReadySubscriber = pluginInterface.GetIpcSubscriber<bool>("vnavmesh.Nav.IsReady");
}
public bool IsReady()
{
try
{
return isReadySubscriber.InvokeFunc();
}
catch
{
return false;
}
}
public Vector3? FindPointOnFloor(Vector3 position, bool allowUnlandable = false, float searchRadius = 10f)
{
try
{
return pointOnFloorSubscriber.InvokeFunc(position, allowUnlandable, searchRadius);
}
catch (Exception)
{
return null;
}
}
public Vector3? FindNearestPoint(Vector3 position, float horizontalRadius = 10f, float verticalRadius = 5f)
{
try
{
return nearestPointSubscriber.InvokeFunc(position, horizontalRadius, verticalRadius);
}
catch (Exception)
{
return null;
}
}
public void Dispose()
{
}
}

View file

@ -0,0 +1,171 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Windowing;
using QuestionableCompanion.Helpers;
using QuestionableCompanion.Models;
using QuestionableCompanion.Services;
namespace QuestionableCompanion.Windows;
public class AlliedSocietyPriorityWindow : Window, IDisposable
{
private readonly Configuration configuration;
private readonly AlliedSocietyDatabase database;
private List<AlliedSocietyPriority> editingPriorities = new List<AlliedSocietyPriority>();
private int? draggedIndex;
private const string DragDropId = "ALLIED_SOCIETY_PRIORITY";
private readonly Dictionary<byte, string> societyNames = new Dictionary<byte, string>
{
{ 1, "Amalj'aa" },
{ 2, "Sylphs" },
{ 3, "Kobolds" },
{ 4, "Sahagin" },
{ 5, "Ixal" },
{ 6, "Vanu Vanu" },
{ 7, "Vath" },
{ 8, "Moogles" },
{ 9, "Kojin" },
{ 10, "Ananta" },
{ 11, "Namazu" },
{ 12, "Pixies" },
{ 13, "Qitari" },
{ 14, "Dwarves" },
{ 15, "Arkasodara" },
{ 16, "Omicrons" },
{ 17, "Loporrits" },
{ 18, "Pelupelu" },
{ 19, "Mamool Ja" },
{ 20, "Yok Huy" }
};
public AlliedSocietyPriorityWindow(Configuration configuration, AlliedSocietyDatabase database)
: base("Allied Society Priority Configuration", ImGuiWindowFlags.NoCollapse)
{
this.configuration = configuration;
this.database = database;
base.Size = new Vector2(400f, 600f);
base.SizeCondition = ImGuiCond.FirstUseEver;
}
public void Dispose()
{
}
public override void OnOpen()
{
if (configuration.AlliedSociety.RotationConfig.Priorities.Count == 0)
{
configuration.AlliedSociety.RotationConfig.InitializeDefaults();
database.SaveToConfig();
}
editingPriorities = (from p in configuration.AlliedSociety.RotationConfig.Priorities
orderby p.Order
select new AlliedSocietyPriority
{
SocietyId = p.SocietyId,
Enabled = p.Enabled,
Order = p.Order
}).ToList();
}
public override void Draw()
{
ImGui.TextWrapped("Drag societies to reorder priorities. Uncheck to disable specific societies.");
ImGui.Separator();
float availableHeight = ImGui.GetWindowSize().Y - 120f;
ImGui.BeginChild("PriorityList", new Vector2(0f, availableHeight), border: true);
for (int i = 0; i < editingPriorities.Count; i++)
{
AlliedSocietyPriority priority = editingPriorities[i];
string name = (societyNames.ContainsKey(priority.SocietyId) ? societyNames[priority.SocietyId] : $"Unknown ({priority.SocietyId})");
ImGui.PushID(i);
if (draggedIndex == i)
{
uint highlightColor = ImGui.ColorConvertFloat4ToU32(new Vector4(0f, 0.7f, 0f, 0.3f));
Vector2 cursorPos = ImGui.GetCursorScreenPos();
Vector2 itemSize = new Vector2(ImGui.GetContentRegionAvail().X, ImGui.GetFrameHeight());
ImGui.GetWindowDrawList().AddRectFilled(cursorPos, cursorPos + itemSize, highlightColor);
}
ImGui.PushFont(UiBuilder.IconFont);
ImU8String label = new ImU8String(6, 1);
label.AppendFormatted(FontAwesomeIcon.ArrowsUpDownLeftRight.ToIconString());
label.AppendLiteral("##drag");
ImGui.Button(label);
ImGui.PopFont();
if (ImGui.IsItemHovered())
{
ImGui.SetMouseCursor(ImGuiMouseCursor.ResizeAll);
}
if (ImGui.BeginDragDropSource())
{
draggedIndex = i;
ImGuiDragDrop.SetDragDropPayload("ALLIED_SOCIETY_PRIORITY", i);
ImGui.Text(name);
ImGui.EndDragDropSource();
}
else if (draggedIndex == i && !ImGui.IsMouseDown(ImGuiMouseButton.Left))
{
draggedIndex = null;
}
if (ImGui.BeginDragDropTarget())
{
if (ImGuiDragDrop.AcceptDragDropPayload<int>("ALLIED_SOCIETY_PRIORITY", out var sourceIndex) && sourceIndex != i)
{
AlliedSocietyPriority item = editingPriorities[sourceIndex];
editingPriorities.RemoveAt(sourceIndex);
editingPriorities.Insert(i, item);
UpdateOrders();
draggedIndex = i;
}
ImGui.EndDragDropTarget();
}
ImGui.SameLine();
bool enabled = priority.Enabled;
ImU8String label2 = new ImU8String(9, 0);
label2.AppendLiteral("##enabled");
if (ImGui.Checkbox(label2, ref enabled))
{
priority.Enabled = enabled;
}
ImGui.SameLine();
ImGui.Text(name);
ImGui.PopID();
}
ImGui.EndChild();
ImGui.Separator();
if (ImGui.Button("Save"))
{
Save();
base.IsOpen = false;
}
ImGui.SameLine();
if (ImGui.Button("Cancel"))
{
base.IsOpen = false;
}
}
private void UpdateOrders()
{
for (int i = 0; i < editingPriorities.Count; i++)
{
editingPriorities[i].Order = i;
}
}
private void Save()
{
UpdateOrders();
configuration.AlliedSociety.RotationConfig.Priorities = editingPriorities;
database.SaveToConfig();
}
}

View file

@ -0,0 +1,564 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.Windowing;
using QuestionableCompanion.Services;
namespace QuestionableCompanion.Windows;
public class ConfigWindow : Window, IDisposable
{
private readonly Configuration configuration;
private readonly Plugin plugin;
public ConfigWindow(Plugin plugin)
: base("Questionable Companion Settings###QCSettings")
{
base.Size = new Vector2(600f, 400f);
base.SizeCondition = ImGuiCond.FirstUseEver;
this.plugin = plugin;
configuration = plugin.Configuration;
}
public void Dispose()
{
}
public override void PreDraw()
{
if (configuration.IsConfigWindowMovable)
{
base.Flags &= ~ImGuiWindowFlags.NoMove;
}
else
{
base.Flags |= ImGuiWindowFlags.NoMove;
}
}
public override void Draw()
{
ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1f, 0.8f, 0.2f, 1f));
ImGui.TextWrapped("Configuration Moved!");
ImGui.PopStyleColor();
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
ImGui.TextWrapped("The configuration interface has been moved to the new Main Window for a better user experience.");
ImGui.Spacing();
ImGui.TextWrapped("All settings are now available in the Main Window with improved organization and features:");
ImGui.Spacing();
ImGui.BulletText("Quest Rotation Management");
ImGui.BulletText("Event Quest Automation");
ImGui.BulletText("MSQ Progress Tracking");
ImGui.BulletText("DC Travel Configuration");
ImGui.BulletText("Advanced Settings");
ImGui.BulletText("And much more!");
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
if (ImGui.Button(size: new Vector2(ImGui.GetContentRegionAvail().X, 50f), label: "Open Main Window (Settings Tab)"))
{
plugin.ToggleMainUi();
base.IsOpen = false;
}
ImGui.Spacing();
ImGui.Spacing();
ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.7f, 0.7f, 0.7f, 1f));
ImGui.TextWrapped("This legacy configuration window will be removed in a future update.");
ImGui.TextWrapped("Please use the Main Window for all configuration needs.");
ImGui.PopStyleColor();
}
private void DrawGeneralTab()
{
ImGui.Text("General Settings");
ImGui.Separator();
ImGui.Spacing();
bool autoStart = configuration.AutoStartOnLogin;
if (ImGui.Checkbox("Auto-start on login", ref autoStart))
{
configuration.AutoStartOnLogin = autoStart;
configuration.Save();
}
bool dryRun = configuration.EnableDryRun;
if (ImGui.Checkbox("Enable Dry Run Mode (simulate without executing)", ref dryRun))
{
configuration.EnableDryRun = dryRun;
configuration.Save();
}
bool restoreState = configuration.RestoreStateOnLoad;
if (ImGui.Checkbox("Restore state on plugin load", ref restoreState))
{
configuration.RestoreStateOnLoad = restoreState;
configuration.Save();
}
ImGui.Spacing();
ImGui.Text("Execution Settings");
ImGui.Separator();
ImGui.Spacing();
int maxRetries = configuration.MaxRetryAttempts;
if (ImGui.SliderInt("Max retry attempts", ref maxRetries, 1, 10))
{
configuration.MaxRetryAttempts = maxRetries;
configuration.Save();
}
int switchDelay = configuration.CharacterSwitchDelay;
if (ImGui.SliderInt("Character switch delay (seconds)", ref switchDelay, 3, 15))
{
configuration.CharacterSwitchDelay = switchDelay;
configuration.Save();
}
ImGui.Spacing();
ImGui.Text("Logging Settings");
ImGui.Separator();
ImGui.Spacing();
int maxLogs = configuration.MaxLogEntries;
if (ImGui.SliderInt("Max log entries", ref maxLogs, 50, 500))
{
configuration.MaxLogEntries = maxLogs;
configuration.Save();
}
bool showDebug = configuration.ShowDebugLogs;
if (ImGui.Checkbox("Show debug logs", ref showDebug))
{
configuration.ShowDebugLogs = showDebug;
configuration.Save();
}
bool logToFile = configuration.LogToFile;
if (ImGui.Checkbox("Log to file", ref logToFile))
{
configuration.LogToFile = logToFile;
configuration.Save();
}
ImGui.Spacing();
ImGui.Text("UI Settings");
ImGui.Separator();
ImGui.Spacing();
bool movable = configuration.IsConfigWindowMovable;
if (ImGui.Checkbox("Movable config window", ref movable))
{
configuration.IsConfigWindowMovable = movable;
configuration.Save();
}
}
private void DrawAdvancedFeaturesTab()
{
ImGui.TextColored(new Vector4(0.3f, 0.8f, 1f, 1f), "Submarine Management");
ImGui.Separator();
ImGui.Spacing();
bool enableSubmarineCheck = configuration.EnableSubmarineCheck;
if (ImGui.Checkbox("Enable Submarine Monitoring", ref enableSubmarineCheck))
{
configuration.EnableSubmarineCheck = enableSubmarineCheck;
configuration.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Automatically monitor submarines and pause quest rotation when submarines are ready");
}
if (configuration.EnableSubmarineCheck)
{
ImGui.Indent();
int submarineCheckInterval = configuration.SubmarineCheckInterval;
if (ImGui.SliderInt("Check Interval (seconds)", ref submarineCheckInterval, 30, 300))
{
configuration.SubmarineCheckInterval = submarineCheckInterval;
configuration.Save();
}
int submarineReloginCooldown = configuration.SubmarineReloginCooldown;
if (ImGui.SliderInt("Cooldown after Relog (seconds)", ref submarineReloginCooldown, 60, 300))
{
configuration.SubmarineReloginCooldown = submarineReloginCooldown;
configuration.Save();
}
int submarineWaitTime = configuration.SubmarineWaitTime;
if (ImGui.SliderInt("Wait time before submarine (seconds)", ref submarineWaitTime, 10, 120))
{
configuration.SubmarineWaitTime = submarineWaitTime;
configuration.Save();
}
ImGui.Unindent();
}
ImGui.Spacing();
ImGui.Spacing();
ImGui.TextColored(new Vector4(0.3f, 0.8f, 1f, 1f), "Movement Monitor");
ImGui.Separator();
ImGui.Spacing();
bool enableMovementMonitor = configuration.EnableMovementMonitor;
if (ImGui.Checkbox("Enable Movement Monitor", ref enableMovementMonitor))
{
configuration.EnableMovementMonitor = enableMovementMonitor;
configuration.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Automatically detect if player is stuck and send /qst reload");
}
if (configuration.EnableMovementMonitor)
{
ImGui.Indent();
int movementCheckInterval = configuration.MovementCheckInterval;
if (ImGui.SliderInt("Check Interval (seconds)##movement", ref movementCheckInterval, 3, 30))
{
configuration.MovementCheckInterval = movementCheckInterval;
configuration.Save();
}
int movementStuckThreshold = configuration.MovementStuckThreshold;
if (ImGui.SliderInt("Stuck Threshold (seconds)", ref movementStuckThreshold, 15, 120))
{
configuration.MovementStuckThreshold = movementStuckThreshold;
configuration.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Time without movement before sending /qst reload");
}
ImGui.Unindent();
}
ImGui.Spacing();
ImGui.Spacing();
ImGui.TextColored(new Vector4(1f, 0.5f, 0.3f, 1f), "Combat Handling");
ImGui.Separator();
ImGui.Spacing();
bool enableCombatHandling = configuration.EnableCombatHandling;
if (ImGui.Checkbox("Enable Combat Handling", ref enableCombatHandling))
{
configuration.EnableCombatHandling = enableCombatHandling;
configuration.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Automatically enable RSR/VBMAI/BMRAI when HP drops below threshold during combat");
}
if (configuration.EnableCombatHandling)
{
ImGui.Indent();
int combatHPThreshold = configuration.CombatHPThreshold;
if (ImGui.SliderInt("HP Threshold (%)", ref combatHPThreshold, 1, 99))
{
configuration.CombatHPThreshold = combatHPThreshold;
configuration.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Enable combat automation when HP drops below this percentage\nCommands: /rsr manual, /vbmai on, /bmrai on");
}
ImGui.TextWrapped("When HP drops below threshold:");
ImGui.BulletText("/rsr manual");
ImGui.BulletText("/vbmai on");
ImGui.BulletText("/bmrai on");
ImGui.Spacing();
ImGui.TextWrapped("When combat ends:");
ImGui.BulletText("/rsr off");
ImGui.BulletText("/vbmai off");
ImGui.BulletText("/bmrai off");
ImGui.Unindent();
}
ImGui.Spacing();
ImGui.Spacing();
ImGui.TextColored(new Vector4(1f, 0.3f, 0.3f, 1f), "Death Handling");
ImGui.Separator();
ImGui.Spacing();
bool enableDeathHandling = configuration.EnableDeathHandling;
if (ImGui.Checkbox("Enable Death Handling", ref enableDeathHandling))
{
configuration.EnableDeathHandling = enableDeathHandling;
configuration.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Automatically respawn when player dies");
}
if (configuration.EnableDeathHandling)
{
ImGui.Indent();
int deathRespawnDelay = configuration.DeathRespawnDelay;
if (ImGui.SliderInt("Teleport Delay (seconds)", ref deathRespawnDelay, 1, 30))
{
configuration.DeathRespawnDelay = deathRespawnDelay;
configuration.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Time to wait after respawn before teleporting back to death location");
}
ImGui.Spacing();
ImGui.TextWrapped("On Death:");
ImGui.BulletText("Detect 0% HP");
ImGui.BulletText("Save position & territory");
ImGui.BulletText("Auto-click SelectYesNo (respawn)");
ImU8String text = new ImU8String(13, 1);
text.AppendLiteral("Wait ");
text.AppendFormatted(configuration.DeathRespawnDelay);
text.AppendLiteral(" seconds");
ImGui.BulletText(text);
ImGui.BulletText("Teleport back to death location");
ImGui.Unindent();
}
ImGui.Spacing();
ImGui.Spacing();
ImGui.TextColored(new Vector4(0.3f, 0.8f, 1f, 1f), "Logging Settings");
ImGui.Separator();
ImGui.Spacing();
bool logToDalamud = configuration.LogToDalamud;
if (ImGui.Checkbox("Log to Dalamud Log", ref logToDalamud))
{
configuration.LogToDalamud = logToDalamud;
configuration.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Enable to also log to Dalamud log (can cause spam)");
}
ImGui.Spacing();
ImGui.Spacing();
ImGui.TextColored(new Vector4(0.3f, 0.8f, 1f, 1f), "Dungeon Automation");
ImGui.Separator();
ImGui.Spacing();
bool enableAutoDutyUnsynced = configuration.EnableAutoDutyUnsynced;
if (ImGui.Checkbox("Enable AutoDuty Unsynced", ref enableAutoDutyUnsynced))
{
configuration.EnableAutoDutyUnsynced = enableAutoDutyUnsynced;
configuration.Save();
plugin.GetDungeonAutomation()?.SetDutyModeBasedOnConfig();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Run dungeons unsynced for faster completion");
}
if (configuration.EnableAutoDutyUnsynced)
{
ImGui.Indent();
int autoDutyPartySize = configuration.AutoDutyPartySize;
if (ImGui.SliderInt("Party Size Check (members)", ref autoDutyPartySize, 1, 8))
{
configuration.AutoDutyPartySize = autoDutyPartySize;
configuration.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Minimum party size required before starting dungeon");
}
int autoDutyMaxWaitForParty = configuration.AutoDutyMaxWaitForParty;
if (ImGui.SliderInt("Max Wait for Party (seconds)", ref autoDutyMaxWaitForParty, 10, 120))
{
configuration.AutoDutyMaxWaitForParty = autoDutyMaxWaitForParty;
configuration.Save();
}
int autoDutyReInviteInterval = configuration.AutoDutyReInviteInterval;
if (ImGui.SliderInt("Re-invite Interval (seconds)", ref autoDutyReInviteInterval, 5, 60))
{
configuration.AutoDutyReInviteInterval = autoDutyReInviteInterval;
configuration.Save();
}
ImGui.Unindent();
}
ImGui.Spacing();
ImGui.Spacing();
ImGui.TextColored(new Vector4(0.3f, 0.8f, 1f, 1f), "Quest Automation");
ImGui.Separator();
ImGui.Spacing();
bool enableQSTReloadTracking = configuration.EnableQSTReloadTracking;
if (ImGui.Checkbox("Enable QST Reload Tracking", ref enableQSTReloadTracking))
{
configuration.EnableQSTReloadTracking = enableQSTReloadTracking;
configuration.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Track Questionable reloads and switch character if too many reloads occur");
}
if (configuration.EnableQSTReloadTracking)
{
ImGui.Indent();
int maxQSTReloadsBeforeSwitch = configuration.MaxQSTReloadsBeforeSwitch;
if (ImGui.SliderInt("Max Reloads before switch", ref maxQSTReloadsBeforeSwitch, 1, 20))
{
configuration.MaxQSTReloadsBeforeSwitch = maxQSTReloadsBeforeSwitch;
configuration.Save();
}
ImGui.Unindent();
}
ImGui.Spacing();
ImGui.Spacing();
ImGui.Spacing();
ImGui.TextColored(new Vector4(0.3f, 0.8f, 1f, 1f), "Character Management");
ImGui.Separator();
ImGui.Spacing();
bool enableMultiModeAfterRotation = configuration.EnableMultiModeAfterRotation;
if (ImGui.Checkbox("Enable Multi-Mode after Rotation", ref enableMultiModeAfterRotation))
{
configuration.EnableMultiModeAfterRotation = enableMultiModeAfterRotation;
configuration.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Enable AutoRetainer Multi-Mode after completing character rotation");
}
bool returnToHomeworldOnStopQuest = configuration.ReturnToHomeworldOnStopQuest;
if (ImGui.Checkbox("Return to Homeworld on Stop Quest", ref returnToHomeworldOnStopQuest))
{
configuration.ReturnToHomeworldOnStopQuest = returnToHomeworldOnStopQuest;
configuration.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Automatically return to homeworld when stop quest is completed");
}
ImGui.Spacing();
ImGui.Spacing();
ImGui.TextColored(new Vector4(0.3f, 0.8f, 1f, 1f), "Safe Wait Settings");
ImGui.Separator();
ImGui.Spacing();
bool enableSafeWaitBefore = configuration.EnableSafeWaitBeforeCharacterSwitch;
if (ImGui.Checkbox("Enable Safe Wait Before Character Switch", ref enableSafeWaitBefore))
{
configuration.EnableSafeWaitBeforeCharacterSwitch = enableSafeWaitBefore;
configuration.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Wait for character to stabilize (movement, actions) before switching");
}
bool enableSafeWaitAfter = configuration.EnableSafeWaitAfterCharacterSwitch;
if (ImGui.Checkbox("Enable Safe Wait After Character Switch", ref enableSafeWaitAfter))
{
configuration.EnableSafeWaitAfterCharacterSwitch = enableSafeWaitAfter;
configuration.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Wait for character to fully load after switching");
}
ImGui.Spacing();
ImGui.Spacing();
ImGui.TextColored(new Vector4(0.3f, 0.8f, 1f, 1f), "Quest Pre-Check");
ImGui.Separator();
ImGui.Spacing();
bool enableQuestPreCheck = configuration.EnableQuestPreCheck;
if (ImGui.Checkbox("Enable Quest Pre-Check", ref enableQuestPreCheck))
{
configuration.EnableQuestPreCheck = enableQuestPreCheck;
configuration.Save();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Check quest completion status before starting rotation to skip completed characters");
}
ImGui.TextWrapped("Quest Pre-Check scans all characters for completed quests before rotation starts, preventing unnecessary character switches.");
ImGui.Spacing();
ImGui.Spacing();
ImGui.TextColored(new Vector4(0.3f, 0.8f, 1f, 1f), "DC Travel World Selector");
ImGui.Separator();
ImGui.Spacing();
ImGui.TextWrapped("Configure world travel for Data Center travel quests. Requires Lifestream plugin.");
ImGui.Spacing();
string[] datacenters = configuration.WorldsByDatacenter.Keys.ToArray();
int currentDCIndex = Array.IndexOf(datacenters, configuration.SelectedDatacenter);
if (currentDCIndex < 0)
{
currentDCIndex = 0;
}
ImGui.Text("Select Datacenter:");
if (ImGui.Combo((ImU8String)"##DCSelector", ref currentDCIndex, (ReadOnlySpan<string>)datacenters, datacenters.Length))
{
configuration.SelectedDatacenter = datacenters[currentDCIndex];
if (configuration.WorldsByDatacenter.TryGetValue(configuration.SelectedDatacenter, out List<string> newWorlds) && newWorlds.Count > 0)
{
configuration.DCTravelWorld = newWorlds[0];
}
configuration.Save();
}
ImGui.Spacing();
if (configuration.WorldsByDatacenter.TryGetValue(configuration.SelectedDatacenter, out List<string> worlds))
{
string[] worldArray = worlds.ToArray();
int currentWorldIndex = Array.IndexOf(worldArray, configuration.DCTravelWorld);
if (currentWorldIndex < 0)
{
currentWorldIndex = 0;
}
ImGui.Text("Select Target World:");
if (ImGui.Combo((ImU8String)"##WorldSelector", ref currentWorldIndex, (ReadOnlySpan<string>)worldArray, worldArray.Length))
{
configuration.DCTravelWorld = worldArray[currentWorldIndex];
configuration.EnableDCTravel = !string.IsNullOrEmpty(configuration.DCTravelWorld);
configuration.Save();
}
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
LifestreamIPC lifestreamIPC = Plugin.Instance?.LifestreamIPC;
if (lifestreamIPC != null && !lifestreamIPC.IsAvailable)
{
lifestreamIPC.ForceCheckAvailability();
}
bool lifestreamAvailable = lifestreamIPC?.IsAvailable ?? false;
if (!lifestreamAvailable)
{
ImGui.BeginDisabled();
}
bool enableDCTravel = configuration.EnableDCTravel;
if (ImGui.Checkbox("Enable DC Travel", ref enableDCTravel))
{
configuration.EnableDCTravel = enableDCTravel;
configuration.Save();
}
if (!lifestreamAvailable)
{
ImGui.EndDisabled();
}
if (ImGui.IsItemHovered())
{
if (!lifestreamAvailable)
{
ImGui.SetTooltip("Lifestream plugin is required for DC Travel!\nPlease install and enable Lifestream to use this feature.");
}
else
{
ImGui.SetTooltip("Enable automatic DC travel when DC travel quests are detected");
}
}
if (!lifestreamAvailable)
{
ImGui.Spacing();
ImGui.TextColored(new Vector4(1f, 0.5f, 0f, 1f), "⚠\ufe0f Lifestream plugin not available!");
ImGui.TextWrapped("DC Travel requires Lifestream to be installed and enabled.");
}
ImGui.Spacing();
if (configuration.EnableDCTravel && !string.IsNullOrEmpty(configuration.DCTravelWorld))
{
Vector4 col = new Vector4(0.2f, 1f, 0.2f, 1f);
ImU8String text2 = new ImU8String(21, 1);
text2.AppendLiteral("✓ DC Travel ACTIVE → ");
text2.AppendFormatted(configuration.DCTravelWorld);
ImGui.TextColored(in col, text2);
ImU8String text3 = new ImU8String(100, 1);
text3.AppendLiteral("Character will travel to ");
text3.AppendFormatted(configuration.DCTravelWorld);
text3.AppendLiteral(" immediately after login, then return to homeworld before character switch.");
ImGui.TextWrapped(text3);
}
else if (!configuration.EnableDCTravel)
{
ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.7f, 1f), "○ DC Travel disabled");
}
else
{
ImGui.TextColored(new Vector4(1f, 0.5f, 0f, 1f), "⚠ DC Travel enabled but no world selected!");
}
}
else
{
ImGui.TextColored(new Vector4(1f, 0.5f, 0f, 1f), "No worlds available for selected datacenter");
}
}
}

View file

@ -0,0 +1,161 @@
using System;
using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.Windowing;
using QuestionableCompanion.Services;
namespace QuestionableCompanion.Windows;
public class DebugWindow : Window, IDisposable
{
private readonly Plugin plugin;
private readonly CombatDutyDetectionService? combatDutyDetection;
private readonly DeathHandlerService? deathHandler;
private readonly DungeonAutomationService? dungeonAutomation;
public DebugWindow(Plugin plugin, CombatDutyDetectionService? combatDutyDetection, DeathHandlerService? deathHandler, DungeonAutomationService? dungeonAutomation)
: base("QST Companion Debug###QSTDebug", ImGuiWindowFlags.NoCollapse)
{
this.plugin = plugin;
this.combatDutyDetection = combatDutyDetection;
this.deathHandler = deathHandler;
this.dungeonAutomation = dungeonAutomation;
base.Size = new Vector2(500f, 400f);
base.SizeCondition = ImGuiCond.FirstUseEver;
}
public void Dispose()
{
}
public override void Draw()
{
ImGui.TextColored(new Vector4(1f, 0.5f, 0f, 1f), " DEBUG MENU - FOR TESTING ONLY ");
ImGui.Separator();
ImGui.Spacing();
ImGui.TextColored(new Vector4(1f, 0.3f, 0.3f, 1f), "Combat Handling");
ImGui.Separator();
if (combatDutyDetection != null)
{
ImU8String text = new ImU8String(11, 1);
text.AppendLiteral("In Combat: ");
text.AppendFormatted(combatDutyDetection.IsInCombat);
ImGui.Text(text);
ImU8String text2 = new ImU8String(9, 1);
text2.AppendLiteral("In Duty: ");
text2.AppendFormatted(combatDutyDetection.IsInDuty);
ImGui.Text(text2);
ImU8String text3 = new ImU8String(15, 1);
text3.AppendLiteral("In Duty Queue: ");
text3.AppendFormatted(combatDutyDetection.IsInDutyQueue);
ImGui.Text(text3);
ImU8String text4 = new ImU8String(14, 1);
text4.AppendLiteral("Should Pause: ");
text4.AppendFormatted(combatDutyDetection.ShouldPauseAutomation);
ImGui.Text(text4);
ImGui.Spacing();
if (ImGui.Button("Test Combat Detection"))
{
combatDutyDetection.Update();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Manually trigger combat detection update");
}
ImGui.SameLine();
if (ImGui.Button("Reset Combat State"))
{
combatDutyDetection.Reset();
}
}
else
{
ImGui.TextColored(new Vector4(1f, 0f, 0f, 1f), "Combat Detection Service not available");
}
ImGui.Spacing();
ImGui.Spacing();
ImGui.TextColored(new Vector4(1f, 0.3f, 0.3f, 1f), "Death Handling");
ImGui.Separator();
if (deathHandler != null)
{
ImU8String text5 = new ImU8String(9, 1);
text5.AppendLiteral("Is Dead: ");
text5.AppendFormatted(deathHandler.IsDead);
ImGui.Text(text5);
ImU8String text6 = new ImU8String(19, 1);
text6.AppendLiteral("Time Since Death: ");
text6.AppendFormatted(deathHandler.TimeSinceDeath.TotalSeconds, "F1");
text6.AppendLiteral("s");
ImGui.Text(text6);
ImGui.Spacing();
if (ImGui.Button("Test Death Detection"))
{
deathHandler.Update();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Manually trigger death detection update");
}
ImGui.SameLine();
if (ImGui.Button("Reset Death State"))
{
deathHandler.Reset();
}
}
else
{
ImGui.TextColored(new Vector4(1f, 0f, 0f, 1f), "Death Handler Service not available");
}
ImGui.Spacing();
ImGui.Spacing();
ImGui.TextColored(new Vector4(1f, 0.3f, 0.3f, 1f), "Dungeon Automation");
ImGui.Separator();
if (dungeonAutomation != null)
{
ImU8String text7 = new ImU8String(19, 1);
text7.AppendLiteral("Waiting for Party: ");
text7.AppendFormatted(dungeonAutomation.IsWaitingForParty);
ImGui.Text(text7);
ImU8String text8 = new ImU8String(20, 1);
text8.AppendLiteral("Current Party Size: ");
text8.AppendFormatted(dungeonAutomation.CurrentPartySize);
ImGui.Text(text8);
ImGui.Spacing();
if (ImGui.Button("Test Party Invite"))
{
dungeonAutomation.StartDungeonAutomation();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Test BTB enable + invite + party wait");
}
ImGui.SameLine();
if (ImGui.Button("Test Party Disband"))
{
dungeonAutomation.DisbandParty();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Test party disband with /pcmd breakup");
}
ImGui.SameLine();
if (ImGui.Button("Reset Dungeon State"))
{
dungeonAutomation.Reset();
}
}
else
{
ImGui.TextColored(new Vector4(1f, 0f, 0f, 1f), "Dungeon Automation Service not available");
}
ImGui.Spacing();
ImGui.Spacing();
ImGui.Separator();
ImGui.TextColored(new Vector4(0.5f, 0.8f, 1f, 1f), "Info");
ImGui.Text("This debug menu allows testing of individual features.");
ImGui.Text("Use /qstcomp dbg to toggle this window.");
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,17 @@
using System;
using System.Collections.Generic;
using QuestionableCompanion.Models;
namespace QuestionableCompanion;
[Serializable]
public class AlliedSocietySettings
{
public AlliedSocietyConfiguration RotationConfig { get; set; } = new AlliedSocietyConfiguration();
public Dictionary<string, AlliedSocietyCharacterStatus> CharacterStatuses { get; set; } = new Dictionary<string, AlliedSocietyCharacterStatus>();
public Dictionary<string, List<AlliedSocietyProgress>> CharacterProgress { get; set; } = new Dictionary<string, List<AlliedSocietyProgress>>();
public DateTime LastResetDate { get; set; } = DateTime.MinValue;
}

View file

@ -0,0 +1,211 @@
using System;
using System.Collections.Generic;
using Dalamud.Configuration;
using QuestionableCompanion.Models;
namespace QuestionableCompanion;
[Serializable]
public class Configuration : IPluginConfiguration
{
public int Version { get; set; } = 1;
public bool IsConfigWindowMovable { get; set; } = true;
public bool ShowDebugLogs { get; set; }
public List<QuestProfile> Profiles { get; set; } = new List<QuestProfile>();
public string ActiveProfileName { get; set; } = string.Empty;
public AlliedSocietySettings AlliedSociety { get; set; } = new AlliedSocietySettings();
public bool AutoStartOnLogin { get; set; }
public bool EnableDryRun { get; set; }
public int MaxRetryAttempts { get; set; } = 3;
public int CharacterSwitchDelay { get; set; } = 5;
public int MaxLogEntries { get; set; } = 100;
public bool LogToFile { get; set; }
public ExecutionState LastExecutionState { get; set; } = new ExecutionState();
public bool RestoreStateOnLoad { get; set; }
public List<StopPoint> StopPoints { get; set; } = new List<StopPoint>();
public RotationState LastRotationState { get; set; } = new RotationState();
public List<string> SelectedCharactersForRotation { get; set; } = new List<string>();
public Dictionary<uint, List<string>> QuestCompletionByCharacter { get; set; } = new Dictionary<uint, List<string>>();
public Dictionary<string, List<string>> EventQuestCompletionByCharacter { get; set; } = new Dictionary<string, List<string>>();
public string CurrentEventQuestId { get; set; } = string.Empty;
public List<string> SelectedCharactersForEventQuest { get; set; } = new List<string>();
public bool RunEventQuestsOnARPostProcess { get; set; }
public List<string> EventQuestsToRunOnPostProcess { get; set; } = new List<string>();
public int EventQuestPostProcessTimeoutMinutes { get; set; } = 30;
public bool EnableSubmarineCheck { get; set; }
public int SubmarineCheckInterval { get; set; } = 90;
public int SubmarineReloginCooldown { get; set; } = 120;
public int SubmarineWaitTime { get; set; } = 30;
public bool EnableAutoDutyUnsynced { get; set; }
public int AutoDutyPartySize { get; set; } = 2;
public int AutoDutyMaxWaitForParty { get; set; } = 30;
public int AutoDutyReInviteInterval { get; set; } = 10;
public bool EnableQSTReloadTracking { get; set; }
public int MaxQSTReloadsBeforeSwitch { get; set; } = 5;
public bool EnableDCTravel { get; set; }
public string DCTravelWorld { get; set; } = "";
public bool EnableMovementMonitor { get; set; }
public int MovementCheckInterval { get; set; } = 5;
public int MovementStuckThreshold { get; set; } = 30;
public bool EnableCombatHandling { get; set; }
public int CombatHPThreshold { get; set; } = 50;
public bool EnableDeathHandling { get; set; }
public int DeathRespawnDelay { get; set; } = 5;
public bool LogToDalamud { get; set; }
public MSQDisplayMode MSQDisplayMode { get; set; } = MSQDisplayMode.Overall;
public bool ShowPatchVersion { get; set; }
public string DCTravelDataCenter { get; set; } = "";
public string DCTravelTargetWorld { get; set; } = "";
public bool EnableDCTravelFeature { get; set; }
public bool EnableMultiModeAfterRotation { get; set; }
public bool ReturnToHomeworldOnStopQuest { get; set; }
public bool IsHighLevelHelper { get; set; }
public bool IsQuester { get; set; }
public List<HighLevelHelperConfig> HighLevelHelpers { get; set; } = new List<HighLevelHelperConfig>();
public bool ChauffeurModeEnabled { get; set; }
public float ChauffeurDistanceThreshold { get; set; } = 100f;
public float ChauffeurStopDistance { get; set; } = 5f;
public uint ChauffeurMountId { get; set; }
public string PreferredHelper { get; set; } = "";
public string AssignedQuester { get; set; } = "";
public HelperStatus CurrentHelperStatus { get; set; }
public bool EnableHelperFollowing { get; set; }
public float HelperFollowDistance { get; set; } = 100f;
public int HelperFollowCheckInterval { get; set; } = 5;
public string AssignedQuesterForFollowing { get; set; } = "";
public string AssignedHelperForFollowing { get; set; } = "";
public bool EnableSafeWaitBeforeCharacterSwitch { get; set; }
public bool EnableSafeWaitAfterCharacterSwitch { get; set; }
public bool EnableQuestPreCheck { get; set; }
public List<uint>? QuestPreCheckRange { get; set; }
public string SelectedDatacenter { get; set; } = "NA";
public Dictionary<string, List<string>> WorldsByDatacenter { get; set; } = new Dictionary<string, List<string>>
{
{
"NA",
new List<string>
{
"Adamantoise", "Cactuar", "Faerie", "Gilgamesh", "Jenova", "Midgardsormr", "Sargatanas", "Siren", "Behemoth", "Excalibur",
"Exodus", "Famfrit", "Hyperion", "Lamia", "Leviathan", "Ultros", "Balmung", "Brynhildr", "Coeurl", "Diabolos",
"Goblin", "Malboro", "Mateus", "Zalera", "Halicarnassus", "Maduin", "Marilith", "Seraph"
}
},
{
"EU",
new List<string>
{
"Cerberus", "Louisoix", "Moogle", "Omega", "Phantom", "Ragnarok", "Sagittarius", "Spriggan", "Alpha", "Lich",
"Odin", "Phoenix", "Raiden", "Shiva", "Twintania", "Zodiark"
}
},
{
"JP",
new List<string>
{
"Aegis", "Atomos", "Carbuncle", "Garuda", "Gungnir", "Kujata", "Tonberry", "Typhon", "Alexander", "Bahamut",
"Durandal", "Fenrir", "Ifrit", "Ridill", "Tiamat", "Ultima", "Anima", "Asura", "Chocobo", "Hades",
"Ixion", "Masamune", "Pandaemonium", "Titan", "Gaia", "Belias", "Mandragora", "Ramuh", "Shinryu", "Unicorn",
"Valefor", "Yojimbo", "Zeromus"
}
},
{
"OCE",
new List<string> { "Bismarck", "Ravana", "Sephirot", "Sophia", "Zurvan" }
}
};
public void Save()
{
Plugin.PluginInterface.SavePluginConfig(this);
}
public QuestProfile? GetActiveProfile()
{
return Profiles.Find((QuestProfile p) => p.Name == ActiveProfileName);
}
public void EnsureDefaultProfile()
{
if (Profiles.Count == 0)
{
QuestProfile defaultProfile = new QuestProfile
{
Name = "Default Profile",
IsActive = true
};
Profiles.Add(defaultProfile);
ActiveProfileName = defaultProfile.Name;
}
}
}

View file

@ -0,0 +1,8 @@
namespace QuestionableCompanion;
public enum HelperStatus
{
Available,
Transporting,
InDungeon
}

View file

@ -0,0 +1,13 @@
using System;
namespace QuestionableCompanion;
[Serializable]
public class HighLevelHelperConfig
{
public string CharacterName { get; set; } = string.Empty;
public ushort WorldId { get; set; }
public string WorldName { get; set; } = string.Empty;
}

View file

@ -0,0 +1,8 @@
namespace QuestionableCompanion;
public enum MSQDisplayMode
{
CurrentExpansion,
Overall,
ExpansionBreakdown
}

File diff suppressed because it is too large Load diff