forked from aly/qstbak
qstcompanion v1.0.6
This commit is contained in:
parent
5e1e1decc5
commit
ada27cf05b
30 changed files with 3403 additions and 426 deletions
|
|
@ -0,0 +1,393 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Dalamud.Game.ClientState.Conditions;
|
||||
using Dalamud.Game.Text;
|
||||
using Dalamud.Plugin.Services;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game.UI;
|
||||
|
||||
namespace QuestionableCompanion.Services;
|
||||
|
||||
public class ARRTrialAutomationService : IDisposable
|
||||
{
|
||||
private readonly IPluginLog log;
|
||||
|
||||
private readonly IFramework framework;
|
||||
|
||||
private readonly ICommandManager commandManager;
|
||||
|
||||
private readonly IChatGui chatGui;
|
||||
|
||||
private readonly Configuration config;
|
||||
|
||||
private readonly QuestionableIPC questionableIPC;
|
||||
|
||||
private readonly SubmarineManager submarineManager;
|
||||
|
||||
private readonly HelperManager helperManager;
|
||||
|
||||
private readonly IPartyList partyList;
|
||||
|
||||
private readonly ICondition condition;
|
||||
|
||||
private readonly MemoryHelper memoryHelper;
|
||||
|
||||
private bool isInDuty;
|
||||
|
||||
private static readonly (uint QuestId, uint TrialId, string ADCommand, string Name)[] Trials = new(uint, uint, string, string)[3]
|
||||
{
|
||||
(1048u, 20004u, "/ad run trial 292 1", "Ifrit HM"),
|
||||
(1157u, 20006u, "/ad run trial 294 1", "Garuda HM"),
|
||||
(1158u, 20005u, "/ad run trial 293 1", "Titan HM")
|
||||
};
|
||||
|
||||
private const uint TRIGGER_QUEST = 89u;
|
||||
|
||||
private const uint TARGET_QUEST = 363u;
|
||||
|
||||
private bool isProcessing;
|
||||
|
||||
private int currentTrialIndex = -1;
|
||||
|
||||
private bool waitingForQuest;
|
||||
|
||||
private bool waitingForParty;
|
||||
|
||||
private bool waitingForTrial;
|
||||
|
||||
private DateTime lastCheckTime = DateTime.MinValue;
|
||||
|
||||
public ARRTrialAutomationService(IPluginLog log, IFramework framework, ICommandManager commandManager, IChatGui chatGui, Configuration config, QuestionableIPC questionableIPC, SubmarineManager submarineManager, HelperManager helperManager, IPartyList partyList, ICondition condition, MemoryHelper memoryHelper)
|
||||
{
|
||||
this.log = log;
|
||||
this.framework = framework;
|
||||
this.commandManager = commandManager;
|
||||
this.chatGui = chatGui;
|
||||
this.config = config;
|
||||
this.questionableIPC = questionableIPC;
|
||||
this.submarineManager = submarineManager;
|
||||
this.helperManager = helperManager;
|
||||
this.partyList = partyList;
|
||||
this.condition = condition;
|
||||
this.memoryHelper = memoryHelper;
|
||||
framework.Update += OnFrameworkUpdate;
|
||||
condition.ConditionChange += OnConditionChanged;
|
||||
log.Information("[ARRTrials] Service initialized");
|
||||
}
|
||||
|
||||
private void OnFrameworkUpdate(IFramework framework)
|
||||
{
|
||||
if (!isProcessing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (waitingForParty && partyList != null && partyList.Length > 1)
|
||||
{
|
||||
if (!((DateTime.Now - lastCheckTime).TotalSeconds < 1.0))
|
||||
{
|
||||
lastCheckTime = DateTime.Now;
|
||||
log.Information($"[ARRTrials] Party join detected (Size: {partyList.Length}) - Triggering trial...");
|
||||
waitingForParty = false;
|
||||
TriggerCurrentTrial();
|
||||
}
|
||||
}
|
||||
else if (waitingForQuest && currentTrialIndex >= 0 && currentTrialIndex < Trials.Length && !((DateTime.Now - lastCheckTime).TotalSeconds < 2.0))
|
||||
{
|
||||
lastCheckTime = DateTime.Now;
|
||||
(uint QuestId, uint TrialId, string ADCommand, string Name) tuple = Trials[currentTrialIndex];
|
||||
uint trialId = tuple.TrialId;
|
||||
string name = tuple.Name;
|
||||
bool unlocked = IsTrialUnlocked(trialId);
|
||||
log.Debug($"[ARRTrials] Polling {name} ({trialId}) Unlocked: {unlocked}");
|
||||
if (unlocked)
|
||||
{
|
||||
log.Information("[ARRTrials] Polling detected " + name + " unlocked - Proceeding...");
|
||||
waitingForQuest = false;
|
||||
helperManager.InviteHelpers();
|
||||
waitingForParty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsTrialComplete(uint instanceId)
|
||||
{
|
||||
return UIState.IsInstanceContentCompleted(instanceId);
|
||||
}
|
||||
|
||||
public bool IsTrialUnlocked(uint instanceId)
|
||||
{
|
||||
return UIState.IsInstanceContentUnlocked(instanceId);
|
||||
}
|
||||
|
||||
public bool IsTargetQuestAvailableOrComplete()
|
||||
{
|
||||
if (QuestManager.IsQuestComplete(363u))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (questionableIPC.IsReadyToAcceptQuest(363u.ToString()))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void OnTriggerQuestComplete()
|
||||
{
|
||||
if (!config.EnableARRPrimalCheck)
|
||||
{
|
||||
log.Debug("[ARRTrials] Feature disabled, skipping check");
|
||||
return;
|
||||
}
|
||||
log.Information("[ARRTrials] Quest 89 complete, starting ARR Primal check...");
|
||||
StartTrialChain();
|
||||
}
|
||||
|
||||
public void StartTrialChain()
|
||||
{
|
||||
if (isProcessing)
|
||||
{
|
||||
log.Debug("[ARRTrials] Already processing trial chain");
|
||||
return;
|
||||
}
|
||||
isProcessing = true;
|
||||
submarineManager.SetExternalPause(paused: true);
|
||||
int startIndex = -1;
|
||||
for (int i = Trials.Length - 1; i >= 0; i--)
|
||||
{
|
||||
if (!IsTrialComplete(Trials[i].TrialId))
|
||||
{
|
||||
for (int j = 0; j <= i; j++)
|
||||
{
|
||||
if (!IsTrialComplete(Trials[j].TrialId))
|
||||
{
|
||||
startIndex = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (startIndex == -1)
|
||||
{
|
||||
log.Information("[ARRTrials] All trials already complete!");
|
||||
isProcessing = false;
|
||||
submarineManager.SetExternalPause(paused: false);
|
||||
return;
|
||||
}
|
||||
currentTrialIndex = startIndex;
|
||||
log.Information($"[ARRTrials] Starting from trial index {startIndex}: {Trials[startIndex].Name}");
|
||||
ProcessCurrentTrial();
|
||||
}
|
||||
|
||||
private void ProcessCurrentTrial()
|
||||
{
|
||||
if (currentTrialIndex < 0 || currentTrialIndex >= Trials.Length)
|
||||
{
|
||||
log.Information("[ARRTrials] Trial chain complete!");
|
||||
isProcessing = false;
|
||||
submarineManager.SetExternalPause(paused: false);
|
||||
return;
|
||||
}
|
||||
var (questId, trialId, _, name) = Trials[currentTrialIndex];
|
||||
if (IsTrialComplete(trialId))
|
||||
{
|
||||
log.Information("[ARRTrials] " + name + " already complete, moving to next");
|
||||
currentTrialIndex++;
|
||||
ProcessCurrentTrial();
|
||||
}
|
||||
else if (!QuestManager.IsQuestComplete(questId))
|
||||
{
|
||||
log.Information($"[ARRTrials] Queueing unlock quest {questId} for {name}");
|
||||
questionableIPC.AddQuestPriority(questId.ToString());
|
||||
framework.RunOnFrameworkThread(delegate
|
||||
{
|
||||
commandManager.ProcessCommand("/qst start");
|
||||
});
|
||||
waitingForQuest = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
log.Information("[ARRTrials] " + name + " unlocked, inviting helper and triggering trial...");
|
||||
helperManager.InviteHelpers();
|
||||
waitingForParty = true;
|
||||
}
|
||||
}
|
||||
|
||||
public void OnQuestComplete(uint questId)
|
||||
{
|
||||
if (!isProcessing || !waitingForQuest)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (int i = currentTrialIndex; i < Trials.Length; i++)
|
||||
{
|
||||
if (Trials[i].QuestId == questId)
|
||||
{
|
||||
log.Information($"[ARRTrials] Unlock quest {questId} completed, triggering trial");
|
||||
waitingForQuest = false;
|
||||
helperManager.InviteHelpers();
|
||||
waitingForParty = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void OnPartyReady()
|
||||
{
|
||||
if (isProcessing && waitingForParty)
|
||||
{
|
||||
waitingForParty = false;
|
||||
TriggerCurrentTrial();
|
||||
}
|
||||
}
|
||||
|
||||
private void TriggerCurrentTrial()
|
||||
{
|
||||
if (currentTrialIndex >= 0 && currentTrialIndex < Trials.Length)
|
||||
{
|
||||
(uint, uint, string, string) tuple = Trials[currentTrialIndex];
|
||||
string adCommand = tuple.Item3;
|
||||
string name = tuple.Item4;
|
||||
log.Information("[ARRTrials] Triggering " + name + " via AD command");
|
||||
framework.RunOnFrameworkThread(delegate
|
||||
{
|
||||
chatGui.Print(new XivChatEntry
|
||||
{
|
||||
Message = "[QSTCompanion] Triggering " + name + "...",
|
||||
Type = XivChatType.Echo
|
||||
});
|
||||
commandManager.ProcessCommand("/ad cfg Unsynced true");
|
||||
commandManager.ProcessCommand(adCommand);
|
||||
});
|
||||
waitingForTrial = true;
|
||||
}
|
||||
}
|
||||
|
||||
public void OnDutyComplete()
|
||||
{
|
||||
if (!isProcessing || !waitingForTrial)
|
||||
{
|
||||
return;
|
||||
}
|
||||
(uint QuestId, uint TrialId, string ADCommand, string Name) tuple = Trials[currentTrialIndex];
|
||||
uint trialId = tuple.TrialId;
|
||||
string name = tuple.Name;
|
||||
if (IsTrialComplete(trialId))
|
||||
{
|
||||
log.Information("[ARRTrials] " + name + " completed successfully!");
|
||||
waitingForTrial = false;
|
||||
currentTrialIndex++;
|
||||
framework.RunOnFrameworkThread(delegate
|
||||
{
|
||||
ProcessCurrentTrial();
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
log.Warning("[ARRTrials] " + name + " NOT complete after verification. Retrying current step...");
|
||||
waitingForTrial = false;
|
||||
framework.RunOnFrameworkThread(delegate
|
||||
{
|
||||
ProcessCurrentTrial();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public string GetStatus()
|
||||
{
|
||||
if (!isProcessing)
|
||||
{
|
||||
return "Idle";
|
||||
}
|
||||
if (currentTrialIndex >= 0 && currentTrialIndex < Trials.Length)
|
||||
{
|
||||
string name = Trials[currentTrialIndex].Name;
|
||||
if (waitingForQuest)
|
||||
{
|
||||
return "Waiting for " + name + " unlock quest";
|
||||
}
|
||||
if (waitingForParty)
|
||||
{
|
||||
return "Waiting for party (" + name + ")";
|
||||
}
|
||||
if (waitingForTrial)
|
||||
{
|
||||
return "In " + name;
|
||||
}
|
||||
return "Processing " + name;
|
||||
}
|
||||
return "Processing...";
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
isProcessing = false;
|
||||
currentTrialIndex = -1;
|
||||
waitingForQuest = false;
|
||||
waitingForParty = false;
|
||||
waitingForTrial = false;
|
||||
submarineManager.SetExternalPause(paused: false);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
framework.Update -= OnFrameworkUpdate;
|
||||
condition.ConditionChange -= OnConditionChanged;
|
||||
log.Information("[ARRTrials] Service disposed");
|
||||
}
|
||||
|
||||
private void OnConditionChanged(ConditionFlag flag, bool value)
|
||||
{
|
||||
if (flag == ConditionFlag.BoundByDuty)
|
||||
{
|
||||
if (value && !isInDuty)
|
||||
{
|
||||
isInDuty = true;
|
||||
log.Debug("[ARRTrials] Entered duty");
|
||||
}
|
||||
else if (!value && isInDuty)
|
||||
{
|
||||
isInDuty = false;
|
||||
OnDutyExited();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDutyExited()
|
||||
{
|
||||
if (!isProcessing || !waitingForTrial)
|
||||
{
|
||||
return;
|
||||
}
|
||||
log.Information("[ARRTrials] Exited duty - stopping AD and disbanding...");
|
||||
framework.RunOnFrameworkThread(delegate
|
||||
{
|
||||
commandManager.ProcessCommand("/ad stop");
|
||||
});
|
||||
Task.Run(async delegate
|
||||
{
|
||||
await Task.Delay(2000);
|
||||
framework.RunOnFrameworkThread(delegate
|
||||
{
|
||||
memoryHelper.SendChatMessage("/leave");
|
||||
commandManager.ProcessCommand("/ad stop");
|
||||
log.Information("[ARRTrials] /leave and safety /ad stop sent");
|
||||
});
|
||||
log.Information("[ARRTrials] Waiting for completion state check...");
|
||||
await Task.Delay(1000);
|
||||
(uint, uint, string, string) tuple = Trials[currentTrialIndex];
|
||||
uint trialId = tuple.Item2;
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
if (IsTrialComplete(trialId))
|
||||
{
|
||||
log.Information($"[ARRTrials] Completion verified on attempt {i + 1}");
|
||||
break;
|
||||
}
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
OnDutyComplete();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,13 @@ namespace QuestionableCompanion.Services;
|
|||
|
||||
public class CombatDutyDetectionService : IDisposable
|
||||
{
|
||||
private enum MultiClientRole
|
||||
{
|
||||
None,
|
||||
Quester,
|
||||
Helper
|
||||
}
|
||||
|
||||
private readonly ICondition condition;
|
||||
|
||||
private readonly IPluginLog log;
|
||||
|
|
@ -143,7 +150,7 @@ public class CombatDutyDetectionService : IDisposable
|
|||
if (player != null)
|
||||
{
|
||||
float hpPercent = (float)player.CurrentHp / (float)player.MaxHp * 100f;
|
||||
if (hpPercent <= (float)config.CombatHPThreshold)
|
||||
if (hpPercent <= (float)config.CombatHPThreshold && CanExecuteCombatAutomation())
|
||||
{
|
||||
log.Warning($"[CombatDuty] HP at {hpPercent:F1}% (threshold: {config.CombatHPThreshold}%) - enabling combat commands");
|
||||
EnableCombatCommands();
|
||||
|
|
@ -231,6 +238,19 @@ public class CombatDutyDetectionService : IDisposable
|
|||
{
|
||||
return;
|
||||
}
|
||||
if (!CanExecuteCombatAutomation())
|
||||
{
|
||||
switch (GetCurrentMultiClientRole())
|
||||
{
|
||||
case MultiClientRole.None:
|
||||
log.Debug("[CombatDuty] Combat blocked: Role is 'None' (Config not Helper/Quester)");
|
||||
break;
|
||||
case MultiClientRole.Quester:
|
||||
log.Debug("[CombatDuty] Combat blocked: Quester outside Solo Duty (Let D.Automation handle invalid content)");
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
log.Information("[CombatDuty] ========================================");
|
||||
|
|
@ -358,6 +378,67 @@ public class CombatDutyDetectionService : IDisposable
|
|||
log.Information("[CombatDuty] State reset");
|
||||
}
|
||||
|
||||
private MultiClientRole GetCurrentMultiClientRole()
|
||||
{
|
||||
if (config.IsHighLevelHelper)
|
||||
{
|
||||
return MultiClientRole.Helper;
|
||||
}
|
||||
if (config.IsQuester)
|
||||
{
|
||||
return MultiClientRole.Quester;
|
||||
}
|
||||
return MultiClientRole.None;
|
||||
}
|
||||
|
||||
private bool IsInSoloDuty()
|
||||
{
|
||||
if (condition[ConditionFlag.BoundByDuty95])
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (IsInDuty && !isInAutoDutyDungeon)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool CanExecuteCombatAutomation()
|
||||
{
|
||||
switch (GetCurrentMultiClientRole())
|
||||
{
|
||||
case MultiClientRole.None:
|
||||
if (!IsInDuty)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case MultiClientRole.Helper:
|
||||
return true;
|
||||
case MultiClientRole.Quester:
|
||||
if (!IsInDuty)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (IsInSoloDuty())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (currentQuestId == 811)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (currentQuestId == 4591)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (combatCommandsActive)
|
||||
|
|
|
|||
|
|
@ -48,16 +48,6 @@ public class DCTravelService : IDisposable
|
|||
log.Information("[DCTravel] Config.DCTravelWorld: '" + config.DCTravelWorld + "'");
|
||||
log.Information($"[DCTravel] State.dcTravelCompleted: {dcTravelCompleted}");
|
||||
log.Information($"[DCTravel] State.dcTravelInProgress: {dcTravelInProgress}");
|
||||
if (dcTravelCompleted)
|
||||
{
|
||||
log.Warning("[DCTravel] SKIP: Already completed for this character");
|
||||
return false;
|
||||
}
|
||||
if (dcTravelInProgress)
|
||||
{
|
||||
log.Warning("[DCTravel] SKIP: Travel already in progress");
|
||||
return false;
|
||||
}
|
||||
if (!config.EnableDCTravel)
|
||||
{
|
||||
log.Warning("[DCTravel] SKIP: DC Travel is DISABLED in config");
|
||||
|
|
@ -78,9 +68,25 @@ public class DCTravelService : IDisposable
|
|||
log.Information("[DCTravel] Target World: '" + config.DCTravelWorld + "'");
|
||||
if (currentWorld.Equals(config.DCTravelWorld, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!dcTravelCompleted)
|
||||
{
|
||||
log.Information("[DCTravel] Character is already on target world - marking as completed");
|
||||
dcTravelCompleted = true;
|
||||
}
|
||||
log.Warning("[DCTravel] SKIP: Already on target world '" + config.DCTravelWorld + "'");
|
||||
return false;
|
||||
}
|
||||
if (dcTravelCompleted)
|
||||
{
|
||||
log.Warning("[DCTravel] State says completed but character is NOT on target world!");
|
||||
log.Warning("[DCTravel] Resetting state - will perform DC Travel");
|
||||
dcTravelCompleted = false;
|
||||
}
|
||||
if (dcTravelInProgress)
|
||||
{
|
||||
log.Warning("[DCTravel] SKIP: Travel already in progress");
|
||||
return false;
|
||||
}
|
||||
log.Information("[DCTravel] ========================================");
|
||||
log.Information("[DCTravel] DC TRAVEL WILL BE PERFORMED!");
|
||||
log.Information("[DCTravel] ========================================");
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ public class DungeonAutomationService : IDisposable
|
|||
|
||||
private readonly QuestionableIPC questionableIPC;
|
||||
|
||||
private readonly CrossProcessIPC crossProcessIPC;
|
||||
|
||||
private readonly MultiClientIPC multiClientIPC;
|
||||
|
||||
private bool isWaitingForParty;
|
||||
|
||||
private DateTime partyInviteTime = DateTime.MinValue;
|
||||
|
|
@ -55,6 +59,10 @@ public class DungeonAutomationService : IDisposable
|
|||
|
||||
private bool isAutomationActive;
|
||||
|
||||
private int originalDutyMode;
|
||||
|
||||
private Func<bool>? isRotationActiveChecker;
|
||||
|
||||
private bool hasSentAtY;
|
||||
|
||||
public bool IsWaitingForParty => isWaitingForParty;
|
||||
|
|
@ -63,7 +71,30 @@ public class DungeonAutomationService : IDisposable
|
|||
|
||||
public bool IsInAutoDutyDungeon => isAutomationActive;
|
||||
|
||||
public DungeonAutomationService(ICondition condition, IPluginLog log, IClientState clientState, ICommandManager commandManager, IFramework framework, IGameGui gameGui, Configuration config, HelperManager helperManager, MemoryHelper memoryHelper, QuestionableIPC questionableIPC)
|
||||
public void SetRotationActiveChecker(Func<bool> checker)
|
||||
{
|
||||
isRotationActiveChecker = checker;
|
||||
}
|
||||
|
||||
private bool CanExecuteAutomation()
|
||||
{
|
||||
if (config.IsHighLevelHelper)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (config.IsQuester)
|
||||
{
|
||||
Func<bool>? func = isRotationActiveChecker;
|
||||
if (func == null || !func())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public DungeonAutomationService(ICondition condition, IPluginLog log, IClientState clientState, ICommandManager commandManager, IFramework framework, IGameGui gameGui, Configuration config, HelperManager helperManager, MemoryHelper memoryHelper, QuestionableIPC questionableIPC, CrossProcessIPC crossProcessIPC, MultiClientIPC multiClientIPC)
|
||||
{
|
||||
this.condition = condition;
|
||||
this.log = log;
|
||||
|
|
@ -75,6 +106,8 @@ public class DungeonAutomationService : IDisposable
|
|||
this.helperManager = helperManager;
|
||||
this.memoryHelper = memoryHelper;
|
||||
this.questionableIPC = questionableIPC;
|
||||
this.crossProcessIPC = crossProcessIPC;
|
||||
this.multiClientIPC = multiClientIPC;
|
||||
condition.ConditionChange += OnConditionChanged;
|
||||
log.Information("[DungeonAutomation] Service initialized with ConditionChange event");
|
||||
log.Information($"[DungeonAutomation] Config - Required Party Size: {config.AutoDutyPartySize}");
|
||||
|
|
@ -87,6 +120,11 @@ public class DungeonAutomationService : IDisposable
|
|||
{
|
||||
if (!isAutomationActive)
|
||||
{
|
||||
if (!CanExecuteAutomation())
|
||||
{
|
||||
log.Information("[DungeonAutomation] Start request ignored - validation failed (Check Role/Rotation)");
|
||||
return;
|
||||
}
|
||||
log.Information("[DungeonAutomation] ========================================");
|
||||
log.Information("[DungeonAutomation] === STARTING DUNGEON AUTOMATION ===");
|
||||
log.Information("[DungeonAutomation] ========================================");
|
||||
|
|
@ -148,6 +186,10 @@ public class DungeonAutomationService : IDisposable
|
|||
|
||||
public void Update()
|
||||
{
|
||||
if (!CanExecuteAutomation() && !isAutomationActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (config.EnableAutoDutyUnsynced && !isAutomationActive)
|
||||
{
|
||||
CheckWaitForPartyTask();
|
||||
|
|
@ -268,8 +310,12 @@ public class DungeonAutomationService : IDisposable
|
|||
return;
|
||||
}
|
||||
lastDutyEntryTime = DateTime.Now;
|
||||
log.Information("[DungeonAutomation] Entered duty");
|
||||
if (expectingDutyEntry)
|
||||
log.Debug("[DungeonAutomation] Entered duty");
|
||||
if (!CanExecuteAutomation())
|
||||
{
|
||||
log.Debug("[DungeonAutomation] OnDutyEntered ignored - validation failed");
|
||||
}
|
||||
else if (expectingDutyEntry)
|
||||
{
|
||||
log.Information("[DungeonAutomation] Duty started by DungeonAutomation - enabling automation commands");
|
||||
expectingDutyEntry = false;
|
||||
|
|
@ -297,7 +343,11 @@ public class DungeonAutomationService : IDisposable
|
|||
}
|
||||
lastDutyExitTime = DateTime.Now;
|
||||
log.Information("[DungeonAutomation] Exited duty");
|
||||
if (isAutomationActive)
|
||||
if (!CanExecuteAutomation() && !isAutomationActive)
|
||||
{
|
||||
log.Information("[DungeonAutomation] OnDutyExited ignored - validation failed");
|
||||
}
|
||||
else if (isAutomationActive)
|
||||
{
|
||||
commandManager.ProcessCommand("/at n");
|
||||
log.Information("[DungeonAutomation] Sent /at n (duty exited)");
|
||||
|
|
@ -343,6 +393,11 @@ public class DungeonAutomationService : IDisposable
|
|||
{
|
||||
try
|
||||
{
|
||||
if (!CanExecuteAutomation())
|
||||
{
|
||||
log.Information("[DungeonAutomation] DisbandParty ignored - validation failed");
|
||||
return;
|
||||
}
|
||||
log.Information("[DungeonAutomation] Disbanding party");
|
||||
framework.RunOnFrameworkThread(delegate
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,197 @@
|
|||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using Dalamud.Game.NativeWrapper;
|
||||
using Dalamud.Hooking;
|
||||
using Dalamud.Plugin.Services;
|
||||
using FFXIVClientStructs.FFXIV.Component.GUI;
|
||||
|
||||
namespace QuestionableCompanion.Services;
|
||||
|
||||
public class ErrorRecoveryService : IDisposable
|
||||
{
|
||||
private delegate char LobbyErrorHandlerDelegate(long a1, long a2, long a3);
|
||||
|
||||
private readonly IPluginLog log;
|
||||
|
||||
private readonly IGameInteropProvider hookProvider;
|
||||
|
||||
private readonly IClientState clientState;
|
||||
|
||||
private readonly IFramework framework;
|
||||
|
||||
private readonly IGameGui gameGui;
|
||||
|
||||
private readonly AutoRetainerIPC? autoRetainerIPC;
|
||||
|
||||
private Hook<LobbyErrorHandlerDelegate>? lobbyErrorHandlerHook;
|
||||
|
||||
private DateTime lastDialogClickTime = DateTime.MinValue;
|
||||
|
||||
public bool IsErrorDisconnect { get; private set; }
|
||||
|
||||
public string? LastDisconnectedCharacter { get; private set; }
|
||||
|
||||
public ErrorRecoveryService(IPluginLog log, IGameInteropProvider hookProvider, IClientState clientState, IFramework framework, IGameGui gameGui, AutoRetainerIPC? autoRetainerIPC = null)
|
||||
{
|
||||
this.log = log;
|
||||
this.hookProvider = hookProvider;
|
||||
this.clientState = clientState;
|
||||
this.framework = framework;
|
||||
this.gameGui = gameGui;
|
||||
this.autoRetainerIPC = autoRetainerIPC;
|
||||
framework.Update += OnFrameworkUpdate;
|
||||
InitializeHook();
|
||||
}
|
||||
|
||||
private void InitializeHook()
|
||||
{
|
||||
try
|
||||
{
|
||||
lobbyErrorHandlerHook = hookProvider.HookFromSignature<LobbyErrorHandlerDelegate>("40 53 48 83 EC 30 48 8B D9 49 8B C8 E8 ?? ?? ?? ?? 8B D0", LobbyErrorHandlerDetour);
|
||||
if (lobbyErrorHandlerHook != null && lobbyErrorHandlerHook.Address != IntPtr.Zero)
|
||||
{
|
||||
lobbyErrorHandlerHook.Enable();
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private char LobbyErrorHandlerDetour(long a1, long a2, long a3)
|
||||
{
|
||||
try
|
||||
{
|
||||
nint p3 = new IntPtr(a3);
|
||||
byte t1 = Marshal.ReadByte(p3);
|
||||
int num = (((t1 & 0xF) > 0) ? Marshal.ReadInt32(p3 + 8) : 0);
|
||||
_ = 0;
|
||||
if (num != 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (autoRetainerIPC != null)
|
||||
{
|
||||
string currentChar = autoRetainerIPC.GetCurrentCharacter();
|
||||
if (!string.IsNullOrEmpty(currentChar))
|
||||
{
|
||||
LastDisconnectedCharacter = currentChar;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
Marshal.WriteInt64(p3 + 8, 16000L);
|
||||
IsErrorDisconnect = true;
|
||||
if ((t1 & 0xF) > 0)
|
||||
{
|
||||
Marshal.ReadInt32(p3 + 8);
|
||||
}
|
||||
else
|
||||
_ = 0;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
return lobbyErrorHandlerHook.Original(a1, a2, a3);
|
||||
}
|
||||
|
||||
private unsafe void OnFrameworkUpdate(IFramework framework)
|
||||
{
|
||||
try
|
||||
{
|
||||
AtkUnitBasePtr dialoguePtr = gameGui.GetAddonByName("Dialogue");
|
||||
if (dialoguePtr == IntPtr.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
AtkUnitBase* dialogueAddon = (AtkUnitBase*)(nint)dialoguePtr;
|
||||
if (dialogueAddon == null || !dialogueAddon->IsVisible || (DateTime.Now - lastDialogClickTime).TotalMilliseconds < 1000.0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
AtkTextNode* textNode = dialogueAddon->GetTextNodeById(3u);
|
||||
if (textNode == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
string text = textNode->NodeText.ToString();
|
||||
if (string.IsNullOrEmpty(text) || (!text.Contains("server", StringComparison.OrdinalIgnoreCase) && !text.Contains("connection", StringComparison.OrdinalIgnoreCase) && !text.Contains("error", StringComparison.OrdinalIgnoreCase) && !text.Contains("lost", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
IsErrorDisconnect = true;
|
||||
try
|
||||
{
|
||||
if (autoRetainerIPC != null)
|
||||
{
|
||||
string currentChar = autoRetainerIPC.GetCurrentCharacter();
|
||||
if (!string.IsNullOrEmpty(currentChar))
|
||||
{
|
||||
LastDisconnectedCharacter = currentChar;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
try
|
||||
{
|
||||
AtkComponentButton* button = dialogueAddon->GetComponentButtonById(4u);
|
||||
if (button != null)
|
||||
{
|
||||
AtkResNode btnRes = button->AtkComponentBase.OwnerNode->AtkResNode;
|
||||
AtkEvent* evt = btnRes.AtkEventManager.Event;
|
||||
dialogueAddon->ReceiveEvent(evt->State.EventType, (int)evt->Param, btnRes.AtkEventManager.Event, null);
|
||||
lastDialogClickTime = DateTime.Now;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
IsErrorDisconnect = false;
|
||||
LastDisconnectedCharacter = null;
|
||||
}
|
||||
|
||||
public bool RequestRelog()
|
||||
{
|
||||
if (autoRetainerIPC == null || !autoRetainerIPC.IsAvailable)
|
||||
{
|
||||
log.Warning("[ErrorRecovery] AutoRetainer IPC not available - cannot relog");
|
||||
return false;
|
||||
}
|
||||
if (string.IsNullOrEmpty(LastDisconnectedCharacter))
|
||||
{
|
||||
log.Warning("[ErrorRecovery] No character to relog to");
|
||||
return false;
|
||||
}
|
||||
log.Information("[ErrorRecovery] Requesting AutoRetainer relog to: " + LastDisconnectedCharacter);
|
||||
return autoRetainerIPC.SwitchCharacter(LastDisconnectedCharacter);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
framework.Update -= OnFrameworkUpdate;
|
||||
if (lobbyErrorHandlerHook != null)
|
||||
{
|
||||
lobbyErrorHandlerHook.Disable();
|
||||
lobbyErrorHandlerHook.Dispose();
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -553,10 +553,17 @@ public class ExecutionService : IDisposable
|
|||
}
|
||||
if (dcTravelService != null && dcTravelService.IsDCTravelCompleted())
|
||||
{
|
||||
AddLog(LogLevel.Info, "[DCTravel] Returning to homeworld before character switch...");
|
||||
dcTravelService.ReturnToHomeworld();
|
||||
Thread.Sleep(2000);
|
||||
AddLog(LogLevel.Info, "[DCTravel] Returned to homeworld");
|
||||
if (config.ReturnToHomeworldOnStopQuest)
|
||||
{
|
||||
AddLog(LogLevel.Info, "[DCTravel] Returning to homeworld before character switch...");
|
||||
dcTravelService.ReturnToHomeworld();
|
||||
Thread.Sleep(2000);
|
||||
AddLog(LogLevel.Info, "[DCTravel] Returned to homeworld");
|
||||
}
|
||||
else
|
||||
{
|
||||
AddLog(LogLevel.Info, "[DCTravel] Skipping return to homeworld (setting disabled)");
|
||||
}
|
||||
}
|
||||
if (config.EnableSafeWaitBeforeCharacterSwitch && safeWaitService != null)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,8 +4,12 @@ using System.Linq;
|
|||
using System.Threading.Tasks;
|
||||
using Dalamud.Game.ClientState.Conditions;
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
using Dalamud.Game.ClientState.Party;
|
||||
using Dalamud.Plugin.Services;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game.Group;
|
||||
using Lumina.Excel;
|
||||
using Lumina.Excel.Sheets;
|
||||
using QuestionableCompanion.Models;
|
||||
|
||||
namespace QuestionableCompanion.Services;
|
||||
|
||||
|
|
@ -33,13 +37,17 @@ public class HelperManager : IDisposable
|
|||
|
||||
private readonly MemoryHelper memoryHelper;
|
||||
|
||||
private readonly LANHelperClient? lanHelperClient;
|
||||
|
||||
private readonly IPartyList partyList;
|
||||
|
||||
private bool isInDuty;
|
||||
|
||||
private List<(string Name, ushort WorldId)> availableHelpers = new List<(string, ushort)>();
|
||||
|
||||
private Dictionary<(string, ushort), bool> helperReadyStatus = new Dictionary<(string, ushort), bool>();
|
||||
|
||||
public HelperManager(Configuration configuration, IPluginLog log, ICommandManager commandManager, ICondition condition, IClientState clientState, IFramework framework, PartyInviteService partyInviteService, MultiClientIPC multiClientIPC, CrossProcessIPC crossProcessIPC, PartyInviteAutoAccept partyInviteAutoAccept, MemoryHelper memoryHelper)
|
||||
public HelperManager(Configuration configuration, IPluginLog log, ICommandManager commandManager, ICondition condition, IClientState clientState, IFramework framework, PartyInviteService partyInviteService, MultiClientIPC multiClientIPC, CrossProcessIPC crossProcessIPC, PartyInviteAutoAccept partyInviteAutoAccept, MemoryHelper memoryHelper, LANHelperClient? lanHelperClient, IPartyList partyList)
|
||||
{
|
||||
this.configuration = configuration;
|
||||
this.log = log;
|
||||
|
|
@ -51,7 +59,9 @@ public class HelperManager : IDisposable
|
|||
this.multiClientIPC = multiClientIPC;
|
||||
this.crossProcessIPC = crossProcessIPC;
|
||||
this.memoryHelper = memoryHelper;
|
||||
this.lanHelperClient = lanHelperClient;
|
||||
this.partyInviteAutoAccept = partyInviteAutoAccept;
|
||||
this.partyList = partyList;
|
||||
condition.ConditionChange += OnConditionChanged;
|
||||
multiClientIPC.OnHelperRequested += OnHelperRequested;
|
||||
multiClientIPC.OnHelperDismissed += OnHelperDismissed;
|
||||
|
|
@ -95,56 +105,236 @@ public class HelperManager : IDisposable
|
|||
log.Debug("[HelperManager] Not a Quester, skipping helper invites");
|
||||
return;
|
||||
}
|
||||
if (configuration.HelperSelection == HelperSelectionMode.ManualInput)
|
||||
{
|
||||
if (string.IsNullOrEmpty(configuration.ManualHelperName))
|
||||
{
|
||||
log.Warning("[HelperManager] Manual Input mode selected but no helper name configured!");
|
||||
return;
|
||||
}
|
||||
Task.Run(async delegate
|
||||
{
|
||||
log.Information("[HelperManager] Manual Input mode: Inviting " + configuration.ManualHelperName);
|
||||
string[] parts = configuration.ManualHelperName.Split('@');
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
log.Error("[HelperManager] Invalid manual helper format: " + configuration.ManualHelperName + " (expected: CharacterName@WorldName)");
|
||||
}
|
||||
else
|
||||
{
|
||||
string helperName = parts[0].Trim();
|
||||
string worldName = parts[1].Trim();
|
||||
ushort worldId = 0;
|
||||
ExcelSheet<World> worldSheet = Plugin.DataManager.GetExcelSheet<World>();
|
||||
if (worldSheet != null)
|
||||
{
|
||||
foreach (World world in worldSheet)
|
||||
{
|
||||
if (world.Name.ExtractText().Equals(worldName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
worldId = (ushort)world.RowId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (worldId == 0)
|
||||
{
|
||||
log.Error("[HelperManager] Could not find world ID for: " + worldName);
|
||||
}
|
||||
else
|
||||
{
|
||||
log.Information($"[HelperManager] Resolved helper: {helperName}@{worldId} ({worldName})");
|
||||
bool alreadyInParty = false;
|
||||
if (partyList != null)
|
||||
{
|
||||
foreach (IPartyMember member in partyList)
|
||||
{
|
||||
if (member.Name.ToString() == helperName && member.World.RowId == worldId)
|
||||
{
|
||||
alreadyInParty = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (alreadyInParty)
|
||||
{
|
||||
log.Information("[HelperManager] helper " + helperName + " is ALREADY in party! Skipping disband/invite.");
|
||||
}
|
||||
else
|
||||
{
|
||||
DisbandParty();
|
||||
await Task.Delay(500);
|
||||
log.Information("[HelperManager] Sending direct invite to " + helperName + " (Manual Input - no IPC wait)");
|
||||
if (partyInviteService.InviteToParty(helperName, worldId))
|
||||
{
|
||||
log.Information("[HelperManager] Successfully invited " + helperName);
|
||||
}
|
||||
else
|
||||
{
|
||||
log.Error("[HelperManager] Failed to invite " + helperName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
log.Information("[HelperManager] Requesting helper announcements...");
|
||||
RequestHelperAnnouncements();
|
||||
Task.Run(async delegate
|
||||
{
|
||||
await Task.Delay(1000);
|
||||
if (availableHelpers.Count == 0)
|
||||
List<(string Name, ushort WorldId)> helpersToInvite = new List<(string, ushort)>();
|
||||
if (configuration.HelperSelection == HelperSelectionMode.Auto)
|
||||
{
|
||||
log.Warning("[HelperManager] No helpers available via IPC!");
|
||||
log.Warning("[HelperManager] Make sure helper clients are running with 'I'm a High-Level Helper' enabled");
|
||||
if (availableHelpers.Count == 0)
|
||||
{
|
||||
log.Warning("[HelperManager] No helpers available via IPC!");
|
||||
if (lanHelperClient != null)
|
||||
{
|
||||
log.Information("[HelperManager] Checking for LAN helpers...");
|
||||
LANHelperInfo lanHelper = lanHelperClient.GetFirstAvailableHelper();
|
||||
if (lanHelper != null)
|
||||
{
|
||||
log.Information("[HelperManager] Found LAN helper: " + lanHelper.Name + " at " + lanHelper.IPAddress);
|
||||
await InviteLANHelper(lanHelper.IPAddress, lanHelper.Name, lanHelper.WorldId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
log.Warning("[HelperManager] Make sure helper clients are running with 'I'm a High-Level Helper' enabled");
|
||||
return;
|
||||
}
|
||||
helpersToInvite.AddRange(availableHelpers);
|
||||
log.Information($"[HelperManager] Auto mode: Inviting {helpersToInvite.Count} AUTO-DISCOVERED helper(s)...");
|
||||
}
|
||||
else if (configuration.HelperSelection == HelperSelectionMode.Dropdown)
|
||||
{
|
||||
if (string.IsNullOrEmpty(configuration.PreferredHelper))
|
||||
{
|
||||
log.Warning("[HelperManager] Dropdown mode selected but no helper chosen!");
|
||||
return;
|
||||
}
|
||||
string[] parts = configuration.PreferredHelper.Split('@');
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
log.Error("[HelperManager] Invalid preferred helper format: " + configuration.PreferredHelper);
|
||||
return;
|
||||
}
|
||||
string helperName = parts[0].Trim();
|
||||
string worldName = parts[1].Trim();
|
||||
(string, ushort) matchingHelper = availableHelpers.FirstOrDefault<(string, ushort)>(delegate((string Name, ushort WorldId) h)
|
||||
{
|
||||
ExcelSheet<World> excelSheet = Plugin.DataManager.GetExcelSheet<World>();
|
||||
string text2 = "Unknown";
|
||||
if (excelSheet != null)
|
||||
{
|
||||
foreach (World current in excelSheet)
|
||||
{
|
||||
if (current.RowId == h.WorldId)
|
||||
{
|
||||
text2 = current.Name.ExtractText();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return h.Name == helperName && text2 == worldName;
|
||||
});
|
||||
var (text, num) = matchingHelper;
|
||||
if (text == null && num == 0)
|
||||
{
|
||||
log.Warning("[HelperManager] Preferred helper " + configuration.PreferredHelper + " not found in discovered helpers!");
|
||||
return;
|
||||
}
|
||||
helpersToInvite.Add(matchingHelper);
|
||||
log.Information("[HelperManager] Dropdown mode: Inviting selected helper " + configuration.PreferredHelper);
|
||||
}
|
||||
bool allHelpersPresent = false;
|
||||
if (partyList != null && partyList.Length > 0 && helpersToInvite.Count > 0)
|
||||
{
|
||||
int presentCount = 0;
|
||||
foreach (var (hName, hWorld) in helpersToInvite)
|
||||
{
|
||||
foreach (IPartyMember member in partyList)
|
||||
{
|
||||
if (member.Name.ToString() == hName && member.World.RowId == hWorld)
|
||||
{
|
||||
presentCount++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (presentCount >= helpersToInvite.Count)
|
||||
{
|
||||
allHelpersPresent = true;
|
||||
}
|
||||
}
|
||||
if (allHelpersPresent)
|
||||
{
|
||||
log.Information("[HelperManager] All desired helpers are ALREADY in party! Skipping disband.");
|
||||
}
|
||||
else if (partyList != null && partyList.Length > 1)
|
||||
{
|
||||
bool anyHelperPresent = false;
|
||||
foreach (var (hName2, hWorld2) in helpersToInvite)
|
||||
{
|
||||
foreach (IPartyMember member2 in partyList)
|
||||
{
|
||||
if (member2.Name.ToString() == hName2 && member2.World.RowId == hWorld2)
|
||||
{
|
||||
anyHelperPresent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (anyHelperPresent)
|
||||
{
|
||||
log.Information("[HelperManager] Some helpers already in party - NOT disbanding, simply inviting remaining.");
|
||||
}
|
||||
else
|
||||
{
|
||||
DisbandParty();
|
||||
await Task.Delay(500);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
log.Information($"[HelperManager] Inviting {availableHelpers.Count} AUTO-DISCOVERED helper(s)...");
|
||||
DisbandParty();
|
||||
await Task.Delay(500);
|
||||
foreach (var (name, worldId) in availableHelpers)
|
||||
}
|
||||
foreach (var (name, worldId) in helpersToInvite)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name) || worldId == 0)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name) || worldId == 0)
|
||||
log.Warning($"[HelperManager] Invalid helper: {name}@{worldId}");
|
||||
}
|
||||
else
|
||||
{
|
||||
log.Information($"[HelperManager] Requesting helper: {name}@{worldId}");
|
||||
helperReadyStatus[(name, worldId)] = false;
|
||||
multiClientIPC.RequestHelper(name, worldId);
|
||||
crossProcessIPC.RequestHelper(name, worldId);
|
||||
log.Information("[HelperManager] Waiting for " + name + " to be ready...");
|
||||
DateTime timeout = DateTime.Now.AddSeconds(10.0);
|
||||
while (!helperReadyStatus.GetValueOrDefault((name, worldId), defaultValue: false) && DateTime.Now < timeout)
|
||||
{
|
||||
log.Warning($"[HelperManager] Invalid helper: {name}@{worldId}");
|
||||
await Task.Delay(100);
|
||||
}
|
||||
if (!helperReadyStatus.GetValueOrDefault((name, worldId), defaultValue: false))
|
||||
{
|
||||
log.Warning("[HelperManager] Timeout waiting for " + name + " to be ready!");
|
||||
}
|
||||
else
|
||||
{
|
||||
log.Information($"[HelperManager] Requesting helper: {name}@{worldId}");
|
||||
helperReadyStatus[(name, worldId)] = false;
|
||||
multiClientIPC.RequestHelper(name, worldId);
|
||||
crossProcessIPC.RequestHelper(name, worldId);
|
||||
log.Information("[HelperManager] Waiting for " + name + " to be ready...");
|
||||
DateTime timeout = DateTime.Now.AddSeconds(10.0);
|
||||
while (!helperReadyStatus.GetValueOrDefault((name, worldId), defaultValue: false) && DateTime.Now < timeout)
|
||||
log.Information("[HelperManager] " + name + " is ready! Sending invite...");
|
||||
if (partyInviteService.InviteToParty(name, worldId))
|
||||
{
|
||||
await Task.Delay(100);
|
||||
}
|
||||
if (!helperReadyStatus.GetValueOrDefault((name, worldId), defaultValue: false))
|
||||
{
|
||||
log.Warning("[HelperManager] Timeout waiting for " + name + " to be ready!");
|
||||
log.Information("[HelperManager] Successfully invited " + name);
|
||||
}
|
||||
else
|
||||
{
|
||||
log.Information("[HelperManager] " + name + " is ready! Sending invite...");
|
||||
if (partyInviteService.InviteToParty(name, worldId))
|
||||
{
|
||||
log.Information("[HelperManager] Successfully invited " + name);
|
||||
}
|
||||
else
|
||||
{
|
||||
log.Error("[HelperManager] Failed to invite " + name);
|
||||
}
|
||||
await Task.Delay(500);
|
||||
log.Error("[HelperManager] Failed to invite " + name);
|
||||
}
|
||||
await Task.Delay(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -153,7 +343,18 @@ public class HelperManager : IDisposable
|
|||
|
||||
public List<(string Name, ushort WorldId)> GetAvailableHelpers()
|
||||
{
|
||||
return new List<(string, ushort)>(availableHelpers);
|
||||
List<(string, ushort)> allHelpers = new List<(string, ushort)>(availableHelpers);
|
||||
if (lanHelperClient != null)
|
||||
{
|
||||
foreach (LANHelperInfo lanHelper in lanHelperClient.DiscoveredHelpers)
|
||||
{
|
||||
if (!allHelpers.Any<(string, ushort)>(((string Name, ushort WorldId) h) => h.Name == lanHelper.Name && h.WorldId == lanHelper.WorldId))
|
||||
{
|
||||
allHelpers.Add((lanHelper.Name, lanHelper.WorldId));
|
||||
}
|
||||
}
|
||||
}
|
||||
return allHelpers;
|
||||
}
|
||||
|
||||
private void LeaveParty()
|
||||
|
|
@ -211,7 +412,7 @@ public class HelperManager : IDisposable
|
|||
|
||||
private void OnDutyEnter()
|
||||
{
|
||||
log.Information("[HelperManager] Entered duty");
|
||||
log.Debug("[HelperManager] Entered duty");
|
||||
if (!configuration.IsHighLevelHelper)
|
||||
{
|
||||
return;
|
||||
|
|
@ -345,14 +546,34 @@ public class HelperManager : IDisposable
|
|||
GroupManager.Group* group = groupManager->GetGroup();
|
||||
if (group != null && group->MemberCount > 0)
|
||||
{
|
||||
needsToLeaveParty = true;
|
||||
log.Information("[HelperManager] Currently in party, notifying quester...");
|
||||
crossProcessIPC.NotifyHelperInParty(localName, localWorldId);
|
||||
if (condition[ConditionFlag.BoundByDuty])
|
||||
bool requesterInParty = false;
|
||||
if (partyList != null)
|
||||
{
|
||||
isInDuty = true;
|
||||
log.Information("[HelperManager] Currently in duty, notifying quester...");
|
||||
crossProcessIPC.NotifyHelperInDuty(localName, localWorldId);
|
||||
foreach (IPartyMember member in partyList)
|
||||
{
|
||||
if (member.Name.ToString() == characterName && member.World.RowId == worldId)
|
||||
{
|
||||
requesterInParty = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (requesterInParty)
|
||||
{
|
||||
log.Information($"[HelperManager] Request from {characterName}@{worldId} who is ALREADY in my party! Ignoring leave request.");
|
||||
needsToLeaveParty = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
needsToLeaveParty = true;
|
||||
log.Information("[HelperManager] Currently in party (but not with requester), notifying quester...");
|
||||
crossProcessIPC.NotifyHelperInParty(localName, localWorldId);
|
||||
if (condition[ConditionFlag.BoundByDuty])
|
||||
{
|
||||
isInDuty = true;
|
||||
log.Information("[HelperManager] Currently in duty, notifying quester...");
|
||||
crossProcessIPC.NotifyHelperInDuty(localName, localWorldId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -427,6 +648,36 @@ public class HelperManager : IDisposable
|
|||
crossProcessIPC.RequestHelperAnnouncements();
|
||||
}
|
||||
|
||||
private async Task InviteLANHelper(string ipAddress, string helperName, ushort worldId)
|
||||
{
|
||||
if (lanHelperClient == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
log.Information("[HelperManager] ========================================");
|
||||
log.Information("[HelperManager] === INVITING LAN HELPER ===");
|
||||
log.Information("[HelperManager] Helper: " + helperName);
|
||||
log.Information("[HelperManager] IP: " + ipAddress);
|
||||
log.Information("[HelperManager] ========================================");
|
||||
DisbandParty();
|
||||
await Task.Delay(500);
|
||||
log.Information("[HelperManager] Sending helper request to " + ipAddress + "...");
|
||||
if (!(await lanHelperClient.RequestHelperAsync(ipAddress, "LAN Dungeon")))
|
||||
{
|
||||
log.Error("[HelperManager] Failed to send helper request to " + ipAddress);
|
||||
return;
|
||||
}
|
||||
await Task.Delay(1000);
|
||||
log.Information("[HelperManager] Sending party invite to " + helperName + "...");
|
||||
if (!partyInviteService.InviteToParty(helperName, worldId))
|
||||
{
|
||||
log.Error("[HelperManager] Failed to invite " + helperName + " to party");
|
||||
return;
|
||||
}
|
||||
await lanHelperClient.NotifyInviteSentAsync(ipAddress, helperName);
|
||||
log.Information("[HelperManager] ✓ LAN helper invite complete");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
condition.ConditionChange -= OnConditionChanged;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,489 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
using Dalamud.Plugin.Services;
|
||||
using Newtonsoft.Json;
|
||||
using QuestionableCompanion.Models;
|
||||
|
||||
namespace QuestionableCompanion.Services;
|
||||
|
||||
public class LANHelperClient : IDisposable
|
||||
{
|
||||
public class ChauffeurMessageEventArgs : EventArgs
|
||||
{
|
||||
public LANMessageType Type { get; }
|
||||
|
||||
public LANChauffeurResponse Data { get; }
|
||||
|
||||
public ChauffeurMessageEventArgs(LANMessageType type, LANChauffeurResponse data)
|
||||
{
|
||||
Type = type;
|
||||
Data = data;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly IPluginLog log;
|
||||
|
||||
private readonly IClientState clientState;
|
||||
|
||||
private readonly IFramework framework;
|
||||
|
||||
private readonly Configuration config;
|
||||
|
||||
private readonly Dictionary<string, TcpClient> activeConnections = new Dictionary<string, TcpClient>();
|
||||
|
||||
private readonly Dictionary<string, LANHelperInfo> discoveredHelpers = new Dictionary<string, LANHelperInfo>();
|
||||
|
||||
private CancellationTokenSource? cancellationTokenSource;
|
||||
|
||||
private string cachedPlayerName = string.Empty;
|
||||
|
||||
private ushort cachedWorldId;
|
||||
|
||||
public IReadOnlyList<LANHelperInfo> DiscoveredHelpers => discoveredHelpers.Values.ToList();
|
||||
|
||||
public event EventHandler<ChauffeurMessageEventArgs>? OnChauffeurMessageReceived;
|
||||
|
||||
public LANHelperClient(IPluginLog log, IClientState clientState, IFramework framework, Configuration config)
|
||||
{
|
||||
this.log = log;
|
||||
this.clientState = clientState;
|
||||
this.framework = framework;
|
||||
this.config = config;
|
||||
framework.Update += OnFrameworkUpdate;
|
||||
}
|
||||
|
||||
private void OnFrameworkUpdate(IFramework fw)
|
||||
{
|
||||
IPlayerCharacter player = clientState.LocalPlayer;
|
||||
if (player != null)
|
||||
{
|
||||
cachedPlayerName = player.Name.ToString();
|
||||
cachedWorldId = (ushort)player.HomeWorld.RowId;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Initialize()
|
||||
{
|
||||
if (!config.EnableLANHelpers)
|
||||
{
|
||||
return;
|
||||
}
|
||||
cancellationTokenSource = new CancellationTokenSource();
|
||||
log.Information("[LANClient] Initializing LAN Helper Client...");
|
||||
foreach (string ip in config.LANHelperIPs)
|
||||
{
|
||||
await ConnectToHelperAsync(ip);
|
||||
}
|
||||
Task.Run(() => HeartbeatMonitorAsync(cancellationTokenSource.Token));
|
||||
}
|
||||
|
||||
private async Task HeartbeatMonitorAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
log.Information("[LANClient] Heartbeat monitor started (30s interval)");
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(30000, cancellationToken);
|
||||
foreach (string ip in config.LANHelperIPs.ToList())
|
||||
{
|
||||
if (!activeConnections.ContainsKey(ip) || !activeConnections[ip].Connected)
|
||||
{
|
||||
log.Debug("[LANClient] Heartbeat: " + ip + " disconnected, reconnecting...");
|
||||
await ConnectToHelperAsync(ip);
|
||||
continue;
|
||||
}
|
||||
LANHeartbeat heartbeatData = new LANHeartbeat
|
||||
{
|
||||
ClientName = (string.IsNullOrEmpty(cachedPlayerName) ? "Unknown" : cachedPlayerName),
|
||||
ClientWorldId = cachedWorldId,
|
||||
ClientRole = (config.IsQuester ? "Quester" : "Helper")
|
||||
};
|
||||
await SendMessageAsync(ip, new LANMessage(LANMessageType.HEARTBEAT, heartbeatData));
|
||||
log.Debug($"[LANClient] Heartbeat sent to {ip} (as {heartbeatData.ClientName}@{heartbeatData.ClientWorldId})");
|
||||
}
|
||||
foreach (LANHelperInfo helper in discoveredHelpers.Values.ToList())
|
||||
{
|
||||
if (!string.IsNullOrEmpty(helper.IPAddress))
|
||||
{
|
||||
LANHeartbeat heartbeatData = new LANHeartbeat
|
||||
{
|
||||
ClientName = (string.IsNullOrEmpty(cachedPlayerName) ? "Unknown" : cachedPlayerName),
|
||||
ClientWorldId = cachedWorldId,
|
||||
ClientRole = (config.IsQuester ? "Quester" : "Helper")
|
||||
};
|
||||
await SendMessageAsync(helper.IPAddress, new LANMessage(LANMessageType.HEARTBEAT, heartbeatData));
|
||||
log.Information($"[LANClient] Heartbeat sent to discovered helper {helper.Name}@{helper.IPAddress} (as {heartbeatData.ClientName}, Role={heartbeatData.ClientRole})");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex2)
|
||||
{
|
||||
log.Error("[LANClient] Heartbeat monitor error: " + ex2.Message);
|
||||
}
|
||||
}
|
||||
log.Information("[LANClient] Heartbeat monitor stopped");
|
||||
}
|
||||
|
||||
public async Task<bool> ConnectToHelperAsync(string ipAddress)
|
||||
{
|
||||
if (activeConnections.ContainsKey(ipAddress))
|
||||
{
|
||||
log.Debug("[LANClient] Already connected to " + ipAddress);
|
||||
return true;
|
||||
}
|
||||
try
|
||||
{
|
||||
log.Information($"[LANClient] Connecting to helper at {ipAddress}:{config.LANServerPort}...");
|
||||
TcpClient client = new TcpClient();
|
||||
await client.ConnectAsync(ipAddress, config.LANServerPort);
|
||||
activeConnections[ipAddress] = client;
|
||||
log.Information("[LANClient] ✓ Connected to " + ipAddress);
|
||||
LANHelperStatusResponse statusResponse = await RequestHelperStatusAsync(ipAddress);
|
||||
if (statusResponse != null)
|
||||
{
|
||||
discoveredHelpers[ipAddress] = new LANHelperInfo
|
||||
{
|
||||
Name = statusResponse.Name,
|
||||
WorldId = statusResponse.WorldId,
|
||||
IPAddress = ipAddress,
|
||||
Status = statusResponse.Status,
|
||||
LastSeen = DateTime.Now
|
||||
};
|
||||
log.Information($"[LANClient] Helper discovered: {statusResponse.Name} ({statusResponse.Status})");
|
||||
}
|
||||
Task.Run(() => ListenToHelperAsync(ipAddress, client), cancellationTokenSource.Token);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.Error("[LANClient] Failed to connect to " + ipAddress + ": " + ex.Message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ListenToHelperAsync(string ipAddress, TcpClient client)
|
||||
{
|
||||
try
|
||||
{
|
||||
using NetworkStream stream = client.GetStream();
|
||||
using StreamReader reader = new StreamReader(stream, Encoding.UTF8);
|
||||
while (client.Connected && !cancellationTokenSource.Token.IsCancellationRequested)
|
||||
{
|
||||
string line = await reader.ReadLineAsync(cancellationTokenSource.Token);
|
||||
if (string.IsNullOrEmpty(line))
|
||||
{
|
||||
break;
|
||||
}
|
||||
try
|
||||
{
|
||||
LANMessage message = JsonConvert.DeserializeObject<LANMessage>(line);
|
||||
if (message != null)
|
||||
{
|
||||
HandleHelperMessage(ipAddress, message);
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
log.Error("[LANClient] Invalid message from " + ipAddress + ": " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex2)
|
||||
{
|
||||
log.Error("[LANClient] Connection to " + ipAddress + " lost: " + ex2.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
log.Information("[LANClient] Disconnected from " + ipAddress);
|
||||
activeConnections.Remove(ipAddress);
|
||||
client.Close();
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleHelperMessage(string ipAddress, LANMessage message)
|
||||
{
|
||||
log.Debug($"[LANClient] Received {message.Type} from {ipAddress}");
|
||||
switch (message.Type)
|
||||
{
|
||||
case LANMessageType.HELPER_STATUS:
|
||||
{
|
||||
LANHelperStatusResponse status = message.GetData<LANHelperStatusResponse>();
|
||||
if (status == null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (!discoveredHelpers.ContainsKey(ipAddress))
|
||||
{
|
||||
log.Information($"[LANClient] New helper discovered via status: {status.Name}@{status.WorldId} ({ipAddress})");
|
||||
discoveredHelpers[ipAddress] = new LANHelperInfo
|
||||
{
|
||||
Name = status.Name,
|
||||
WorldId = status.WorldId,
|
||||
IPAddress = ipAddress,
|
||||
Status = status.Status,
|
||||
LastSeen = DateTime.Now
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
discoveredHelpers[ipAddress].Status = status.Status;
|
||||
discoveredHelpers[ipAddress].LastSeen = DateTime.Now;
|
||||
if (discoveredHelpers[ipAddress].Name != status.Name)
|
||||
{
|
||||
discoveredHelpers[ipAddress].Name = status.Name;
|
||||
discoveredHelpers[ipAddress].WorldId = status.WorldId;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case LANMessageType.INVITE_ACCEPTED:
|
||||
log.Information("[LANClient] ✓ Helper at " + ipAddress + " accepted invite");
|
||||
break;
|
||||
case LANMessageType.FOLLOW_STARTED:
|
||||
log.Information("[LANClient] ✓ Helper at " + ipAddress + " started following");
|
||||
break;
|
||||
case LANMessageType.FOLLOW_ARRIVED:
|
||||
log.Information("[LANClient] ✓ Helper at " + ipAddress + " arrived at destination");
|
||||
break;
|
||||
case LANMessageType.HELPER_READY:
|
||||
log.Information("[LANClient] ✓ Helper at " + ipAddress + " is ready");
|
||||
break;
|
||||
case LANMessageType.HELPER_IN_PARTY:
|
||||
log.Information("[LANClient] ✓ Helper at " + ipAddress + " joined party");
|
||||
break;
|
||||
case LANMessageType.HELPER_IN_DUTY:
|
||||
log.Information("[LANClient] ✓ Helper at " + ipAddress + " entered duty");
|
||||
break;
|
||||
case LANMessageType.CHAUFFEUR_HELPER_READY_FOR_MOUNT:
|
||||
{
|
||||
LANChauffeurResponse readyData = message.GetData<LANChauffeurResponse>();
|
||||
if (readyData != null)
|
||||
{
|
||||
log.Information($"[LANClient] Received Chauffeur Mount Ready from {readyData.QuesterName}@{readyData.QuesterWorldId}");
|
||||
this.OnChauffeurMessageReceived?.Invoke(this, new ChauffeurMessageEventArgs(LANMessageType.CHAUFFEUR_HELPER_READY_FOR_MOUNT, readyData));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case LANMessageType.CHAUFFEUR_HELPER_ARRIVED_DEST:
|
||||
{
|
||||
LANChauffeurResponse arrivedData = message.GetData<LANChauffeurResponse>();
|
||||
if (arrivedData != null)
|
||||
{
|
||||
log.Information($"[LANClient] Received Chauffeur Arrived from {arrivedData.QuesterName}@{arrivedData.QuesterWorldId}");
|
||||
this.OnChauffeurMessageReceived?.Invoke(this, new ChauffeurMessageEventArgs(LANMessageType.CHAUFFEUR_HELPER_ARRIVED_DEST, arrivedData));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case LANMessageType.INVITE_NOTIFICATION:
|
||||
case LANMessageType.DUTY_COMPLETE:
|
||||
case LANMessageType.FOLLOW_COMMAND:
|
||||
case LANMessageType.CHAUFFEUR_PICKUP_REQUEST:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> RequestHelperAsync(string ipAddress, string dutyName = "")
|
||||
{
|
||||
IPlayerCharacter player = clientState.LocalPlayer;
|
||||
if (player == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
LANHelperRequest request = new LANHelperRequest
|
||||
{
|
||||
QuesterName = player.Name.ToString(),
|
||||
QuesterWorldId = (ushort)player.HomeWorld.RowId,
|
||||
DutyName = dutyName
|
||||
};
|
||||
return await SendMessageAsync(ipAddress, new LANMessage(LANMessageType.REQUEST_HELPER, request));
|
||||
}
|
||||
|
||||
public async Task<LANHelperStatusResponse?> RequestHelperStatusAsync(string ipAddress)
|
||||
{
|
||||
if (!(await SendMessageAsync(ipAddress, new LANMessage(LANMessageType.HELPER_STATUS))))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
await Task.Delay(500);
|
||||
if (discoveredHelpers.TryGetValue(ipAddress, out LANHelperInfo helper))
|
||||
{
|
||||
return new LANHelperStatusResponse
|
||||
{
|
||||
Name = helper.Name,
|
||||
WorldId = helper.WorldId,
|
||||
Status = helper.Status
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<bool> SendFollowCommandAsync(string ipAddress, float x, float y, float z, uint territoryId)
|
||||
{
|
||||
LANFollowCommand followCmd = new LANFollowCommand
|
||||
{
|
||||
X = x,
|
||||
Y = y,
|
||||
Z = z,
|
||||
TerritoryId = territoryId
|
||||
};
|
||||
log.Information($"[LANClient] Sending follow command to {ipAddress}: ({x:F2}, {y:F2}, {z:F2})");
|
||||
return await SendMessageAsync(ipAddress, new LANMessage(LANMessageType.FOLLOW_COMMAND, followCmd));
|
||||
}
|
||||
|
||||
public async Task<bool> NotifyInviteSentAsync(string ipAddress, string helperName)
|
||||
{
|
||||
log.Information("[LANClient] Notifying " + ipAddress + " of invite sent to " + helperName);
|
||||
return await SendMessageAsync(ipAddress, new LANMessage(LANMessageType.INVITE_NOTIFICATION, helperName));
|
||||
}
|
||||
|
||||
private async Task<bool> SendMessageAsync(string ipAddress, LANMessage message)
|
||||
{
|
||||
_ = 2;
|
||||
try
|
||||
{
|
||||
if (!activeConnections.ContainsKey(ipAddress) && !(await ConnectToHelperAsync(ipAddress)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
TcpClient client = activeConnections[ipAddress];
|
||||
if (!client.Connected)
|
||||
{
|
||||
log.Warning("[LANClient] Not connected to " + ipAddress + ", reconnecting...");
|
||||
activeConnections.Remove(ipAddress);
|
||||
if (!(await ConnectToHelperAsync(ipAddress)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
client = activeConnections[ipAddress];
|
||||
}
|
||||
string json = JsonConvert.SerializeObject(message);
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(json + "\n");
|
||||
await client.GetStream().WriteAsync(bytes, 0, bytes.Length);
|
||||
log.Debug($"[LANClient] Sent {message.Type} to {ipAddress}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.Error("[LANClient] Failed to send message to " + ipAddress + ": " + ex.Message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> ScanNetworkAsync(int timeoutSeconds = 5)
|
||||
{
|
||||
log.Information($"[LANClient] \ud83d\udce1 Scanning network for helpers (timeout: {timeoutSeconds}s)...");
|
||||
int foundCount = 0;
|
||||
try
|
||||
{
|
||||
using UdpClient udpClient = new UdpClient(47789);
|
||||
udpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, optionValue: true);
|
||||
CancellationTokenSource cancellation = new CancellationTokenSource();
|
||||
cancellation.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
|
||||
while (!cancellation.Token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
UdpReceiveResult result = await udpClient.ReceiveAsync(cancellation.Token);
|
||||
dynamic announcement = JsonConvert.DeserializeObject<object>(Encoding.UTF8.GetString(result.Buffer));
|
||||
if (announcement?.Type == "HELPER_ANNOUNCE")
|
||||
{
|
||||
string helperIP = result.RemoteEndPoint.Address.ToString();
|
||||
string helperName = (string)announcement.Name;
|
||||
_ = (int)announcement.Port;
|
||||
log.Information("[LANClient] ✓ Found helper: " + helperName + " at " + helperIP);
|
||||
if (!config.LANHelperIPs.Contains(helperIP))
|
||||
{
|
||||
config.LANHelperIPs.Add(helperIP);
|
||||
config.Save();
|
||||
log.Information("[LANClient] → Added " + helperIP + " to configuration");
|
||||
foundCount++;
|
||||
}
|
||||
await ConnectToHelperAsync(helperIP);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex2)
|
||||
{
|
||||
log.Debug("[LANClient] Scan error: " + ex2.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex3)
|
||||
{
|
||||
log.Error("[LANClient] Network scan failed: " + ex3.Message);
|
||||
}
|
||||
if (foundCount > 0)
|
||||
{
|
||||
log.Information($"[LANClient] ✓ Scan complete: Found {foundCount} new helper(s)");
|
||||
}
|
||||
else
|
||||
{
|
||||
log.Information("[LANClient] Scan complete: No new helpers found");
|
||||
}
|
||||
return foundCount;
|
||||
}
|
||||
|
||||
public LANHelperInfo? GetFirstAvailableHelper()
|
||||
{
|
||||
return (from h in discoveredHelpers.Values
|
||||
where h.Status == LANHelperStatus.Available
|
||||
orderby h.LastSeen
|
||||
select h).FirstOrDefault();
|
||||
}
|
||||
|
||||
public async Task<bool> SendChauffeurSummonAsync(string ipAddress, LANChauffeurSummon summonData)
|
||||
{
|
||||
log.Information("[LANClient] *** SENDING CHAUFFEUR_PICKUP_REQUEST to " + ipAddress + " ***");
|
||||
log.Information($"[LANClient] Summon data: Quester={summonData.QuesterName}@{summonData.QuesterWorldId}, Zone={summonData.ZoneId}");
|
||||
LANMessage message = new LANMessage(LANMessageType.CHAUFFEUR_PICKUP_REQUEST, summonData);
|
||||
bool num = await SendMessageAsync(ipAddress, message);
|
||||
if (num)
|
||||
{
|
||||
log.Information("[LANClient] ✓ CHAUFFEUR_PICKUP_REQUEST sent successfully to " + ipAddress);
|
||||
}
|
||||
else
|
||||
{
|
||||
log.Error("[LANClient] ✗ FAILED to send CHAUFFEUR_PICKUP_REQUEST to " + ipAddress);
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
public void DisconnectAll()
|
||||
{
|
||||
log.Information("[LANClient] Disconnecting from all helpers...");
|
||||
foreach (KeyValuePair<string, TcpClient> kvp in activeConnections.ToList())
|
||||
{
|
||||
try
|
||||
{
|
||||
SendMessageAsync(kvp.Key, new LANMessage(LANMessageType.DISCONNECT)).Wait(1000);
|
||||
kvp.Value.Close();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
activeConnections.Clear();
|
||||
discoveredHelpers.Clear();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
cancellationTokenSource?.Cancel();
|
||||
DisconnectAll();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,577 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Numerics;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
using Dalamud.Plugin.Services;
|
||||
using Newtonsoft.Json;
|
||||
using QuestionableCompanion.Models;
|
||||
|
||||
namespace QuestionableCompanion.Services;
|
||||
|
||||
public class LANHelperServer : IDisposable
|
||||
{
|
||||
private readonly IPluginLog log;
|
||||
|
||||
private readonly IClientState clientState;
|
||||
|
||||
private readonly IFramework framework;
|
||||
|
||||
private readonly Configuration config;
|
||||
|
||||
private readonly PartyInviteAutoAccept partyInviteAutoAccept;
|
||||
|
||||
private readonly ICommandManager commandManager;
|
||||
|
||||
private readonly Plugin plugin;
|
||||
|
||||
private TcpListener? listener;
|
||||
|
||||
private CancellationTokenSource? cancellationTokenSource;
|
||||
|
||||
private readonly List<TcpClient> connectedClients = new List<TcpClient>();
|
||||
|
||||
private readonly Dictionary<string, TcpClient> activeConnections = new Dictionary<string, TcpClient>();
|
||||
|
||||
private readonly Dictionary<string, DateTime> knownQuesters = new Dictionary<string, DateTime>();
|
||||
|
||||
private bool isRunning;
|
||||
|
||||
private string? cachedPlayerName;
|
||||
|
||||
private ushort cachedWorldId;
|
||||
|
||||
private DateTime lastCacheRefresh = DateTime.MinValue;
|
||||
|
||||
private const int CACHE_REFRESH_SECONDS = 30;
|
||||
|
||||
public bool IsRunning => isRunning;
|
||||
|
||||
public int ConnectedClientCount => connectedClients.Count;
|
||||
|
||||
public List<string> GetConnectedClientNames()
|
||||
{
|
||||
DateTime now = DateTime.Now;
|
||||
foreach (string s in (from kvp in knownQuesters
|
||||
where (now - kvp.Value).TotalSeconds > 60.0
|
||||
select kvp.Key).ToList())
|
||||
{
|
||||
knownQuesters.Remove(s);
|
||||
}
|
||||
return knownQuesters.Keys.ToList();
|
||||
}
|
||||
|
||||
public LANHelperServer(IPluginLog log, IClientState clientState, IFramework framework, Configuration config, PartyInviteAutoAccept partyInviteAutoAccept, ICommandManager commandManager, Plugin plugin)
|
||||
{
|
||||
this.log = log;
|
||||
this.clientState = clientState;
|
||||
this.framework = framework;
|
||||
this.config = config;
|
||||
this.partyInviteAutoAccept = partyInviteAutoAccept;
|
||||
this.commandManager = commandManager;
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (isRunning)
|
||||
{
|
||||
log.Warning("[LANServer] Server already running");
|
||||
return;
|
||||
}
|
||||
Task.Run(async delegate
|
||||
{
|
||||
try
|
||||
{
|
||||
framework.Update += OnFrameworkUpdate;
|
||||
int retries = 5;
|
||||
while (retries > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
listener = new TcpListener(IPAddress.Any, config.LANServerPort);
|
||||
listener.Start();
|
||||
}
|
||||
catch (SocketException ex) when (ex.SocketErrorCode == SocketError.AddressAlreadyInUse)
|
||||
{
|
||||
retries--;
|
||||
if (retries == 0)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
log.Warning($"[LANServer] Port {config.LANServerPort} in use, retrying in 1s... ({retries} retries left)");
|
||||
await Task.Delay(1000);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
cancellationTokenSource = new CancellationTokenSource();
|
||||
isRunning = true;
|
||||
log.Information("[LANServer] ===== LAN HELPER SERVER STARTED (v2-DEBUG) =====");
|
||||
log.Information($"[LANServer] Listening on port {config.LANServerPort}");
|
||||
log.Information("[LANServer] Waiting for player info cache... (via framework update)");
|
||||
Task.Run(() => AcceptClientsAsync(cancellationTokenSource.Token));
|
||||
Task.Run(() => BroadcastPresenceAsync(cancellationTokenSource.Token));
|
||||
}
|
||||
catch (Exception ex2)
|
||||
{
|
||||
log.Error("[LANServer] Failed to start server: " + ex2.Message);
|
||||
isRunning = false;
|
||||
framework.Update -= OnFrameworkUpdate;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void OnFrameworkUpdate(IFramework framework)
|
||||
{
|
||||
if (isRunning)
|
||||
{
|
||||
DateTime now = DateTime.Now;
|
||||
if ((now - lastCacheRefresh).TotalSeconds >= 30.0)
|
||||
{
|
||||
log.Debug($"[LANServer] Framework.Update triggered cache refresh (last: {(now - lastCacheRefresh).TotalSeconds:F1}s ago)");
|
||||
RefreshPlayerCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshPlayerCache()
|
||||
{
|
||||
try
|
||||
{
|
||||
log.Debug("[LANServer] RefreshPlayerCache called");
|
||||
IPlayerCharacter player = clientState.LocalPlayer;
|
||||
if (player != null)
|
||||
{
|
||||
string newName = player.Name.ToString();
|
||||
ushort newWorldId = (ushort)player.HomeWorld.RowId;
|
||||
if (cachedPlayerName != newName || cachedWorldId != newWorldId)
|
||||
{
|
||||
if (cachedPlayerName == null)
|
||||
{
|
||||
log.Information($"[LANServer] ✓ Player info cached: {newName}@{newWorldId}");
|
||||
}
|
||||
else
|
||||
{
|
||||
log.Information($"[LANServer] Player info updated: {newName}@{newWorldId}");
|
||||
}
|
||||
cachedPlayerName = newName;
|
||||
cachedWorldId = newWorldId;
|
||||
}
|
||||
lastCacheRefresh = DateTime.Now;
|
||||
}
|
||||
else
|
||||
{
|
||||
log.Warning("[LANServer] RefreshPlayerCache: LocalPlayer is NULL!");
|
||||
}
|
||||
lastCacheRefresh = DateTime.Now;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.Error("[LANServer] RefreshPlayerCache ERROR: " + ex.Message);
|
||||
log.Error("[LANServer] Stack: " + ex.StackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BroadcastPresenceAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_ = 3;
|
||||
try
|
||||
{
|
||||
using UdpClient udpClient = new UdpClient();
|
||||
udpClient.EnableBroadcast = true;
|
||||
IPEndPoint broadcastEndpoint = new IPEndPoint(IPAddress.Broadcast, 47789);
|
||||
if (cachedPlayerName == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
string json = JsonConvert.SerializeObject(new
|
||||
{
|
||||
Type = "HELPER_ANNOUNCE",
|
||||
Name = cachedPlayerName,
|
||||
WorldId = cachedWorldId,
|
||||
Port = config.LANServerPort
|
||||
});
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(json);
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
await udpClient.SendAsync(bytes, bytes.Length, broadcastEndpoint);
|
||||
log.Information($"[LANServer] \ud83d\udce1 Broadcast announcement sent ({i + 1}/3)");
|
||||
await Task.Delay(500, cancellationToken);
|
||||
}
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(30000, cancellationToken);
|
||||
await udpClient.SendAsync(bytes, bytes.Length, broadcastEndpoint);
|
||||
log.Debug("[LANServer] Broadcast presence updated");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex2)
|
||||
{
|
||||
log.Error("[LANServer] UDP broadcast error: " + ex2.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (!isRunning && listener == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
log.Information("[LANServer] Stopping server...");
|
||||
isRunning = false;
|
||||
cancellationTokenSource?.Cancel();
|
||||
framework.Update -= OnFrameworkUpdate;
|
||||
lock (connectedClients)
|
||||
{
|
||||
foreach (TcpClient client in connectedClients.ToList())
|
||||
{
|
||||
try
|
||||
{
|
||||
if (client.Connected)
|
||||
{
|
||||
try
|
||||
{
|
||||
NetworkStream stream = client.GetStream();
|
||||
if (stream.CanWrite)
|
||||
{
|
||||
string json = JsonConvert.SerializeObject(new LANMessage(LANMessageType.DISCONNECT));
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(json + "\n");
|
||||
stream.Write(bytes, 0, bytes.Length);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
client.Close();
|
||||
client.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
connectedClients.Clear();
|
||||
}
|
||||
try
|
||||
{
|
||||
listener?.Stop();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.Warning("[LANServer] Error stopping listener: " + ex.Message);
|
||||
}
|
||||
listener = null;
|
||||
log.Information("[LANServer] Server stopped");
|
||||
}
|
||||
|
||||
private async Task AcceptClientsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested && isRunning)
|
||||
{
|
||||
try
|
||||
{
|
||||
TcpClient client = await listener.AcceptTcpClientAsync(cancellationToken);
|
||||
string clientIP = ((IPEndPoint)client.Client.RemoteEndPoint).Address.ToString();
|
||||
log.Information("[LANServer] Client connected from " + clientIP);
|
||||
lock (connectedClients)
|
||||
{
|
||||
connectedClients.Add(client);
|
||||
}
|
||||
Task.Run(() => HandleClientAsync(client, cancellationToken), cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex2)
|
||||
{
|
||||
log.Error("[LANServer] Error accepting client: " + ex2.Message);
|
||||
await Task.Delay(1000, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleClientAsync(TcpClient client, CancellationToken cancellationToken)
|
||||
{
|
||||
string clientIP = ((IPEndPoint)client.Client.RemoteEndPoint).Address.ToString();
|
||||
try
|
||||
{
|
||||
using NetworkStream stream = client.GetStream();
|
||||
using StreamReader reader = new StreamReader(stream, Encoding.UTF8);
|
||||
while (!cancellationToken.IsCancellationRequested && client.Connected)
|
||||
{
|
||||
string line = await reader.ReadLineAsync(cancellationToken);
|
||||
if (string.IsNullOrEmpty(line))
|
||||
{
|
||||
break;
|
||||
}
|
||||
try
|
||||
{
|
||||
LANMessage message = JsonConvert.DeserializeObject<LANMessage>(line);
|
||||
if (message != null)
|
||||
{
|
||||
await HandleMessageAsync(client, message, clientIP);
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
log.Error("[LANServer] Invalid message from " + clientIP + ": " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex2)
|
||||
{
|
||||
log.Error("[LANServer] Client " + clientIP + " error: " + ex2.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
log.Information("[LANServer] Client " + clientIP + " disconnected");
|
||||
lock (connectedClients)
|
||||
{
|
||||
connectedClients.Remove(client);
|
||||
}
|
||||
client.Close();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleMessageAsync(TcpClient client, LANMessage message, string clientIP)
|
||||
{
|
||||
log.Debug($"[LANServer] Received {message.Type} from {clientIP}");
|
||||
switch (message.Type)
|
||||
{
|
||||
case LANMessageType.REQUEST_HELPER:
|
||||
await HandleHelperRequest(client, message);
|
||||
break;
|
||||
case LANMessageType.HELPER_STATUS:
|
||||
await HandleStatusRequest(client);
|
||||
break;
|
||||
case LANMessageType.INVITE_NOTIFICATION:
|
||||
await HandleInviteNotification(client, message);
|
||||
break;
|
||||
case LANMessageType.FOLLOW_COMMAND:
|
||||
await HandleFollowCommand(client, message);
|
||||
break;
|
||||
case LANMessageType.CHAUFFEUR_PICKUP_REQUEST:
|
||||
await HandleChauffeurSummon(message);
|
||||
break;
|
||||
case LANMessageType.HEARTBEAT:
|
||||
{
|
||||
LANHeartbeat heartbeatData = message.GetData<LANHeartbeat>();
|
||||
if (heartbeatData != null && heartbeatData.ClientRole == "Quester" && !string.IsNullOrEmpty(heartbeatData.ClientName))
|
||||
{
|
||||
string questerKey = $"{heartbeatData.ClientName}@{heartbeatData.ClientWorldId}";
|
||||
knownQuesters[questerKey] = DateTime.Now;
|
||||
}
|
||||
SendMessage(client, new LANMessage(LANMessageType.HEARTBEAT));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
log.Debug($"[LANServer] Unhandled message type: {message.Type}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleHelperRequest(TcpClient client, LANMessage message)
|
||||
{
|
||||
LANHelperRequest request = message.GetData<LANHelperRequest>();
|
||||
if (request != null)
|
||||
{
|
||||
log.Information("[LANServer] Helper requested by " + request.QuesterName + " for duty: " + request.DutyName);
|
||||
await SendCurrentStatus(client);
|
||||
partyInviteAutoAccept.EnableForQuester(request.QuesterName);
|
||||
log.Information("[LANServer] Auto-accept enabled for " + request.QuesterName);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleStatusRequest(TcpClient client)
|
||||
{
|
||||
await SendCurrentStatus(client);
|
||||
}
|
||||
|
||||
private async Task SendCurrentStatus(TcpClient client)
|
||||
{
|
||||
try
|
||||
{
|
||||
log.Debug("[LANServer] SendCurrentStatus: Start");
|
||||
if (cachedPlayerName == null)
|
||||
{
|
||||
log.Warning("[LANServer] SendCurrentStatus: Player info not cached! Sending NotReady status.");
|
||||
LANHelperStatusResponse notReadyStatus = new LANHelperStatusResponse
|
||||
{
|
||||
Name = "Unknown",
|
||||
WorldId = 0,
|
||||
Status = LANHelperStatus.Offline,
|
||||
CurrentActivity = "Waiting for character login..."
|
||||
};
|
||||
SendMessage(client, new LANMessage(LANMessageType.HELPER_STATUS, notReadyStatus));
|
||||
return;
|
||||
}
|
||||
log.Debug($"[LANServer] SendCurrentStatus: Cached Name={cachedPlayerName}, World={cachedWorldId}");
|
||||
LANHelperStatusResponse status = new LANHelperStatusResponse
|
||||
{
|
||||
Name = cachedPlayerName,
|
||||
WorldId = cachedWorldId,
|
||||
Status = LANHelperStatus.Available,
|
||||
CurrentActivity = "Ready"
|
||||
};
|
||||
log.Debug("[LANServer] SendCurrentStatus: Status object created");
|
||||
LANMessage msg = new LANMessage(LANMessageType.HELPER_STATUS, status);
|
||||
log.Debug("[LANServer] SendCurrentStatus: LANMessage created");
|
||||
SendMessage(client, msg);
|
||||
log.Debug("[LANServer] SendCurrentStatus: Message sent");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.Error("[LANServer] SendCurrentStatus CRASH: " + ex.Message);
|
||||
log.Error("[LANServer] Stack: " + ex.StackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleInviteNotification(TcpClient client, LANMessage message)
|
||||
{
|
||||
string questerName = message.GetData<string>();
|
||||
log.Information("[LANServer] Invite notification from " + questerName);
|
||||
SendMessage(client, new LANMessage(LANMessageType.INVITE_ACCEPTED));
|
||||
}
|
||||
|
||||
private async Task HandleFollowCommand(TcpClient client, LANMessage message)
|
||||
{
|
||||
LANFollowCommand followCmd = message.GetData<LANFollowCommand>();
|
||||
if (followCmd != null)
|
||||
{
|
||||
ChauffeurModeService chauffeurSvc = plugin.GetChauffeurMode();
|
||||
if (chauffeurSvc == null)
|
||||
{
|
||||
log.Warning("[LANServer] No ChauffeurModeService available for position update");
|
||||
return;
|
||||
}
|
||||
if (chauffeurSvc.IsTransportingQuester)
|
||||
{
|
||||
log.Debug("[LANServer] Ignoring FOLLOW_COMMAND - Chauffeur Mode is actively transporting");
|
||||
return;
|
||||
}
|
||||
string questerName = config.AssignedQuesterForFollowing ?? "LAN Quester";
|
||||
chauffeurSvc.UpdateQuesterPositionFromLAN(followCmd.X, followCmd.Y, followCmd.Z, followCmd.TerritoryId, questerName);
|
||||
log.Debug($"[LANServer] Updated quester position: ({followCmd.X:F2}, {followCmd.Y:F2}, {followCmd.Z:F2}) Zone={followCmd.TerritoryId}");
|
||||
SendMessage(client, new LANMessage(LANMessageType.FOLLOW_STARTED));
|
||||
}
|
||||
}
|
||||
|
||||
private void SendMessage(TcpClient client, LANMessage message)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (client.Connected)
|
||||
{
|
||||
string json = JsonConvert.SerializeObject(message);
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(json + "\n");
|
||||
client.GetStream().Write(bytes, 0, bytes.Length);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.Error("[LANServer] Failed to send message: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public void BroadcastMessage(LANMessage message)
|
||||
{
|
||||
lock (connectedClients)
|
||||
{
|
||||
foreach (TcpClient client in connectedClients.ToList())
|
||||
{
|
||||
SendMessage(client, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleChauffeurSummon(LANMessage message)
|
||||
{
|
||||
LANChauffeurSummon summonData = message.GetData<LANChauffeurSummon>();
|
||||
if (summonData == null)
|
||||
{
|
||||
log.Error("[LANServer] HandleChauffeurSummon: Failed to deserialize summon data!");
|
||||
return;
|
||||
}
|
||||
log.Information("[LANServer] =========================================");
|
||||
log.Information("[LANServer] *** CHAUFFEUR PICKUP REQUEST RECEIVED ***");
|
||||
log.Information("[LANServer] =========================================");
|
||||
log.Information($"[LANServer] Quester: {summonData.QuesterName}@{summonData.QuesterWorldId}");
|
||||
log.Information($"[LANServer] Zone: {summonData.ZoneId}");
|
||||
log.Information($"[LANServer] Target: ({summonData.TargetX:F2}, {summonData.TargetY:F2}, {summonData.TargetZ:F2})");
|
||||
log.Information($"[LANServer] AttuneAetheryte: {summonData.IsAttuneAetheryte}");
|
||||
ChauffeurModeService chauffeur = plugin.GetChauffeurMode();
|
||||
if (chauffeur != null)
|
||||
{
|
||||
Vector3 targetPos = new Vector3(summonData.TargetX, summonData.TargetY, summonData.TargetZ);
|
||||
Vector3 questerPos = new Vector3(summonData.QuesterX, summonData.QuesterY, summonData.QuesterZ);
|
||||
log.Information("[LANServer] Calling ChauffeurModeService.StartHelperWorkflow...");
|
||||
await framework.RunOnFrameworkThread(delegate
|
||||
{
|
||||
chauffeur.StartHelperWorkflow(summonData.QuesterName, summonData.QuesterWorldId, summonData.ZoneId, targetPos, questerPos, summonData.IsAttuneAetheryte);
|
||||
});
|
||||
log.Information("[LANServer] StartHelperWorkflow dispatched to framework thread");
|
||||
}
|
||||
else
|
||||
{
|
||||
log.Error("[LANServer] ChauffeurModeService is null! Cannot start helper workflow.");
|
||||
}
|
||||
}
|
||||
|
||||
public void SendChauffeurMountReady(string questerName, ushort questerWorldId)
|
||||
{
|
||||
LANChauffeurResponse response = new LANChauffeurResponse
|
||||
{
|
||||
QuesterName = questerName,
|
||||
QuesterWorldId = questerWorldId
|
||||
};
|
||||
LANMessage message = new LANMessage(LANMessageType.CHAUFFEUR_HELPER_READY_FOR_MOUNT, response);
|
||||
log.Information($"[LANServer] Sending Chauffeur Mount Ready to connected clients for {questerName}@{questerWorldId}");
|
||||
lock (connectedClients)
|
||||
{
|
||||
foreach (TcpClient client in connectedClients.ToList())
|
||||
{
|
||||
if (client.Connected)
|
||||
{
|
||||
SendMessage(client, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SendChauffeurArrived(string questerName, ushort questerWorldId)
|
||||
{
|
||||
LANChauffeurResponse response = new LANChauffeurResponse
|
||||
{
|
||||
QuesterName = questerName,
|
||||
QuesterWorldId = questerWorldId
|
||||
};
|
||||
LANMessage message = new LANMessage(LANMessageType.CHAUFFEUR_HELPER_ARRIVED_DEST, response);
|
||||
log.Information($"[LANServer] Sending Chauffeur Arrived to connected clients for {questerName}@{questerWorldId}");
|
||||
lock (connectedClients)
|
||||
{
|
||||
foreach (TcpClient client in connectedClients.ToList())
|
||||
{
|
||||
if (client.Connected)
|
||||
{
|
||||
SendMessage(client, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,8 @@ public class MultiClientIPC : IDisposable
|
|||
|
||||
private readonly ICallGateProvider<string, ushort, object?> passengerMountedProvider;
|
||||
|
||||
private readonly ICallGateProvider<string, ushort, string, object?> helperStatusProvider;
|
||||
|
||||
private readonly ICallGateSubscriber<string, ushort, object?> requestHelperSubscriber;
|
||||
|
||||
private readonly ICallGateSubscriber<object?> dismissHelperSubscriber;
|
||||
|
|
@ -31,6 +33,8 @@ public class MultiClientIPC : IDisposable
|
|||
|
||||
private readonly ICallGateSubscriber<string, ushort, object?> passengerMountedSubscriber;
|
||||
|
||||
private readonly ICallGateSubscriber<string, ushort, string, object?> helperStatusSubscriber;
|
||||
|
||||
public event Action<string, ushort>? OnHelperRequested;
|
||||
|
||||
public event Action? OnHelperDismissed;
|
||||
|
|
@ -41,6 +45,8 @@ public class MultiClientIPC : IDisposable
|
|||
|
||||
public event Action<string, ushort>? OnPassengerMounted;
|
||||
|
||||
public event Action<string, ushort, string>? OnHelperStatusUpdate;
|
||||
|
||||
public MultiClientIPC(IDalamudPluginInterface pluginInterface, IPluginLog log)
|
||||
{
|
||||
this.pluginInterface = pluginInterface;
|
||||
|
|
@ -50,11 +56,13 @@ public class MultiClientIPC : IDisposable
|
|||
helperAvailableProvider = pluginInterface.GetIpcProvider<string, ushort, object>("QSTCompanion.HelperAvailable");
|
||||
chatMessageProvider = pluginInterface.GetIpcProvider<string, object>("QSTCompanion.ChatMessage");
|
||||
passengerMountedProvider = pluginInterface.GetIpcProvider<string, ushort, object>("QSTCompanion.PassengerMounted");
|
||||
helperStatusProvider = pluginInterface.GetIpcProvider<string, ushort, string, object>("QSTCompanion.HelperStatus");
|
||||
requestHelperSubscriber = pluginInterface.GetIpcSubscriber<string, ushort, object>("QSTCompanion.RequestHelper");
|
||||
dismissHelperSubscriber = pluginInterface.GetIpcSubscriber<object>("QSTCompanion.DismissHelper");
|
||||
helperAvailableSubscriber = pluginInterface.GetIpcSubscriber<string, ushort, object>("QSTCompanion.HelperAvailable");
|
||||
chatMessageSubscriber = pluginInterface.GetIpcSubscriber<string, object>("QSTCompanion.ChatMessage");
|
||||
passengerMountedSubscriber = pluginInterface.GetIpcSubscriber<string, ushort, object>("QSTCompanion.PassengerMounted");
|
||||
helperStatusSubscriber = pluginInterface.GetIpcSubscriber<string, ushort, string, object>("QSTCompanion.HelperStatus");
|
||||
requestHelperProvider.RegisterFunc(delegate(string name, ushort worldId)
|
||||
{
|
||||
OnRequestHelperReceived(name, worldId);
|
||||
|
|
@ -80,6 +88,11 @@ public class MultiClientIPC : IDisposable
|
|||
OnPassengerMountedReceived(questerName, questerWorld);
|
||||
return (object?)null;
|
||||
});
|
||||
helperStatusProvider.RegisterFunc(delegate(string helperName, ushort helperWorld, string status)
|
||||
{
|
||||
OnHelperStatusReceived(helperName, helperWorld, status);
|
||||
return (object?)null;
|
||||
});
|
||||
log.Information("[MultiClientIPC] ✅ IPC initialized successfully");
|
||||
}
|
||||
|
||||
|
|
@ -213,6 +226,32 @@ public class MultiClientIPC : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
public void BroadcastHelperStatus(string helperName, ushort worldId, string status)
|
||||
{
|
||||
try
|
||||
{
|
||||
log.Debug($"[MultiClientIPC] Broadcasting helper status: {helperName}@{worldId} = {status}");
|
||||
helperStatusSubscriber.InvokeFunc(helperName, worldId, status);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.Error("[MultiClientIPC] Failed to broadcast helper status: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnHelperStatusReceived(string helperName, ushort helperWorld, string status)
|
||||
{
|
||||
try
|
||||
{
|
||||
log.Debug($"[MultiClientIPC] Received helper status: {helperName}@{helperWorld} = {status}");
|
||||
this.OnHelperStatusUpdate?.Invoke(helperName, helperWorld, status);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.Error("[MultiClientIPC] Error handling helper status: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
|
|
@ -221,6 +260,7 @@ public class MultiClientIPC : IDisposable
|
|||
dismissHelperProvider.UnregisterFunc();
|
||||
helperAvailableProvider.UnregisterFunc();
|
||||
chatMessageProvider.UnregisterFunc();
|
||||
helperStatusProvider.UnregisterFunc();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
using System;
|
||||
using Dalamud.Plugin.Services;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI;
|
||||
using FFXIVClientStructs.FFXIV.Component.GUI;
|
||||
|
||||
namespace QuestionableCompanion.Services;
|
||||
|
|
@ -21,6 +20,8 @@ public class PartyInviteAutoAccept : IDisposable
|
|||
|
||||
private DateTime autoAcceptUntil = DateTime.MinValue;
|
||||
|
||||
private bool hasLoggedAlwaysAccept;
|
||||
|
||||
public PartyInviteAutoAccept(IPluginLog log, IFramework framework, IGameGui gameGui, IPartyList partyList, Configuration configuration)
|
||||
{
|
||||
this.log = log;
|
||||
|
|
@ -47,16 +48,50 @@ public class PartyInviteAutoAccept : IDisposable
|
|||
log.Information("[PartyInviteAutoAccept] Will accept ALL party invites during this time!");
|
||||
}
|
||||
|
||||
public void EnableForQuester(string questerName)
|
||||
{
|
||||
shouldAutoAccept = true;
|
||||
autoAcceptUntil = DateTime.Now.AddSeconds(60.0);
|
||||
log.Information("[PartyInviteAutoAccept] Auto-accept enabled for quester: " + questerName);
|
||||
log.Information("[PartyInviteAutoAccept] Will accept invites for 60 seconds");
|
||||
}
|
||||
|
||||
private unsafe void OnFrameworkUpdate(IFramework framework)
|
||||
{
|
||||
if (!shouldAutoAccept)
|
||||
bool shouldAcceptNow = false;
|
||||
if (configuration.IsHighLevelHelper && configuration.AlwaysAutoAcceptInvites)
|
||||
{
|
||||
return;
|
||||
if (!hasLoggedAlwaysAccept)
|
||||
{
|
||||
log.Information("[PartyInviteAutoAccept] === ALWAYS AUTO-ACCEPT ENABLED ===");
|
||||
log.Information("[PartyInviteAutoAccept] Helper will continuously accept ALL party invites");
|
||||
log.Information("[PartyInviteAutoAccept] This mode is ALWAYS ON (no timeout)");
|
||||
hasLoggedAlwaysAccept = true;
|
||||
}
|
||||
shouldAcceptNow = true;
|
||||
}
|
||||
if (DateTime.Now > autoAcceptUntil)
|
||||
else if (shouldAutoAccept)
|
||||
{
|
||||
if (hasLoggedAlwaysAccept)
|
||||
{
|
||||
log.Information("[PartyInviteAutoAccept] Always auto-accept disabled");
|
||||
hasLoggedAlwaysAccept = false;
|
||||
}
|
||||
if (DateTime.Now > autoAcceptUntil)
|
||||
{
|
||||
shouldAutoAccept = false;
|
||||
log.Information("[PartyInviteAutoAccept] Auto-accept window expired");
|
||||
return;
|
||||
}
|
||||
shouldAcceptNow = true;
|
||||
}
|
||||
else if (hasLoggedAlwaysAccept)
|
||||
{
|
||||
log.Information("[PartyInviteAutoAccept] Always auto-accept disabled");
|
||||
hasLoggedAlwaysAccept = false;
|
||||
}
|
||||
if (!shouldAcceptNow)
|
||||
{
|
||||
shouldAutoAccept = false;
|
||||
log.Information("[PartyInviteAutoAccept] Auto-accept window expired");
|
||||
return;
|
||||
}
|
||||
try
|
||||
|
|
@ -72,66 +107,35 @@ public class PartyInviteAutoAccept : IDisposable
|
|||
break;
|
||||
}
|
||||
}
|
||||
if (addonPtr == IntPtr.Zero)
|
||||
if (addonPtr != IntPtr.Zero)
|
||||
{
|
||||
if (DateTime.Now.Second % 5 != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
log.Debug($"[PartyInviteAutoAccept] Still waiting for party invite dialog... ({(autoAcceptUntil - DateTime.Now).TotalSeconds:F0}s remaining)");
|
||||
if (DateTime.Now.Second % 10 != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
log.Warning("[PartyInviteAutoAccept] === DUMPING ALL VISIBLE ADDONS ===");
|
||||
RaptureAtkUnitManager* atkStage = RaptureAtkUnitManager.Instance();
|
||||
if (atkStage != null)
|
||||
{
|
||||
AtkUnitManager* unitManager = &atkStage->AtkUnitManager;
|
||||
for (int j = 0; j < unitManager->AllLoadedUnitsList.Count; j++)
|
||||
{
|
||||
AtkUnitBase* addon = unitManager->AllLoadedUnitsList.Entries[j].Value;
|
||||
if (addon != null && addon->IsVisible)
|
||||
{
|
||||
string name2 = addon->NameString;
|
||||
log.Warning("[PartyInviteAutoAccept] Visible addon: " + name2);
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Warning("[PartyInviteAutoAccept] === END ADDON DUMP ===");
|
||||
}
|
||||
else
|
||||
{
|
||||
AtkUnitBase* addon2 = (AtkUnitBase*)addonPtr;
|
||||
if (addon2 == null)
|
||||
AtkUnitBase* addon = (AtkUnitBase*)addonPtr;
|
||||
if (addon == null)
|
||||
{
|
||||
log.Warning("[PartyInviteAutoAccept] Addon pointer is null!");
|
||||
return;
|
||||
}
|
||||
if (!addon2->IsVisible)
|
||||
else if (addon->IsVisible)
|
||||
{
|
||||
log.Debug("[PartyInviteAutoAccept] Addon exists but not visible yet");
|
||||
return;
|
||||
AtkValue* values = stackalloc AtkValue[1];
|
||||
*values = new AtkValue
|
||||
{
|
||||
Type = FFXIVClientStructs.FFXIV.Component.GUI.ValueType.Int,
|
||||
Int = 0
|
||||
};
|
||||
addon->FireCallback(1u, values);
|
||||
AtkValue* values2 = stackalloc AtkValue[2];
|
||||
*values2 = new AtkValue
|
||||
{
|
||||
Type = FFXIVClientStructs.FFXIV.Component.GUI.ValueType.Int,
|
||||
Int = 0
|
||||
};
|
||||
values2[1] = new AtkValue
|
||||
{
|
||||
Type = FFXIVClientStructs.FFXIV.Component.GUI.ValueType.UInt,
|
||||
UInt = 0u
|
||||
};
|
||||
addon->FireCallback(2u, values2);
|
||||
}
|
||||
AtkValue* values = stackalloc AtkValue[1];
|
||||
*values = new AtkValue
|
||||
{
|
||||
Type = FFXIVClientStructs.FFXIV.Component.GUI.ValueType.Int,
|
||||
Int = 0
|
||||
};
|
||||
addon2->FireCallback(1u, values);
|
||||
AtkValue* values2 = stackalloc AtkValue[2];
|
||||
*values2 = new AtkValue
|
||||
{
|
||||
Type = FFXIVClientStructs.FFXIV.Component.GUI.ValueType.Int,
|
||||
Int = 0
|
||||
};
|
||||
values2[1] = new AtkValue
|
||||
{
|
||||
Type = FFXIVClientStructs.FFXIV.Component.GUI.ValueType.UInt,
|
||||
UInt = 0u
|
||||
};
|
||||
addon2->FireCallback(2u, values2);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
|
|||
|
|
@ -293,6 +293,22 @@ public class QuestPreCheckService : IDisposable
|
|||
log.Information("[QuestPreCheck] Pre-check results cleared");
|
||||
}
|
||||
|
||||
public void ClearCharacterData(string characterName)
|
||||
{
|
||||
if (questDatabase.ContainsKey(characterName))
|
||||
{
|
||||
int questCount = questDatabase[characterName].Count;
|
||||
questDatabase.Remove(characterName);
|
||||
SaveQuestDatabase();
|
||||
log.Information($"[QuestPreCheck] Cleared {questCount} quests for {characterName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
log.Information("[QuestPreCheck] No quest data found for " + characterName);
|
||||
}
|
||||
lastRefreshByCharacter.Remove(characterName);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SaveQuestDatabase();
|
||||
|
|
|
|||
|
|
@ -538,6 +538,31 @@ public class QuestRotationExecutionService : IDisposable
|
|||
return false;
|
||||
}
|
||||
|
||||
public void ClearCharacterQuestData(string characterName)
|
||||
{
|
||||
log.Information("[QuestRotation] Clearing all quest data for " + characterName);
|
||||
int questsCleared = 0;
|
||||
foreach (KeyValuePair<uint, List<string>> kvp in questCompletionByCharacter.ToList())
|
||||
{
|
||||
if (kvp.Value.Remove(characterName))
|
||||
{
|
||||
questsCleared++;
|
||||
}
|
||||
if (kvp.Value.Count == 0)
|
||||
{
|
||||
questCompletionByCharacter.Remove(kvp.Key);
|
||||
}
|
||||
}
|
||||
log.Information($"[QuestRotation] Removed {characterName} from {questsCleared} quests in rotation tracking");
|
||||
if (preCheckService != null)
|
||||
{
|
||||
preCheckService.ClearCharacterData(characterName);
|
||||
log.Information("[QuestRotation] Cleared " + characterName + " data from PreCheck service");
|
||||
}
|
||||
onDataChanged?.Invoke();
|
||||
log.Information("[QuestRotation] Quest data reset complete for " + characterName);
|
||||
}
|
||||
|
||||
private void ScanAndSaveAllCompletedQuests(string characterName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(characterName))
|
||||
|
|
@ -637,12 +662,19 @@ public class QuestRotationExecutionService : IDisposable
|
|||
{
|
||||
log.Warning("[ErrorRecovery] Disconnect detected for " + charToRelog);
|
||||
log.Information("[ErrorRecovery] Automatically relogging to " + charToRelog + "...");
|
||||
errorRecoveryService.Reset();
|
||||
currentState.Phase = RotationPhase.WaitingForCharacterLogin;
|
||||
currentState.CurrentCharacter = charToRelog;
|
||||
currentState.PhaseStartTime = DateTime.Now;
|
||||
autoRetainerIpc.SwitchCharacter(charToRelog);
|
||||
log.Information("[ErrorRecovery] Relog initiated for " + charToRelog);
|
||||
if (errorRecoveryService.RequestRelog())
|
||||
{
|
||||
errorRecoveryService.Reset();
|
||||
currentState.Phase = RotationPhase.WaitingForCharacterLogin;
|
||||
currentState.CurrentCharacter = charToRelog;
|
||||
currentState.PhaseStartTime = DateTime.Now;
|
||||
log.Information("[ErrorRecovery] Relog initiated for " + charToRelog);
|
||||
}
|
||||
else
|
||||
{
|
||||
log.Error("[ErrorRecovery] Failed to request relog via AutoRetainer");
|
||||
errorRecoveryService.Reset();
|
||||
}
|
||||
return;
|
||||
}
|
||||
log.Warning("[ErrorRecovery] Disconnect detected but no character to relog to");
|
||||
|
|
@ -652,19 +684,12 @@ public class QuestRotationExecutionService : IDisposable
|
|||
{
|
||||
deathHandler.Update();
|
||||
}
|
||||
if (dungeonAutomation != null)
|
||||
if (dungeonAutomation != null && !submarineManager.IsSubmarinePaused)
|
||||
{
|
||||
if (submarineManager.IsSubmarinePaused)
|
||||
dungeonAutomation.Update();
|
||||
if (isRotationActive && configuration.EnableAutoDutyUnsynced && !dungeonAutomation.IsWaitingForParty && currentState.Phase != RotationPhase.WaitingForCharacterLogin && currentState.Phase != RotationPhase.WaitingBeforeCharacterSwitch && currentState.Phase != RotationPhase.WaitingForHomeworldReturn && currentState.Phase != RotationPhase.ScanningQuests && currentState.Phase != RotationPhase.CheckingQuestCompletion && currentState.Phase != RotationPhase.InitializingFirstCharacter)
|
||||
{
|
||||
log.Debug("[QuestRotation] Submarine multi-mode active - skipping dungeon validation");
|
||||
}
|
||||
else
|
||||
{
|
||||
dungeonAutomation.Update();
|
||||
if (isRotationActive && configuration.EnableAutoDutyUnsynced && !dungeonAutomation.IsWaitingForParty && currentState.Phase != RotationPhase.WaitingForCharacterLogin && currentState.Phase != RotationPhase.WaitingBeforeCharacterSwitch && currentState.Phase != RotationPhase.WaitingForHomeworldReturn && currentState.Phase != RotationPhase.ScanningQuests && currentState.Phase != RotationPhase.CheckingQuestCompletion && currentState.Phase != RotationPhase.InitializingFirstCharacter)
|
||||
{
|
||||
_ = submarineManager.IsSubmarinePaused;
|
||||
}
|
||||
_ = submarineManager.IsSubmarinePaused;
|
||||
}
|
||||
}
|
||||
if (combatDutyDetection != null)
|
||||
|
|
@ -1164,7 +1189,25 @@ public class QuestRotationExecutionService : IDisposable
|
|||
{
|
||||
string currentQuestIdStr2 = questionableIPC.GetCurrentQuestId();
|
||||
byte? currentSequence2 = questionableIPC.GetCurrentSequence();
|
||||
if (!string.IsNullOrEmpty(currentQuestIdStr2) && currentSequence2.HasValue && uint.TryParse(currentQuestIdStr2, out var currentQuestId2))
|
||||
uint currentQuestId2;
|
||||
if (string.IsNullOrEmpty(currentQuestIdStr2) && currentState.HasQuestBeenAccepted)
|
||||
{
|
||||
if (QuestManager.Instance() != null)
|
||||
{
|
||||
byte gameQuestSeq = QuestManager.GetQuestSequence((ushort)questId);
|
||||
if (gameQuestSeq >= activeStopPoint.Sequence.Value)
|
||||
{
|
||||
log.Information("[QuestRotation] ✓ Questionable auto-stopped at stop point!");
|
||||
log.Information($"[QuestRotation] Quest {questId} Sequence {gameQuestSeq} >= {activeStopPoint.Sequence.Value}");
|
||||
shouldRotate = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
log.Debug($"[QuestRotation] Questionable stopped but not at stop sequence (seq {gameQuestSeq} < {activeStopPoint.Sequence.Value})");
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(currentQuestIdStr2) && currentSequence2.HasValue && uint.TryParse(currentQuestIdStr2, out currentQuestId2))
|
||||
{
|
||||
if (currentQuestId2 == questId)
|
||||
{
|
||||
|
|
@ -1315,17 +1358,24 @@ public class QuestRotationExecutionService : IDisposable
|
|||
{
|
||||
return;
|
||||
}
|
||||
log.Information("[QuestRotation] ========================================");
|
||||
log.Information("[QuestRotation] === SENDING HOMEWORLD RETURN COMMAND ===");
|
||||
log.Information("[QuestRotation] ========================================");
|
||||
try
|
||||
if (configuration.ReturnToHomeworldOnStopQuest)
|
||||
{
|
||||
commandManager.ProcessCommand("/li");
|
||||
log.Information("[QuestRotation] ✓ /li command sent (homeworld return)");
|
||||
log.Information("[QuestRotation] ========================================");
|
||||
log.Information("[QuestRotation] === SENDING HOMEWORLD RETURN COMMAND ===");
|
||||
log.Information("[QuestRotation] ========================================");
|
||||
try
|
||||
{
|
||||
commandManager.ProcessCommand("/li");
|
||||
log.Information("[QuestRotation] ✓ /li command sent (homeworld return)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.Error("[QuestRotation] Failed to send /li command: " + ex.Message);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
else
|
||||
{
|
||||
log.Error("[QuestRotation] Failed to send /li command: " + ex.Message);
|
||||
log.Information("[QuestRotation] Skipping homeworld return (setting disabled)");
|
||||
}
|
||||
Task.Delay(2000).ContinueWith(delegate
|
||||
{
|
||||
|
|
|
|||
|
|
@ -47,10 +47,6 @@ public class StepsOfFaithHandler : IDisposable
|
|||
|
||||
public bool ShouldActivate(uint questId, bool isInSoloDuty)
|
||||
{
|
||||
if (!config.EnableAutoDutyUnsynced)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (isActive)
|
||||
{
|
||||
return false;
|
||||
|
|
@ -70,8 +66,10 @@ public class StepsOfFaithHandler : IDisposable
|
|||
}
|
||||
if (characterHandledStatus.GetValueOrDefault(characterName, defaultValue: false))
|
||||
{
|
||||
log.Debug("[StepsOfFaith] Character " + characterName + " already handled SoF - skipping");
|
||||
return false;
|
||||
}
|
||||
log.Information("[StepsOfFaith] Handler will activate for " + characterName);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -108,11 +106,6 @@ public class StepsOfFaithHandler : IDisposable
|
|||
}
|
||||
log.Information("[StepsOfFaith] Waiting 25s for stabilization...");
|
||||
Thread.Sleep(25000);
|
||||
log.Information("[StepsOfFaith] Disabling Bossmod Rotation...");
|
||||
framework.RunOnFrameworkThread(delegate
|
||||
{
|
||||
commandManager.ProcessCommand("/vbm ar disable");
|
||||
});
|
||||
log.Information("[StepsOfFaith] Moving to target position...");
|
||||
framework.RunOnFrameworkThread(delegate
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Dalamud.Plugin.Services;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
|
@ -28,6 +27,8 @@ public class SubmarineManager : IDisposable
|
|||
|
||||
private bool submarinesPaused;
|
||||
|
||||
private bool externalPause;
|
||||
|
||||
private bool submarinesWaitingForSeq0;
|
||||
|
||||
private bool submarineReloginInProgress;
|
||||
|
|
@ -36,7 +37,17 @@ public class SubmarineManager : IDisposable
|
|||
|
||||
private string? originalCharacterForSubmarines;
|
||||
|
||||
public bool IsSubmarinePaused => submarinesPaused;
|
||||
public bool IsSubmarinePaused
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!submarinesPaused)
|
||||
{
|
||||
return externalPause;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsWaitingForSequence0 => submarinesWaitingForSeq0;
|
||||
|
||||
|
|
@ -44,6 +55,12 @@ public class SubmarineManager : IDisposable
|
|||
|
||||
public bool IsSubmarineJustCompleted => submarineJustCompleted;
|
||||
|
||||
public void SetExternalPause(bool paused)
|
||||
{
|
||||
externalPause = paused;
|
||||
log.Information($"[SubmarineManager] External pause set to: {paused}");
|
||||
}
|
||||
|
||||
public SubmarineManager(IPluginLog log, AutoRetainerIPC autoRetainerIPC, Configuration config, ICommandManager? commandManager = null, IFramework? framework = null)
|
||||
{
|
||||
this.log = log;
|
||||
|
|
@ -88,7 +105,7 @@ public class SubmarineManager : IDisposable
|
|||
|
||||
public bool CheckSubmarines()
|
||||
{
|
||||
if (!config.EnableSubmarineCheck)
|
||||
if (!config.EnableSubmarineCheck || externalPause)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
|
@ -154,7 +171,7 @@ public class SubmarineManager : IDisposable
|
|||
|
||||
public int CheckSubmarinesSoon()
|
||||
{
|
||||
if (!config.EnableSubmarineCheck)
|
||||
if (!config.EnableSubmarineCheck || externalPause)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -230,25 +247,39 @@ public class SubmarineManager : IDisposable
|
|||
{
|
||||
JObject json = JObject.Parse(jsonContent);
|
||||
HashSet<string> enabledSubs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (json.SelectTokens("$..EnabledSubs").FirstOrDefault() is JArray enabledSubsArray)
|
||||
IEnumerable<JToken> enumerable = json.SelectTokens("$..EnabledSubs");
|
||||
int arrayCount = 0;
|
||||
foreach (JToken item in enumerable)
|
||||
{
|
||||
foreach (JToken item in enabledSubsArray)
|
||||
if (!(item is JArray enabledSubsArray))
|
||||
{
|
||||
string subName = item.Value<string>();
|
||||
continue;
|
||||
}
|
||||
arrayCount++;
|
||||
foreach (JToken item2 in enabledSubsArray)
|
||||
{
|
||||
string subName = item2.Value<string>();
|
||||
if (!string.IsNullOrEmpty(subName))
|
||||
{
|
||||
enabledSubs.Add(subName);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (arrayCount > 0)
|
||||
{
|
||||
if (enabledSubs.Count > 0)
|
||||
{
|
||||
log.Information($"[SubmarineManager] Found {enabledSubs.Count} enabled submarines: {string.Join(", ", enabledSubs)}");
|
||||
log.Information($"[SubmarineManager] Found {enabledSubs.Count} unique submarine name(s) across {arrayCount} character(s): {string.Join(", ", enabledSubs)}");
|
||||
}
|
||||
else
|
||||
{
|
||||
log.Information("[SubmarineManager] EnabledSubs array found but empty - NO submarines will be checked");
|
||||
log.Information($"[SubmarineManager] Found {arrayCount} EnabledSubs array(s) but all empty - NO submarines will be checked");
|
||||
}
|
||||
FindReturnTimes(json, returnTimes, enabledSubs);
|
||||
if (returnTimes.Count > 0)
|
||||
{
|
||||
log.Information($"[SubmarineManager] Total submarines to monitor: {returnTimes.Count} (including same-named subs from different characters)");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -281,10 +312,6 @@ public class SubmarineManager : IDisposable
|
|||
{
|
||||
long returnTime = returnTimeToken.Value<long>();
|
||||
returnTimes.Add(returnTime);
|
||||
if (enabledSubs != null)
|
||||
{
|
||||
log.Debug($"[SubmarineManager] Including submarine '{submarineName}' (ReturnTime: {returnTime})");
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue