using System; using System.Collections.Generic; using System.Linq; using System.Numerics; using System.Runtime.InteropServices; using Dalamud.Game.ClientState.Conditions; using Dalamud.Game.ClientState.Objects.Enums; using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.ClientState.Objects.Types; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Plugin.Services; using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.Game.Object; using FFXIVClientStructs.FFXIV.Client.Game.UI; using FFXIVClientStructs.FFXIV.Client.System.Framework; using FFXIVClientStructs.FFXIV.Common.Component.BGCollision; using Lumina.Excel.Sheets; using Microsoft.Extensions.Logging; using Questionable.Controller.CombatModules; using Questionable.Controller.Steps; using Questionable.Controller.Utils; using Questionable.External; using Questionable.Functions; using Questionable.Model; using Questionable.Model.Questing; namespace Questionable.Controller; internal sealed class CombatController : IDisposable { private sealed class CurrentFight { public required ICombatModule Module { get; init; } public required CombatData Data { get; init; } public required long LastDistanceCheck { get; set; } public bool HasAttemptedFateSync { get; set; } public Dictionary KillCountsByComplexDataIndex { get; } = new Dictionary(); public int TotalKillCount { get; set; } public HashSet CountedEnemies { get; } = new HashSet(); } public sealed class CombatData { public required ElementId? ElementId { get; init; } public required int Sequence { get; init; } public required IList CompletionQuestVariablesFlags { get; init; } public required EEnemySpawnType SpawnType { get; init; } public required List KillEnemyDataIds { get; init; } public required List ComplexCombatDatas { get; init; } public required CombatItemUse? CombatItemUse { get; init; } public HashSet CompletedComplexDatas { get; } = new HashSet(); } public enum EStatus { NotStarted, InCombat, Moving, Complete } private const float MaxNameplateRange = 50f; private const float MaxSelfDefenseRange = 30f; private readonly List _combatModules; private readonly MovementController _movementController; private readonly GameFunctions _gameFunctions; private readonly BossModIpc _bossModIpc; private readonly ITargetManager _targetManager; private readonly IObjectTable _objectTable; private readonly ICondition _condition; private readonly IClientState _clientState; private readonly QuestFunctions _questFunctions; private readonly IToastGui _toastGui; private readonly ILogger _logger; private readonly string? _cannotSeeTargetText; private readonly Dictionary _losIgnoreList = new Dictionary(); private CurrentFight? _currentFight; private bool _wasInCombat; private bool _passiveBossModEnabled; private ulong? _lastTargetId; private List? _previousQuestVariables; private ulong _lastLoSFailTargetId; private int _losFailCount; private long _lastLoSFailTimeMs; public bool IsRunning => _currentFight != null; public CombatController(IEnumerable combatModules, MovementController movementController, GameFunctions gameFunctions, BossModIpc bossModIpc, ITargetManager targetManager, IObjectTable objectTable, ICondition condition, IClientState clientState, QuestFunctions questFunctions, IToastGui toastGui, IDataManager dataManager, ILogger logger) { _combatModules = combatModules.ToList(); _movementController = movementController; _gameFunctions = gameFunctions; _bossModIpc = bossModIpc; _targetManager = targetManager; _objectTable = objectTable; _condition = condition; _clientState = clientState; _questFunctions = questFunctions; _toastGui = toastGui; _logger = logger; _clientState.TerritoryChanged += TerritoryChanged; toastGui.ErrorToast += OnErrorToast; try { _cannotSeeTargetText = dataManager.GetExcelSheet()?.GetRowOrDefault(562u)?.Text.ExtractText(); } catch (Exception exception) { _logger.LogDebug(exception, "Failed to load LoS failure text from game data"); _cannotSeeTargetText = null; } } public bool Start(CombatData combatData) { Stop("Starting combat"); ICombatModule combatModule = _combatModules.FirstOrDefault((ICombatModule x) => x.CanHandleFight(combatData)); if (combatModule == null) { return false; } if (!combatModule.Start(combatData)) { return false; } _currentFight = new CurrentFight { Module = combatModule, Data = combatData, LastDistanceCheck = Environment.TickCount64 }; EEnemySpawnType spawnType = combatData.SpawnType; bool wasInCombat = (uint)(spawnType - 8) <= 1u; _wasInCombat = wasInCombat; UpdateLastTargetAndQuestVariables(null); _logger.LogDebug("Combat started: Module={Module}, SpawnType={SpawnType}, BossModVariant={Variant}, BossModSupported={Supported}", combatModule.GetType().Name, combatData.SpawnType, _bossModIpc.Variant, _bossModIpc.IsSupported()); if (!(combatModule is BossModModule) && (!(combatModule is ItemUseModule itemUseModule) || !(itemUseModule.Delegate is BossModModule)) && _bossModIpc.IsSupported() && combatData.SpawnType != EEnemySpawnType.QuestInterruption) { _bossModIpc.EnableAi(BossModIpc.EPreset.Passive); _passiveBossModEnabled = true; } return true; } public EStatus Update() { if (_currentFight == null) { return EStatus.Complete; } if (_movementController.IsPathfinding || _movementController.IsPathRunning || !_movementController.HasBeenMovingForAtLeast(1000)) { return EStatus.Moving; } if (_currentFight.Data.SpawnType == EEnemySpawnType.OverworldEnemies) { if (_targetManager.Target != null) { _lastTargetId = _targetManager.Target.GameObjectId; } else if (_lastTargetId.HasValue) { IGameObject gameObject = _objectTable.FirstOrDefault((IGameObject x) => x.GameObjectId == _lastTargetId); if (gameObject != null) { if (gameObject.IsDead) { TrackKillIfNeeded(gameObject); ElementId elementId = _currentFight.Data.ElementId; QuestProgressInfo questProgressInfo = ((elementId != null) ? _questFunctions.GetQuestProgressInfo(elementId) : null); if (questProgressInfo != null && questProgressInfo.Sequence == _currentFight.Data.Sequence && QuestWorkUtils.HasCompletionFlags(_currentFight.Data.CompletionQuestVariablesFlags) && QuestWorkUtils.MatchesQuestWork(_currentFight.Data.CompletionQuestVariablesFlags, questProgressInfo)) { return EStatus.InCombat; } if (questProgressInfo == null || questProgressInfo.Sequence != _currentFight.Data.Sequence || _previousQuestVariables == null || questProgressInfo.Variables.SequenceEqual(_previousQuestVariables)) { return EStatus.InCombat; } UpdateLastTargetAndQuestVariables(null); } } else { _lastTargetId = null; } } } IGameObject target = _targetManager.Target; if (target != null) { int item = GetKillPriority(target).Priority; IGameObject gameObject2 = FindNextTarget(); int num = ((gameObject2 != null) ? GetKillPriority(gameObject2).Priority : 0); if (gameObject2 != null && gameObject2.Equals(target)) { if (!IsMovingOrShouldMove(target)) { try { _currentFight.Module.Update(target); } catch (TaskException) { Stop("Combat module failure"); throw; } } } else if (gameObject2 != null) { if (num > item || item == 0) { SetTarget(gameObject2); } } else { SetTarget(null); } } else { IGameObject gameObject3 = FindNextTarget(); if (gameObject3 != null && !gameObject3.IsDead) { SetTarget(gameObject3); } } if (_condition[ConditionFlag.InCombat]) { _wasInCombat = true; return EStatus.InCombat; } if (_wasInCombat) { return EStatus.Complete; } return EStatus.InCombat; } private void TrackKillIfNeeded(IGameObject deadEnemy) { if (_currentFight == null || !(deadEnemy is IBattleNpc battleNpc) || !_currentFight.CountedEnemies.Add(deadEnemy.GameObjectId)) { return; } List complexCombatDatas = _currentFight.Data.ComplexCombatDatas; for (int i = 0; i < complexCombatDatas.Count; i++) { ComplexCombatData complexCombatData = complexCombatDatas[i]; if (complexCombatData.DataId == battleNpc.BaseId && (!complexCombatData.NameId.HasValue || complexCombatData.NameId == battleNpc.NameId) && complexCombatData.MinimumKillCount.HasValue) { int num = (_currentFight.KillCountsByComplexDataIndex[i] = _currentFight.KillCountsByComplexDataIndex.GetValueOrDefault(i) + 1); int num3 = num; _logger.LogDebug("Tracked kill for ComplexCombatData[{Index}] ({DataId}): {Count}/{Required}", i, complexCombatData.DataId, num3, complexCombatData.MinimumKillCount.Value); return; } } if (complexCombatDatas.Count == 0 && _currentFight.Data.KillEnemyDataIds.Contains(battleNpc.BaseId)) { _currentFight.TotalKillCount++; _logger.LogDebug("Tracked kill for KillEnemyDataIds ({DataId}): Total count = {Count}", battleNpc.BaseId, _currentFight.TotalKillCount); } } private unsafe IGameObject? FindNextTarget() { if (_currentFight == null) { return null; } List complexCombatDatas = _currentFight.Data.ComplexCombatDatas; if (complexCombatDatas.Count > 0) { for (int i = 0; i < complexCombatDatas.Count; i++) { if (_currentFight.Data.CompletedComplexDatas.Contains(i)) { continue; } ComplexCombatData complexCombatData = complexCombatDatas[i]; bool hasValue = complexCombatData.MinimumKillCount.HasValue; bool flag = complexCombatData.RewardItemId.HasValue && complexCombatData.RewardItemCount.HasValue; bool flag2 = QuestWorkUtils.HasCompletionFlags(complexCombatData.CompletionQuestVariablesFlags); if (hasValue || flag) { bool flag3 = true; bool flag4 = true; if (hasValue) { int valueOrDefault = _currentFight.KillCountsByComplexDataIndex.GetValueOrDefault(i, 0); flag3 = valueOrDefault >= complexCombatData.MinimumKillCount.Value; if (flag3) { _logger.LogDebug("Kill count condition met for ComplexCombatData[{Index}]: {Count}/{Required}", i, valueOrDefault, complexCombatData.MinimumKillCount.Value); } } if (flag) { int inventoryItemCount = InventoryManager.Instance()->GetInventoryItemCount(complexCombatData.RewardItemId.Value, isHq: false, checkEquipped: true, checkArmory: true, 0); flag4 = inventoryItemCount >= complexCombatData.RewardItemCount.Value; if (flag4) { _logger.LogDebug("Item count condition met for ComplexCombatData[{Index}]: {ItemId} = {Count}/{Required}", i, complexCombatData.RewardItemId.Value, inventoryItemCount, complexCombatData.RewardItemCount.Value); } } if (flag3 && flag4) { _logger.LogDebug("Complex combat condition fulfilled for ComplexCombatData[{Index}] ({DataId}): KillCount={KillCountMet}, Items={ItemMet}", i, complexCombatData.DataId, flag3, flag4); _currentFight.Data.CompletedComplexDatas.Add(i); } } else if (flag2 && _currentFight.Data.ElementId is QuestId elementId) { QuestProgressInfo questProgressInfo = _questFunctions.GetQuestProgressInfo(elementId); if (questProgressInfo != null && QuestWorkUtils.MatchesQuestWork(complexCombatData.CompletionQuestVariablesFlags, questProgressInfo)) { _logger.LogDebug("Complex combat condition fulfilled for ComplexCombatData[{Index}] ({DataId}): QuestWork (fallback)", i, complexCombatData.DataId); _currentFight.Data.CompletedComplexDatas.Add(i); } } } } IPlayerCharacter localPlayer = _objectTable.LocalPlayer; Vector3 value = localPlayer?.Position ?? Vector3.Zero; IGameObject gameObject = null; int num = 0; float num2 = float.MaxValue; bool flag5 = _logger.IsEnabled(LogLevel.Debug); foreach (IGameObject item in _objectTable) { var (num3, text) = GetKillPriority(item, localPlayer); if (num3 <= 0) { continue; } if (_losIgnoreList.TryGetValue(item.GameObjectId, out var value2)) { if (Environment.TickCount64 < value2) { if (flag5) { _logger.LogDebug("Target {Name} ({Id:X8}) skipped - LoS ignore list ({Remaining}ms left)", item.Name, item.GameObjectId, value2 - Environment.TickCount64); } continue; } _losIgnoreList.Remove(item.GameObjectId); if (flag5) { _logger.LogDebug("Target {Name} ({Id:X8}) - LoS ignore expired", item.Name, item.GameObjectId); } } float num4 = Vector3.Distance(item.Position, value); if (flag5) { _logger.LogDebug("Target candidate: {Name} ({Id:X8}) BaseId={BaseId} Priority={Priority} ({Reason}) Distance={Distance:N1}", item.Name, item.GameObjectId, item.BaseId, num3, text, num4); } if (num3 > num || (num3 == num && num4 < num2)) { gameObject = item; num = num3; num2 = num4; } } ulong? num5 = gameObject?.GameObjectId; if (num5 != _lastTargetId) { if (gameObject != null) { _logger.LogDebug("Selected target: {Name} ({Id:X8}) BaseId={BaseId} Priority={Priority} Distance={Distance:N1}", gameObject.Name, gameObject.GameObjectId, gameObject.BaseId, num, num2); } else { _logger.LogDebug("No valid target found"); } _lastTargetId = num5; } if (gameObject != null && num <= 15 && !_condition[ConditionFlag.InCombat]) { return null; } if (gameObject != null && _currentFight.Data.SpawnType == EEnemySpawnType.FateEnemies && !_currentFight.HasAttemptedFateSync) { ushort currentFateId = _gameFunctions.GetCurrentFateId(); if (currentFateId != 0) { _logger.LogDebug("Checking FATE sync for FATE {FateId}", currentFateId); _gameFunctions.SyncToFate(currentFateId); _currentFight.HasAttemptedFateSync = true; } } return gameObject; } public (int Priority, string Reason) GetKillPriority(IGameObject gameObject) { return GetKillPriority(gameObject, _objectTable.LocalPlayer); } private unsafe (int Priority, string Reason) GetKillPriority(IGameObject gameObject, IGameObject? localPlayer) { var (num, text) = GetRawKillPriority(gameObject, localPlayer); if (!num.HasValue) { if (gameObject is IBattleNpc { IsDead: false, IsTargetable: not false } battleNpc && battleNpc.StatusFlags.HasFlag(StatusFlags.InCombat) && localPlayer != null && Vector3.Distance(localPlayer.Position, gameObject.Position) <= 30f) { if (gameObject.TargetObjectId == localPlayer.GameObjectId) { return (Priority: 15, Reason: text + "/SelfDefense-Targeted"); } Hater hater = UIState.Instance()->Hater; for (int i = 0; i < hater.HaterCount; i++) { if (hater.Haters[i].EntityId == gameObject.GameObjectId) { return (Priority: 10, Reason: text + "/SelfDefense-Enmity"); } } } return (Priority: 0, Reason: text); } if (gameObject is IBattleNpc battleNpc2 && battleNpc2.StatusFlags.HasFlag(StatusFlags.InCombat)) { if (gameObject.TargetObjectId == localPlayer?.GameObjectId) { return (Priority: num.Value + 150, Reason: text + "/Targeted"); } Hater hater2 = UIState.Instance()->Hater; for (int j = 0; j < hater2.HaterCount; j++) { if (hater2.Haters[j].EntityId == gameObject.GameObjectId) { return (Priority: num.Value + 125, Reason: text + "/Enmity"); } } } return (Priority: num.Value, Reason: text); } private unsafe (int? Priority, string Reason) GetRawKillPriority(IGameObject gameObject, IGameObject? localPlayer) { if (_currentFight == null) { return (Priority: null, Reason: "Not Fighting"); } if (!(gameObject is IBattleNpc battleNpc)) { return (Priority: null, Reason: "Not BattleNpc"); } if (!_currentFight.Module.CanAttack(battleNpc)) { return (Priority: null, Reason: "Can't attack"); } if (battleNpc.IsDead) { return (Priority: null, Reason: "Dead"); } if (!battleNpc.IsTargetable) { return (Priority: null, Reason: "Untargetable"); } List complexCombatDatas = _currentFight.Data.ComplexCombatDatas; GameObject* address = (GameObject*)gameObject.Address; if (address->FateId != 0 && _currentFight.Data.SpawnType != EEnemySpawnType.FateEnemies && gameObject.TargetObjectId != localPlayer?.GameObjectId) { return (Priority: null, Reason: "FATE mob"); } Vector3 value = localPlayer?.Position ?? Vector3.Zero; bool flag; switch (_currentFight.Data.SpawnType) { case EEnemySpawnType.FinishCombatIfAny: flag = false; break; case EEnemySpawnType.FateEnemies: flag = false; break; case EEnemySpawnType.OverworldEnemies: if (Vector3.Distance(value, battleNpc.Position) >= 50f) { flag = false; break; } goto default; default: flag = true; break; } bool flag2 = flag; if (complexCombatDatas.Count > 0) { for (int i = 0; i < complexCombatDatas.Count; i++) { if (!_currentFight.Data.CompletedComplexDatas.Contains(i) && (!flag2 || complexCombatDatas[i].IgnoreQuestMarker || address->NamePlateIconId != 0) && complexCombatDatas[i].DataId == battleNpc.BaseId && (!complexCombatDatas[i].NameId.HasValue || complexCombatDatas[i].NameId == battleNpc.NameId)) { return (Priority: 100, Reason: "CCD"); } } } else if ((!flag2 || address->NamePlateIconId != 0) && _currentFight.Data.KillEnemyDataIds.Contains(battleNpc.BaseId)) { return (Priority: 90, Reason: "KED"); } Dalamud.Game.ClientState.Objects.Enums.BattleNpcSubKind battleNpcKind = battleNpc.BattleNpcKind; if ((battleNpcKind == Dalamud.Game.ClientState.Objects.Enums.BattleNpcSubKind.BNpcPart || battleNpcKind == Dalamud.Game.ClientState.Objects.Enums.BattleNpcSubKind.Combatant) ? true : false) { uint namePlateIconId = address->NamePlateIconId; if ((namePlateIconId == 60093 || namePlateIconId == 60732) ? true : false) { return (Priority: null, Reason: "FATE NPC"); } return (Priority: null, Reason: "Not part of quest"); } return (Priority: null, Reason: "Wrong BattleNpcKind"); } private void SetTarget(IGameObject? target) { if (target == null) { if (_targetManager.Target != null) { _logger.LogDebug("Clearing target"); _targetManager.Target = null; } return; } IPlayerCharacter localPlayer = _objectTable.LocalPlayer; float num = ((localPlayer != null) ? Vector3.Distance(localPlayer.Position, target.Position) : 0f); bool flag = _currentFight?.Module is BossModModule; _logger.LogDebug("Setting target to {TargetName} ({TargetId:X8}), distance: {Distance:N2}", target.Name.ToString(), target.GameObjectId, num); _targetManager.Target = target; if (!flag) { MoveToTarget(target); } } private bool IsMovingOrShouldMove(IGameObject gameObject) { if (_currentFight?.Module is BossModModule) { return false; } if (_movementController.IsPathfinding || _movementController.IsPathRunning) { return true; } if (Environment.TickCount64 > _currentFight.LastDistanceCheck + 10000) { MoveToTarget(gameObject); _currentFight.LastDistanceCheck = Environment.TickCount64; return true; } return false; } private void MoveToTarget(IGameObject gameObject) { IPlayerCharacter localPlayer = _objectTable.LocalPlayer; if (localPlayer == null) { return; } float num = localPlayer.HitboxRadius + gameObject.HitboxRadius; float num2 = Vector3.Distance(localPlayer.Position, gameObject.Position); byte? b = localPlayer.ClassJob.ValueNullable?.Role; bool flag; if (b.HasValue) { byte valueOrDefault = b.GetValueOrDefault(); if ((uint)(valueOrDefault - 3) <= 1u) { flag = true; goto IL_008e; } } flag = false; goto IL_008e; IL_008e: float num3 = (flag ? 20f : 2.9f); bool flag2 = num2 - num >= num3; bool flag3 = IsInLineOfSight(gameObject); if (flag2 || !flag3) { bool flag4 = num2 - num > 5f; if (!flag2 && !flag3) { num3 = Math.Min(num3, num2) / 2f; flag4 = true; } if (!flag4) { _logger.LogDebug("Moving to {TargetName} ({BaseId}) to attack", gameObject.Name, gameObject.BaseId); MovementController movementController = _movementController; int num4 = 1; List list = new List(num4); CollectionsMarshal.SetCount(list, num4); CollectionsMarshal.AsSpan(list)[0] = gameObject.Position; movementController.NavigateTo(EMovementType.Combat, null, list, fly: false, sprint: false, num3 + num - 0.25f, float.MaxValue); } else { _logger.LogDebug("Moving to {TargetName} ({BaseId}) to attack (with navmesh)", gameObject.Name, gameObject.BaseId); _movementController.NavigateTo(EMovementType.Combat, null, gameObject.Position, fly: false, sprint: false, num3 + num - 0.25f, float.MaxValue); } } } internal unsafe bool IsInLineOfSight(IGameObject target) { IPlayerCharacter localPlayer = _objectTable.LocalPlayer; if (localPlayer == null) { return false; } Vector3 position = localPlayer.Position; position.Y += 2f; Vector3 position2 = target.Position; position2.Y += 2f; Vector3 vector = Vector3.Normalize(position2 - position); float maxDistance = Vector3.Distance(position, position2); int* flags = stackalloc int[4] { 16384, 0, 16384, 0 }; RaycastHit raycastHit = default(RaycastHit); return !Framework.Instance()->BGCollisionModule->RaycastMaterialFilter(&raycastHit, &position, &vector, maxDistance, 1, flags); } private void OnErrorToast(ref SeString message, ref bool isHandled) { if (_cannotSeeTargetText == null || _currentFight == null || !message.TextValue.Contains(_cannotSeeTargetText, StringComparison.Ordinal)) { return; } IGameObject target = _targetManager.Target; if (target == null) { return; } long tickCount = Environment.TickCount64; if (_lastLoSFailTargetId == target.GameObjectId && tickCount - _lastLoSFailTimeMs < 2000) { _losFailCount++; if (_losFailCount >= 2) { _losIgnoreList[target.GameObjectId] = tickCount + 10000; _logger.LogDebug("Target {TargetId:X8} added to LoS ignore list for 10s", target.GameObjectId); _losFailCount = 0; _targetManager.Target = null; } } else { _lastLoSFailTargetId = target.GameObjectId; _losFailCount = 1; } _lastLoSFailTimeMs = tickCount; } private void UpdateLastTargetAndQuestVariables(IGameObject? target) { _lastTargetId = target?.GameObjectId; _previousQuestVariables = ((!(_currentFight.Data.ElementId != null)) ? null : _questFunctions.GetQuestProgressInfo(_currentFight.Data.ElementId)?.Variables); } public void Stop(string label) { using (_logger.BeginScope(label)) { if (_currentFight != null) { _logger.LogDebug("Stopping current fight"); _currentFight.Module.Stop(); } if (_passiveBossModEnabled) { _bossModIpc.DisableAi(); _passiveBossModEnabled = false; } _losIgnoreList.Clear(); _losFailCount = 0; _currentFight = null; _wasInCombat = false; } } private void TerritoryChanged(uint territoryId) { Stop("TerritoryChanged"); } public void Dispose() { _toastGui.ErrorToast -= OnErrorToast; _clientState.TerritoryChanged -= TerritoryChanged; Stop("Dispose"); } }