diff --git a/BossMod/ActionTweaks/AnimationLockTweak.cs b/BossMod/ActionTweaks/AnimationLockTweak.cs index 3fa87e7c5f..80a00fddf8 100644 --- a/BossMod/ActionTweaks/AnimationLockTweak.cs +++ b/BossMod/ActionTweaks/AnimationLockTweak.cs @@ -16,13 +16,44 @@ public sealed class AnimationLockTweak { private readonly ActionManagerConfig _config = Service.Config.Get(); + private float _lastReqInitialAnimLock; + private int _lastReqSequence = -1; public float DelaySmoothing = 0.8f; // TODO tweak public float DelayAverage { get; private set; } = 0.1f; // smoothed delay between client request and server response public float DelayEstimate => _config.RemoveAnimationLockDelay ? 0 : MathF.Min(DelayAverage * 1.5f, 0.1f); // this is a conservative estimate + // record initial animation lock after action request + public void RecordRequest(uint expectedSequence, float initialAnimLock) + { + _lastReqInitialAnimLock = initialAnimLock; + _lastReqSequence = (int)expectedSequence; + } + + // apply the tweak: calculate animation lock delay and calculate how much animation lock should be reduced + public float Apply(uint sequence, float gamePrevAnimLock, float gameCurrAnimLock, float packetPrevAnimLock, float packetCurrAnimLock, out float delay) + { + delay = _lastReqInitialAnimLock - gamePrevAnimLock; + if (delay < 0) + Service.Log($"[ALT] Prev anim lock {gamePrevAnimLock:f3} is larger than initial {_lastReqInitialAnimLock:f3}, something is wrong"); + if (_lastReqSequence != sequence && gameCurrAnimLock != gamePrevAnimLock) + Service.Log($"[ALT] Animation lock updated by action with unexpected sequence ID #{sequence}: {gamePrevAnimLock:f3} -> {gameCurrAnimLock:f3}"); + + float reduction = 0; + if (_lastReqSequence == sequence && _lastReqInitialAnimLock > 0) + { + SanityCheck(packetPrevAnimLock, packetCurrAnimLock); + DelayAverage = delay * (1 - DelaySmoothing) + DelayAverage * DelaySmoothing; // update the average + // the result will be subtracted from current anim lock (and thus from adjusted lock delay) + reduction = _config.RemoveCooldownDelay ? Math.Clamp(delay /* - DelayMax */, 0, gameCurrAnimLock) : 0; + } + _lastReqInitialAnimLock = 0; + _lastReqSequence = -1; + return reduction; + } + // perform sanity check to detect conflicting plugins: disable the tweak if condition is false - public void SanityCheck(float originalAnimLock, float modifiedAnimLock) + private void SanityCheck(float originalAnimLock, float modifiedAnimLock) { if (!_config.RemoveAnimationLockDelay) return; // nothing to do, tweak is already disabled @@ -32,12 +63,4 @@ public void SanityCheck(float originalAnimLock, float modifiedAnimLock) Service.Log($"[ALT] Unexpected animation lock {originalAnimLock:f} -> {modifiedAnimLock:f}, disabling anim lock tweak feature"); _config.RemoveAnimationLockDelay = false; // disable the tweak (but don't save the config, in case this condition is temporary) } - - // apply tweak: given the delay, calculate how much it should be reduced - public float Apply(float current, float delay) - { - DelayAverage = delay * (1 - DelaySmoothing) + DelayAverage * DelaySmoothing; // update the average - // the result will be subtracted from current anim lock (and thus from adjusted lock delay) - return _config.RemoveCooldownDelay ? Math.Clamp(delay /* - DelayMax */, 0, current) : 0; - } } diff --git a/BossMod/ActionTweaks/CooldownDelayTweak.cs b/BossMod/ActionTweaks/CooldownDelayTweak.cs new file mode 100644 index 0000000000..55b48679f3 --- /dev/null +++ b/BossMod/ActionTweaks/CooldownDelayTweak.cs @@ -0,0 +1,30 @@ +namespace BossMod; + +// Framerate-dependent cooldown reduction. +// Imagine game is running at exactly 100fps (10ms frame time), and action is queued when remaining cooldown is 5ms. +// On next frame (+10ms), cooldown will be reduced and clamped to 0, action will be executed and it's cooldown set to X ms - so next time it can be pressed at X+10 ms. +// If we were running with infinite fps, cooldown would be reduced to 0 and action would be executed slightly (5ms) earlier. +// We can't fix that easily, but at least we can fix the cooldown after action execution - so that next time it can be pressed at X+5ms. +// We do that by reducing actual cooldown by difference between previously-remaining cooldown and frame delta, if action is executed at first opportunity. +public sealed class CooldownDelayTweak +{ + private readonly ActionManagerConfig _config = Service.Config.Get(); + + public float Adjustment { get; private set; } // if >0 while using an action, cooldown/anim lock will be reduced by this amount as if action was used a bit in the past + + public void StartAdjustment(float prevAnimLock, float prevRemainingCooldown, float dt) => Adjustment = CalculateAdjustment(prevAnimLock, prevRemainingCooldown, dt); + public void StopAdjustment() => Adjustment = 0; + + private float CalculateAdjustment(float prevAnimLock, float prevRemainingCooldown, float dt) + { + if (!_config.RemoveCooldownDelay) + return 0; // tweak is disabled, so no adjustment + + var maxDelay = Math.Max(prevAnimLock, prevRemainingCooldown); + if (maxDelay <= 0) + return 0; // nothing prevented us from executing the action on previous frame, so no adjustment + + var overflow = dt - maxDelay; // both cooldown and animation lock should expire this much before current frame start + return Math.Clamp(overflow, 0, 0.1f); // use upper limit for time adjustment (if you have dogshit fps, adjusting too much could be suspicious) + } +} diff --git a/BossMod/ActionTweaks/RestoreRotationTweak.cs b/BossMod/ActionTweaks/RestoreRotationTweak.cs new file mode 100644 index 0000000000..80d99eb7d5 --- /dev/null +++ b/BossMod/ActionTweaks/RestoreRotationTweak.cs @@ -0,0 +1,45 @@ +namespace BossMod; + +// Preserving character facing direction tweak. +// When any action is executed, character is automatically rotated to face the target (this can be disabled in-game, but it would simply block an action if not facing target instead). +// This makes maintaining uptime during gaze mechanics unnecessarily complicated (requiring either moving or rotating mouse back-and-forth in non-legacy camera mode). +// This feature remembers original rotation before executing an action and then attempts to restore it. +// Just like any 'manual' way, it is not 100% reliable: +// * client rate-limits rotation updates, so even for instant casts there is a short window of time (~0.1s) following action execution when character faces a target on server +// * for movement-affecting abilities (jumps, charges, etc) rotation can't be restored until animation ends +// * for casted abilities, rotation isn't restored until slidecast window starts, as otherwise cast is interrupted +public sealed class RestoreRotationTweak +{ + private readonly ActionManagerConfig _config = Service.Config.Get(); + private Angle _modified; // rotation immediately after action execution; as long as it's not unchanged, we'll try restoring (otherwise we assume player changed facing manually and abort) + private Angle _original; // rotation immediately before action execution; this is what we're trying to restore + private int _numRetries; // for some reason, sometimes after successfully restoring rotation it is snapped back on next frame; in this case we retry again - TODO investigate why this happens + private bool _pending; + + public void Preserve(Angle original, Angle modified) + { + if (_config.RestoreRotation && original != modified) + { + _modified = modified; + _original = original; + _numRetries = 2; + _pending = true; + //Service.Log($"[RRT] Restore start: {modified.Rad} -> {original.Rad}"); + } + } + + public bool TryRestore(Angle current, out Angle updated) + { + //Service.Log($"[RRT] Restore rotation: {current.Rad}: {_modified.Rad}->{_original.Rad}"); + updated = _original; + if (!_pending) + return false; // we don't have any pending rotation to restore + + if (_modified.AlmostEqual(current, 0.01f)) + return true; // we still have the 'post' rotation, try restoring + + if (--_numRetries == 0) + _pending = false; // we have unexpected rotation and we're out of retries, stop trying + return false; + } +} diff --git a/BossMod/Autorotation/Autorotation.cs b/BossMod/Autorotation/Autorotation.cs index 112e1b6c0a..84aab4e651 100644 --- a/BossMod/Autorotation/Autorotation.cs +++ b/BossMod/Autorotation/Autorotation.cs @@ -42,7 +42,7 @@ sealed class Autorotation : IDisposable public Actor? SecondaryTarget; // this is usually a mouseover, but AI can override; typically used for heal and utility abilities public AIHints Hints = new(); public float EffAnimLock => ActionManagerEx.Instance!.EffectiveAnimationLock; - public float AnimLockDelay => ActionManagerEx.Instance!.AnimLockTweak.DelayEstimate; + public float AnimLockDelay => ActionManagerEx.Instance!.AnimationLockDelayEstimate; private static readonly ActionID IDSprintGeneral = new(ActionType.General, 4); diff --git a/BossMod/Autorotation/CommonActions.cs b/BossMod/Autorotation/CommonActions.cs index e1f7f4cf92..d165935247 100644 --- a/BossMod/Autorotation/CommonActions.cs +++ b/BossMod/Autorotation/CommonActions.cs @@ -328,7 +328,7 @@ protected void FillCommonPlayerState(CommonRotation.PlayerState s) s.TargetingEnemy = Autorot.PrimaryTarget != null && Autorot.PrimaryTarget.Type is ActorType.Enemy or ActorType.Part && !Autorot.PrimaryTarget.IsAlly; s.RangeToTarget = Autorot.PrimaryTarget != null ? (Autorot.PrimaryTarget.Position - Player.Position).Length() - Autorot.PrimaryTarget.HitboxRadius - Player.HitboxRadius : float.MaxValue; s.AnimationLock = am.EffectiveAnimationLock; - s.AnimationLockDelay = am.AnimLockTweak.DelayEstimate; + s.AnimationLockDelay = am.AnimationLockDelayEstimate; s.ComboTimeLeft = am.ComboTimeLeft; s.ComboLastAction = am.ComboLastMove; s.LimitBreakLevel = Autorot.WorldState.Party.LimitBreakMax > 0 ? Autorot.WorldState.Party.LimitBreakCur / Autorot.WorldState.Party.LimitBreakMax : 0; diff --git a/BossMod/Autorotation/GNB/GNBRotation.cs b/BossMod/Autorotation/GNB/GNBRotation.cs index 8b9d3542cd..c84c9b7a2b 100644 --- a/BossMod/Autorotation/GNB/GNBRotation.cs +++ b/BossMod/Autorotation/GNB/GNBRotation.cs @@ -1,4 +1,4 @@ -// CONTRIB: made by LazyLemo, tweaked by Akechi (there's still plenty of issues that need to be addressed.. but with DT around the corner, not so much on my mind) +// made by LazyLemo, edited by Akechi (there's still plenty of issues, but with DT around the corner, I dont care to fix them. These QoL updates should suffice until then) namespace BossMod.GNB; public static class Rotation @@ -394,6 +394,54 @@ public static AID GetNextAmmoAction(State state, Strategy strategy, bool aoe) if (Service.Config.Get().EarlySonicBreak && state.CD(CDGroup.NoMercy) > 40 && state.CD(CDGroup.SonicBreak) < 0.6f) return AID.SonicBreak; + // Lv30-53 NM proc ST + if (state.Unlocked(AID.NoMercy)) + { + bool canUseBurstStrike = (state.NoMercyLeft > 0) && + !state.Unlocked(AID.FatedCircle) && + !state.Unlocked(AID.DoubleDown) && + !state.Unlocked(AID.Bloodfest) && + !state.Unlocked(AID.Continuation) && + !state.Unlocked(AID.GnashingFang) && + state.Ammo >= 1; + + // ST + if (!aoe) + { + if (!state.Unlocked(AID.FatedCircle) && + !state.Unlocked(AID.DoubleDown) && + !state.Unlocked(AID.Bloodfest) && + !state.Unlocked(AID.Continuation) && + !state.Unlocked(AID.GnashingFang) && + state.Ammo >= 2) + { + return AID.NoMercy; + } + else if (canUseBurstStrike && state.Ammo >= 2) // Ensure at least 2 ammo for BurstStrike + { + return AID.BurstStrike; + } + } + + // AOE + if (aoe) + { + if (!state.Unlocked(AID.FatedCircle) && + !state.Unlocked(AID.DoubleDown) && + !state.Unlocked(AID.Bloodfest) && + !state.Unlocked(AID.Continuation) && + !state.Unlocked(AID.GnashingFang) && + state.Ammo >= 2) + { + return AID.NoMercy; + } + else if (canUseBurstStrike && state.Ammo >= 2) // Ensure at least 2 ammo for BurstStrike + { + return AID.BurstStrike; + } + } + } + if (state.CD(CDGroup.NoMercy) > 17) { if (state.GunComboStep == 0 && state.Unlocked(AID.GnashingFang) && state.CD(CDGroup.GnashingFang) < 0.6f && state.Ammo >= 1 && ShouldUseGnash(state, strategy) && state.NumTargetsHitByAOE <= 3) @@ -416,21 +464,70 @@ public static AID GetNextAmmoAction(State state, Strategy strategy, bool aoe) return AID.BurstStrike; if (!aoe && state.Ammo >= 1 && state.CD(CDGroup.GnashingFang) > state.GCD && !state.Unlocked(AID.DoubleDown) && !state.Unlocked(AID.SonicBreak) && state.GunComboStep == 0) return AID.BurstStrike; - - // Lv70 only; when in NM and you can't use Fated Circle (Lv72) sadge - if (aoe && state.Ammo >= 1 && !state.Unlocked(AID.FatedCircle) && !state.Unlocked(AID.DoubleDown) && !state.Unlocked(AID.Bloodfest) && state.Unlocked(AID.Continuation) && state.GunComboStep == 0) + if (!state.Unlocked(AID.FatedCircle) && !state.Unlocked(AID.DoubleDown) && !state.Unlocked(AID.Bloodfest) && !state.Unlocked(AID.Continuation) && !state.Unlocked(AID.GnashingFang) && !state.Unlocked(AID.SonicBreak) && state.Ammo >= 2) return AID.BurstStrike; - if (!aoe && state.Ammo >= 1 && !state.Unlocked(AID.DoubleDown) && !state.Unlocked(AID.SonicBreak) && !state.Unlocked(AID.GnashingFang)) - return AID.BurstStrike; - if (aoe && state.Ammo >= 1 && state.CD(CDGroup.GnashingFang) > state.GCD && state.CD(CDGroup.DoubleDown) > state.GCD && state.CD(CDGroup.SonicBreak) > state.GCD && state.Unlocked(AID.DoubleDown) && state.GunComboStep == 0) - return AID.FatedCircle; - if (aoe && state.Ammo >= 1 && state.CD(CDGroup.GnashingFang) > state.GCD && state.CD(CDGroup.SonicBreak) > state.GCD && !state.Unlocked(AID.DoubleDown) && state.GunComboStep == 0) - return AID.FatedCircle; - if (aoe && state.Ammo >= 1 && state.CD(CDGroup.GnashingFang) > state.GCD && state.Unlocked(AID.FatedCircle) && !state.Unlocked(AID.DoubleDown) && !state.Unlocked(AID.SonicBreak) && state.GunComboStep == 0) - return AID.FatedCircle; - if (aoe && state.Ammo >= 1 && state.Unlocked(AID.FatedCircle) && !state.Unlocked(AID.DoubleDown) && !state.Unlocked(AID.SonicBreak) && !state.Unlocked(AID.GnashingFang)) - return AID.FatedCircle; + if (!aoe) + { + if (state.Ammo >= 1 && !state.Unlocked(AID.DoubleDown) && !state.Unlocked(AID.Bloodfest) && !state.Unlocked(AID.Continuation) && !state.Unlocked(AID.GnashingFang) && !state.Unlocked(AID.SonicBreak)) + return AID.BurstStrike; // Use Burst Strike + } + // AOE Logic + else if (aoe) + { + if (state.NoMercyLeft > 0) + { + if (state.Ammo >= 1) + { + if (state.Unlocked(AID.GnashingFang) && state.CD(CDGroup.GnashingFang) == 0) + { + return AID.GnashingFang; // Use Gnashing Fang if available and off cooldown + } + if (!state.Unlocked(AID.FatedCircle) && !state.Unlocked(AID.DoubleDown)) + { + return AID.BurstStrike; // Use Burst Strike if Fated Circle and Double Down are not unlocked + } + } + if (state.Ammo >= 2 && !state.Unlocked(AID.DoubleDown) && + !state.Unlocked(AID.Bloodfest) && !state.Unlocked(AID.Continuation) && !state.Unlocked(AID.GnashingFang)) + { + return AID.BurstStrike; // Use Burst Strike for Lv30-53 AOE spender + } + if (state.Ammo >= 2 && state.Unlocked(AID.SonicBreak) && state.Unlocked(AID.GnashingFang) && + !state.Unlocked(AID.FatedCircle) && !state.Unlocked(AID.DoubleDown)) + { + return AID.GnashingFang; // Use Gnashing Fang for Lv60 AOE fix + } + } + if (state.Ammo >= 1 && state.GunComboStep == 0) + { + if (state.NoMercyLeft > 0 && !state.Unlocked(AID.FatedCircle) && !state.Unlocked(AID.DoubleDown) && !state.Unlocked(AID.Bloodfest) && + state.Unlocked(AID.Continuation)) + { + return AID.BurstStrike; // Lv70 AOE combo, no Fated Circle + } + if (state.Ammo >= 1 && state.NoMercyLeft > 0 && state.Unlocked(AID.SonicBreak) && state.Unlocked(AID.GnashingFang) && + !state.Unlocked(AID.FatedCircle) && !state.Unlocked(AID.DoubleDown)) + { + return AID.BurstStrike; // Lv60 AOE BurstStrike fix + } + if (state.CD(CDGroup.GnashingFang) > state.GCD && state.CD(CDGroup.DoubleDown) > state.GCD && + state.CD(CDGroup.SonicBreak) > state.GCD && state.Unlocked(AID.DoubleDown)) + { + return AID.FatedCircle; // Lv80 AOE with DoubleDown + } + if (state.CD(CDGroup.GnashingFang) > state.GCD && state.Unlocked(AID.FatedCircle) && + !state.Unlocked(AID.DoubleDown) && !state.Unlocked(AID.SonicBreak)) + { + return AID.FatedCircle; // Lv80 AOE with Fated Circle and without DoubleDown and SonicBreak + } + if (state.Unlocked(AID.FatedCircle) && !state.Unlocked(AID.DoubleDown) && + !state.Unlocked(AID.SonicBreak) && !state.Unlocked(AID.GnashingFang)) + { + return AID.FatedCircle; // Lv80 AOE with only Fated Circle unlocked + } + } + } } if (state.GunComboStep > 0) @@ -782,9 +879,70 @@ public static AID GetNextBestGCD(State state, Strategy strategy, bool aoe) if (strategy.GaugeStrategy == Strategy.GaugeUse.LightningShotIfNotInMelee && state.RangeToTarget > 3) return AID.LightningShot; - // Lv70 only; can't use Fated Circle (Lv72) sadge - if (aoe && state.Ammo >= 1 && !state.Unlocked(AID.FatedCircle) && !state.Unlocked(AID.DoubleDown) && !state.Unlocked(AID.Bloodfest) && state.Unlocked(AID.BurstStrike) && state.Unlocked(AID.Continuation) && state.CD(CDGroup.GnashingFang) > 24 && state.GunComboStep == 0) - return AID.BurstStrike; + if (!aoe) + { + if (state.Ammo >= 2 && !state.Unlocked(AID.DoubleDown) && + !state.Unlocked(AID.Bloodfest) && !state.Unlocked(AID.Continuation) && !state.Unlocked(AID.GnashingFang) && + !state.Unlocked(AID.SonicBreak)) + { + return AID.BurstStrike; // Use Burst Strike + } + } + // AOE Logic + else if (aoe) + { + if (state.Ammo >= 2) + { + if (state.Ammo >= 2) + { + if (state.Unlocked(AID.GnashingFang) && state.CD(CDGroup.GnashingFang) == 0) + { + return AID.GnashingFang; // Use Gnashing Fang if available and off cooldown + } + if (!state.Unlocked(AID.FatedCircle) && !state.Unlocked(AID.DoubleDown)) + { + return AID.BurstStrike; // Use Burst Strike if Fated Circle and Double Down are not unlocked + } + } + if (state.Ammo >= 2 && !state.Unlocked(AID.DoubleDown) && + !state.Unlocked(AID.Bloodfest) && !state.Unlocked(AID.Continuation) && !state.Unlocked(AID.GnashingFang)) + { + return AID.BurstStrike; // Use Burst Strike for Lv30-53 AOE spender + } + if (state.Ammo >= 2 && state.Unlocked(AID.SonicBreak) && state.Unlocked(AID.GnashingFang) && + !state.Unlocked(AID.FatedCircle) && !state.Unlocked(AID.DoubleDown)) + { + return AID.GnashingFang; // Use Gnashing Fang for Lv60 AOE fix + } + else if (state.Ammo >= 2 && state.Unlocked(AID.SonicBreak) && state.Unlocked(AID.GnashingFang) && (state.CD(CDGroup.GnashingFang) > state.AnimationLock && !state.Unlocked(AID.FatedCircle) && !state.Unlocked(AID.DoubleDown))) + { + return AID.BurstStrike; // Use BurstStrike for Lv60 AOE fix + } + } + if (state.Ammo >= 2 && state.GunComboStep == 0) + { + if (!state.Unlocked(AID.FatedCircle) && !state.Unlocked(AID.DoubleDown) && !state.Unlocked(AID.Bloodfest) && + state.Unlocked(AID.Continuation)) + { + return AID.BurstStrike; // Lv70 AOE combo, no Fated Circle + } + if (state.CD(CDGroup.GnashingFang) > state.GCD && state.CD(CDGroup.DoubleDown) > state.GCD && + state.CD(CDGroup.SonicBreak) > state.GCD && state.Unlocked(AID.DoubleDown)) + { + return AID.FatedCircle; // Lv80 AOE with DoubleDown + } + if (state.CD(CDGroup.GnashingFang) > state.GCD && state.Unlocked(AID.FatedCircle) && + !state.Unlocked(AID.DoubleDown) && !state.Unlocked(AID.SonicBreak)) + { + return AID.FatedCircle; // Lv80 AOE with Fated Circle and without DoubleDown and SonicBreak + } + if (state.Unlocked(AID.FatedCircle) && !state.Unlocked(AID.DoubleDown) && + !state.Unlocked(AID.SonicBreak) && !state.Unlocked(AID.GnashingFang)) + { + return AID.FatedCircle; // Lv80 AOE with only Fated Circle unlocked + } + } + } if (state.ReadyToBlast) return state.BestContinuation; @@ -875,6 +1033,20 @@ public static ActionID GetNextBestOGCD(State state, Strategy strategy, float dea if (state.Unlocked(AID.Bloodfest) && state.CanWeave(CDGroup.Bloodfest, 0.6f, deadline) && ShouldUseFest(state, strategy)) return ActionID.MakeSpell(AID.Bloodfest); + // Lv30-53 NM proc ST + if (state.Unlocked(AID.NoMercy)) + { + if (!state.Unlocked(AID.FatedCircle) && !state.Unlocked(AID.DoubleDown) && !state.Unlocked(AID.Bloodfest) && !state.Unlocked(AID.Continuation) && !state.Unlocked(AID.GnashingFang) && state.Ammo == 2 && state.CanWeave(CDGroup.NoMercy, 0.6f, deadline)) + return ActionID.MakeSpell(AID.NoMercy); + } + + // Lv30-53 NM proc AOE + if (state.Unlocked(AID.NoMercy)) + { + if (aoe && !state.Unlocked(AID.FatedCircle) && !state.Unlocked(AID.DoubleDown) && !state.Unlocked(AID.Bloodfest) && !state.Unlocked(AID.Continuation) && !state.Unlocked(AID.GnashingFang) && state.Ammo == 2) + return ActionID.MakeSpell(AID.NoMercy); + } + if (wantRoughDivide && Service.Config.Get().NoMercyRoughDivide && state.CanWeave(state.CD(CDGroup.RoughDivide) - 28.5f, 0.6f, deadline) && state.NoMercyLeft > state.AnimationLock && state.CD(CDGroup.SonicBreak) > 5.5 && state.Unlocked(AID.BurstStrike)) return ActionID.MakeSpell(AID.RoughDivide); diff --git a/BossMod/Autorotation/MNK/MNKActions.cs b/BossMod/Autorotation/MNK/MNKActions.cs index ed493ec157..df3920e53a 100644 --- a/BossMod/Autorotation/MNK/MNKActions.cs +++ b/BossMod/Autorotation/MNK/MNKActions.cs @@ -1,4 +1,5 @@ -using Dalamud.Game.ClientState.JobGauge.Types; +using System; +using Dalamud.Game.ClientState.JobGauge.Types; namespace BossMod.MNK; @@ -7,6 +8,7 @@ class Actions : CommonActions public const int AutoActionST = AutoActionFirstCustom + 0; public const int AutoActionAOE = AutoActionFirstCustom + 1; public const int AutoActionFiller = AutoActionFirstCustom + 2; + public const int AutoActionSTQOpener = AutoActionFirstCustom + 3; private readonly Rotation.State _state; private readonly Rotation.Strategy _strategy; @@ -54,28 +56,37 @@ protected override void UpdateInternalState(int autoAction) { UpdatePlayerState(); FillCommonStrategy(_strategy, CommonDefinitions.IDPotionStr); + _strategy.NumBlitzTargets = NumTargetsHitByBlitz(); _strategy.ApplyStrategyOverrides(Autorot.Bossmods.ActiveModule?.PlanExecution?.ActiveStrategyOverrides(Autorot.Bossmods.ActiveModule.StateMachine) ?? []); _strategy.NumPointBlankAOETargets = autoAction == AutoActionST ? 0 : NumTargetsHitByPBAOE(); _strategy.NumEnlightenmentTargets = Autorot.PrimaryTarget != null && autoAction != AutoActionST && _state.Unlocked(AID.HowlingFist) ? NumTargetsHitByEnlightenment(Autorot.PrimaryTarget) : 0; + _strategy.UseAOE = _strategy.NumPointBlankAOETargets >= 3; + _strategy.UseSTQOpener = autoAction == AutoActionSTQOpener; + if (autoAction == AutoActionFiller) { _strategy.FireUse = Rotation.Strategy.FireStrategy.Delay; _strategy.WindUse = CommonRotation.Strategy.OffensiveAbilityUse.Delay; _strategy.BrotherhoodUse = CommonRotation.Strategy.OffensiveAbilityUse.Delay; _strategy.PerfectBalanceUse = CommonRotation.Strategy.OffensiveAbilityUse.Delay; + _strategy.TrueNorthUse = CommonRotation.Strategy.OffensiveAbilityUse.Delay; } + FillStrategyPositionals(_strategy, Rotation.GetNextPositional(_state, _strategy), _state.TrueNorthLeft > _state.GCD); } protected override void QueueAIActions() { - if (_state.Unlocked(AID.SteelPeak)) - SimulateManualActionForAI(ActionID.MakeSpell(AID.Meditation), Player, !Player.InCombat && _state.Chakra < 5); if (_state.Unlocked(AID.SecondWind)) SimulateManualActionForAI(ActionID.MakeSpell(AID.SecondWind), Player, Player.InCombat && Player.HPMP.CurHP < Player.HPMP.MaxHP * 0.5f); if (_state.Unlocked(AID.Bloodbath)) SimulateManualActionForAI(ActionID.MakeSpell(AID.Bloodbath), Player, Player.InCombat && Player.HPMP.CurHP < Player.HPMP.MaxHP * 0.8f); + if (_state.Unlocked(AID.Meditation)) + SimulateManualActionForAI(ActionID.MakeSpell(AID.Meditation), Player, !Player.InCombat && _state.Chakra < 5); + // TODO: this ends up being super annoying in some cases, maybe reconsider conditions + // if (_state.Unlocked(AID.FormShift)) + // SimulateManualActionForAI(ActionID.MakeSpell(AID.FormShift), Player, !Player.InCombat && _state.FormShiftLeft == 0 && _state.PerfectBalanceLeft == 0); } protected override NextAction CalculateAutomaticGCD() @@ -88,14 +99,14 @@ protected override NextAction CalculateAutomaticGCD() protected override NextAction CalculateAutomaticOGCD(float deadline) { - if (!Rotation.HaveTarget(_state, _strategy) || AutoAction < AutoActionAIFight) + if (AutoAction < AutoActionAIFight) return new(); ActionID res = new(); if (_state.CanWeave(deadline - _state.OGCDSlotLength)) // first ogcd slot - res = Rotation.GetNextBestOGCD(_state, _strategy, deadline - _state.OGCDSlotLength); + res = Rotation.GetNextBestOGCD(_state, _strategy, deadline - _state.OGCDSlotLength, deadline); if (!res && _state.CanWeave(deadline)) // second/only ogcd slot - res = Rotation.GetNextBestOGCD(_state, _strategy, deadline); + res = Rotation.GetNextBestOGCD(_state, _strategy, deadline, deadline); return MakeResult(res, Autorot.PrimaryTarget); } @@ -107,6 +118,7 @@ private void UpdatePlayerState() _state.Chakra = gauge.Chakra; _state.BeastChakra = gauge.BeastChakra; _state.Nadi = gauge.Nadi; + _state.BlitzLeft = gauge.BlitzTimeRemaining / 1000f; (_state.Form, _state.FormLeft) = DetermineForm(); _state.DisciplinedFistLeft = StatusDetails(Player, SID.DisciplinedFist, Player.InstanceID).Left; @@ -116,6 +128,14 @@ private void UpdatePlayerState() _state.FireLeft = StatusDetails(Player, SID.RiddleOfFire, Player.InstanceID).Left; _state.TrueNorthLeft = StatusDetails(Player, SID.TrueNorth, Player.InstanceID).Left; + // these are functionally the same as far as the rotation is concerned + _state.LostExcellenceLeft = MathF.Max( + StatusDetails(Player, SID.LostExcellence, Player.InstanceID).Left, + StatusDetails(Player, SID.Memorable, Player.InstanceID).Left + ); + _state.FoPLeft = StatusDetails(Player, SID.LostFontofPower, Player.InstanceID).Left; + _state.HsacLeft = StatusDetails(Player, SID.BannerHonoredSacrifice, Player.InstanceID).Left; + _state.TargetDemolishLeft = StatusDetails(Autorot.PrimaryTarget, SID.Demolish, Player.InstanceID).Left; } @@ -139,6 +159,7 @@ private void OnConfigModified(MNKConfig config) SupportedSpell(AID.Bootshine).PlaceholderForAuto = config.FullRotation ? AutoActionST : AutoActionNone; SupportedSpell(AID.ArmOfTheDestroyer).PlaceholderForAuto = SupportedSpell(AID.ShadowOfTheDestroyer).PlaceholderForAuto = config.FullRotation ? AutoActionAOE : AutoActionNone; SupportedSpell(AID.TrueStrike).PlaceholderForAuto = config.FillerRotation ? AutoActionFiller : AutoActionNone; + SupportedSpell(AID.SnapPunch).PlaceholderForAuto = config.FullRotation ? AutoActionSTQOpener : AutoActionNone; // combo replacement SupportedSpell(AID.FourPointFury).TransformAction = config.AOECombos ? () => ActionID.MakeSpell(Rotation.GetNextComboAction(_state, _strategy)) : null; @@ -149,11 +170,16 @@ private void OnConfigModified(MNKConfig config) SupportedSpell(AID.Thunderclap).TransformTarget = config.SmartThunderclap ? (act) => Autorot.SecondaryTarget ?? act : null; - _strategy.PreCombatFormShift = config.AutoFormShift; - // smart targets } + private int NumTargetsHitByBlitz() + { + if (_state.BestBlitz is AID.TornadoKick or AID.PhantomRush) + return Autorot.PrimaryTarget == null ? 0 : Autorot.Hints.NumPriorityTargetsInAOECircle(Autorot.PrimaryTarget.Position, 5); + return Autorot.Hints.NumPriorityTargetsInAOECircle(Player.Position, 5); + } + private int NumTargetsHitByPBAOE() => Autorot.Hints.NumPriorityTargetsInAOECircle(Player.Position, 5); private int NumTargetsHitByEnlightenment(Actor primary) => Autorot.Hints.NumPriorityTargetsInAOERect(Player.Position, (primary.Position - Player.Position).Normalized(), 10, _state.Unlocked(AID.Enlightenment) ? 2 : 1); } diff --git a/BossMod/Autorotation/MNK/MNKConfig.cs b/BossMod/Autorotation/MNK/MNKConfig.cs index 6cf9590fe9..652b1fdef4 100644 --- a/BossMod/Autorotation/MNK/MNKConfig.cs +++ b/BossMod/Autorotation/MNK/MNKConfig.cs @@ -17,7 +17,4 @@ class MNKConfig : ConfigNode [PropertyDisplay("Delay Thunderclap if already in melee range of target")] public bool PreventCloseDash = true; - - [PropertyDisplay("Use Form Shift out of combat")] - public bool AutoFormShift = false; } diff --git a/BossMod/Autorotation/MNK/MNKDefinitions.cs b/BossMod/Autorotation/MNK/MNKDefinitions.cs index 365dc9c049..7802eea656 100644 --- a/BossMod/Autorotation/MNK/MNKDefinitions.cs +++ b/BossMod/Autorotation/MNK/MNKDefinitions.cs @@ -116,6 +116,11 @@ public enum SID : uint Stun = 2, // applied by Leg Sweep to target FormlessFist = 2513, // applied by Form Shift to self SixSidedStar = 2514, // applied by Six-Sided Star to self + + LostFontofPower = 2346, + BannerHonoredSacrifice = 2327, + LostExcellence = 2564, + Memorable = 2565, } public static class Definitions diff --git a/BossMod/Autorotation/MNK/MNKRotation.cs b/BossMod/Autorotation/MNK/MNKRotation.cs index 5da002c0f3..c32a52e219 100644 --- a/BossMod/Autorotation/MNK/MNKRotation.cs +++ b/BossMod/Autorotation/MNK/MNKRotation.cs @@ -7,6 +7,11 @@ public static class Rotation { public enum Form { None, OpoOpo, Raptor, Coeurl } + private const float SSSApplicationDelay = 0.62f; + + // make configurable? idk? only rotation devs would care about this + public static readonly bool Debug; + // full state needed for determining next action public class State(WorldState ws) : CommonRotation.PlayerState(ws) { @@ -14,6 +19,7 @@ public class State(WorldState ws) : CommonRotation.PlayerState(ws) public BeastChakra[] BeastChakra = []; public Nadi Nadi; public Form Form; + public float BlitzLeft; // 20 max public float FormLeft; // 0 if no form, 30 max public float DisciplinedFistLeft; // 15 max public float LeadenFistLeft; // 30 max @@ -22,14 +28,20 @@ public class State(WorldState ws) : CommonRotation.PlayerState(ws) public float FormShiftLeft; // 30 max public float FireLeft; // 20 max public float TrueNorthLeft; // 10 max + public float LostExcellenceLeft; // 60(?) max + public float FoPLeft; // 30 max + public float HsacLeft; // 15 max + + public bool HasLunar => Nadi.HasFlag(Nadi.LUNAR); + public bool HasSolar => Nadi.HasFlag(Nadi.SOLAR); + public bool HasBothNadi => HasLunar && HasSolar; - public bool HaveLunar => Nadi.HasFlag(Nadi.LUNAR); - public bool HaveSolar => Nadi.HasFlag(Nadi.SOLAR); + public bool CanFormShift => Unlocked(AID.FormShift) && PerfectBalanceLeft == 0; public int BeastCount => BeastChakra.Count(x => x != Dalamud.Game.ClientState.JobGauge.Enums.BeastChakra.NONE); - public bool ForcedLunar => BeastChakra[0] == Dalamud.Game.ClientState.JobGauge.Enums.BeastChakra.OPOOPO && BeastChakra[1] == Dalamud.Game.ClientState.JobGauge.Enums.BeastChakra.OPOOPO; - public bool ForcedSolar => BeastChakra.Any(x => x is not Dalamud.Game.ClientState.JobGauge.Enums.BeastChakra.NONE and not Dalamud.Game.ClientState.JobGauge.Enums.BeastChakra.OPOOPO); + public bool ForcedLunar => BeastCount > 1 && BeastChakra[0] == BeastChakra[1] && !HasBothNadi; + public bool ForcedSolar => BeastCount > 1 && BeastChakra[0] != BeastChakra[1] && !HasBothNadi; // upgrade paths public AID BestForbiddenChakra => Unlocked(AID.ForbiddenChakra) ? AID.ForbiddenChakra : AID.SteelPeak; @@ -45,7 +57,7 @@ public AID BestBlitz if (BeastCount != 3) return AID.MasterfulBlitz; - if (HaveLunar && HaveSolar) + if (HasLunar && HasSolar) return BestPhantomRush; var bc = BeastChakra; @@ -63,19 +75,30 @@ public AID BestBlitz public override string ToString() { - return $"RB={RaidBuffsLeft:f1}, Demo={TargetDemolishLeft:f1}, DF={DisciplinedFistLeft:f1}, Form={Form}/{FormLeft:f1}, LFist={LeadenFistLeft:f1}, PotCD={PotionCD:f1}, GCD={GCD:f3}, ALock={AnimationLock:f3}+{AnimationLockDelay:f3}, lvl={Level}/{UnlockProgress}"; + return $"RB={RaidBuffsLeft:f1}, Demo={TargetDemolishLeft:f1}, DF={DisciplinedFistLeft:f1}, Blitz={BlitzLeft:f1}, Form={Form}/{FormLeft:f1}, LFist={LeadenFistLeft:f1}, PotCD={PotionCD:f1}, GCD={GCD:f3}, ALock={AnimationLock:f3}+{AnimationLockDelay:f3}, lvl={Level}/{UnlockProgress}"; } } // strategy configuration public class Strategy : CommonRotation.Strategy { - public int NumPointBlankAOETargets; // range 5 around self - public int NumEnlightenmentTargets; // range 10 width 2/4 rect + public int NumBlitzTargets; // 5y around self + public int NumPointBlankAOETargets; // 5y around self + public int NumEnlightenmentTargets; // 10y/4y rect public bool UseAOE; - public bool PreCombatFormShift; + public bool UseSTQOpener; + + public enum FormShiftStrategy : uint + { + [PropertyDisplay("Use if there are no targets in range")] + Automatic = 0, + [PropertyDisplay("Do not use")] + Delay = 1 + } + + public FormShiftStrategy FormShiftUse; public enum DashStrategy : uint { @@ -94,16 +117,31 @@ public enum DashStrategy : uint public enum NadiChoice : uint { Automatic = 0, // lunar -> solar - [PropertyDisplay("Lunar", 0xFFDB8BCA)] Lunar = 1, - [PropertyDisplay("Solar", 0xFF8EE6FA)] - Solar = 2 + Solar = 2, + [PropertyDisplay("Lunar (downtime)", 0xA0DB8BCA)] + LunarDowntime = 3, + [PropertyDisplay("Solar (downtime)", 0xA08EE6FA)] + SolarDowntime = 4 } public NadiChoice NextNadi; + public enum FormChoice : uint + { + Automatic = 0, + [PropertyDisplay("Opo-Opo", 0xFF3B34DA)] + Opo = 1, + [PropertyDisplay("Raptor", 0xFF38D17B)] + Raptor = 2, + [PropertyDisplay("Coeurl", 0xFFA264D7)] + Coeurl = 3, + } + + public FormChoice FormShiftForm; + public enum FireStrategy : uint { Automatic = 0, // use on cooldown-ish if something is targetable @@ -115,15 +153,55 @@ public enum FireStrategy : uint Force = 2, [PropertyDisplay("Delay until Brotherhood is off cooldown")] - DelayUntilBrotherhood = 3 + DelayUntilBrotherhood = 3, + + [PropertyDisplay("Delay until 1 Beast Chakra is opened")] + DelayBeast1 = 4, + + [PropertyDisplay("Delay until 2 Beast Chakra are opened")] + DelayBeast2 = 5, + + [PropertyDisplay("Delay until 3 Beast Chakra are opened")] + DelayBeast3 = 6 } public FireStrategy FireUse; + + public enum BlitzStrategy : uint + { + // use when available + Automatic = 0, + [PropertyDisplay("Delay")] + Delay = 1, + [PropertyDisplay("Delay until at least two targets are in range")] + DelayUntilMultiTarget = 2, + } + public BlitzStrategy BlitzUse; + + public enum DragonKickStrategy : uint + { + // standard rotation, use in opo-opo form to proc leaden fist + Automatic = 0, + [PropertyDisplay("Replace all GCDs unless Leaden Fist is active or Disciplined Fist will expire")] + Filler = 1, + } + public OffensiveAbilityUse WindUse; public OffensiveAbilityUse BrotherhoodUse; + public OffensiveAbilityUse TFCUse; + public OffensiveAbilityUse MeditationUse; public OffensiveAbilityUse PerfectBalanceUse; + public FormChoice PBForm1; + public FormChoice PBForm2; + public FormChoice PBForm3; public OffensiveAbilityUse SSSUse; public OffensiveAbilityUse TrueNorthUse; + public OffensiveAbilityUse DisciplinedFistUse; + public OffensiveAbilityUse DemolishUse; + public DragonKickStrategy DragonKickUse; + public OffensiveAbilityUse PotionUse; + + public float ActualFightEndIn => FightEndIn == 0 ? 10000f : FightEndIn; public override string ToString() { @@ -132,27 +210,51 @@ public override string ToString() public void ApplyStrategyOverrides(uint[] overrides) { - if (overrides.Length >= 8) + if (overrides.Length >= 20) { DashUse = (DashStrategy)overrides[0]; TrueNorthUse = (OffensiveAbilityUse)overrides[1]; - NextNadi = (NadiChoice)overrides[2]; - FireUse = (FireStrategy)overrides[3]; - WindUse = (OffensiveAbilityUse)overrides[4]; - BrotherhoodUse = (OffensiveAbilityUse)overrides[5]; - PerfectBalanceUse = (OffensiveAbilityUse)overrides[6]; - SSSUse = (OffensiveAbilityUse)overrides[7]; + DisciplinedFistUse = (OffensiveAbilityUse)overrides[2]; + DemolishUse = (OffensiveAbilityUse)overrides[3]; + NextNadi = (NadiChoice)overrides[4]; + FireUse = (FireStrategy)overrides[5]; + WindUse = (OffensiveAbilityUse)overrides[6]; + BrotherhoodUse = (OffensiveAbilityUse)overrides[7]; + TFCUse = (OffensiveAbilityUse)overrides[8]; + MeditationUse = (OffensiveAbilityUse)overrides[9]; + PerfectBalanceUse = (OffensiveAbilityUse)overrides[10]; + PBForm1 = (FormChoice)overrides[11]; + PBForm2 = (FormChoice)overrides[12]; + PBForm3 = (FormChoice)overrides[13]; + FormShiftUse = (FormShiftStrategy)overrides[14]; + FormShiftForm = (FormChoice)overrides[15]; + BlitzUse = (BlitzStrategy)overrides[16]; + DragonKickUse = (DragonKickStrategy)overrides[17]; + SSSUse = (OffensiveAbilityUse)overrides[18]; + PotionUse = (OffensiveAbilityUse)overrides[19]; } else { DashUse = DashStrategy.Automatic; TrueNorthUse = OffensiveAbilityUse.Automatic; + DisciplinedFistUse = OffensiveAbilityUse.Automatic; + DemolishUse = OffensiveAbilityUse.Automatic; NextNadi = NadiChoice.Automatic; FireUse = FireStrategy.Automatic; WindUse = OffensiveAbilityUse.Automatic; BrotherhoodUse = OffensiveAbilityUse.Automatic; + TFCUse = OffensiveAbilityUse.Automatic; + MeditationUse = OffensiveAbilityUse.Automatic; PerfectBalanceUse = OffensiveAbilityUse.Automatic; + PBForm1 = FormChoice.Automatic; + PBForm2 = FormChoice.Automatic; + PBForm3 = FormChoice.Automatic; + FormShiftUse = FormShiftStrategy.Automatic; + FormShiftForm = FormChoice.Automatic; + BlitzUse = BlitzStrategy.Automatic; + DragonKickUse = DragonKickStrategy.Automatic; SSSUse = OffensiveAbilityUse.Automatic; + PotionUse = OffensiveAbilityUse.Automatic; } } } @@ -188,11 +290,20 @@ public static AID GetRaptorFormAction(State state, Strategy strategy) // during fire windows, if next GCD is demo, force refresh to align loop; we can't use a lunar PB unless // DF + demo are close to max duration, since DF only lasts about 7-8 GCDs and a blitz window is 5 - if (rofIsAligned && WillDemolishExpire(state, strategy, 4)) + if (rofIsAligned && NeedDemolishRefresh(state, strategy, 4)) + return AID.TwinSnakes; + + // force refresh if we anticipate another PB use in this buff window + if ( + state.FireLeft >= state.GCD + state.AttackGCDTime * 3 && + state.CanWeave(state.CD(CDGroup.PerfectBalance) - 40, 0.6f, state.GCD + state.AttackGCDTime) && + state.PerfectBalanceLeft == 0 && + state.HasSolar + ) return AID.TwinSnakes; // normal refresh - if (WillDFExpire(state, 3)) + if (NeedDFRefresh(state, strategy, 3)) return AID.TwinSnakes; return AID.TrueStrike; @@ -207,7 +318,7 @@ public static AID GetCoeurlFormAction(State state, Strategy strategy) return AID.Rockbreaker; // normal refresh - if (!strategy.ForbidDOTs && state.Unlocked(AID.Demolish) && WillDemolishExpire(state, strategy, 3)) + if (!strategy.ForbidDOTs && state.Unlocked(AID.Demolish) && NeedDemolishRefresh(state, strategy, 3)) return AID.Demolish; return AID.SnapPunch; @@ -227,44 +338,84 @@ public static AID GetNextComboAction(State state, Strategy strategy) public static AID GetNextBestGCD(State state, Strategy strategy) { - if (strategy.CombatTimer < 0) + // tradeoff here between always using meditation + form shift when not in combat ("optimal") versus only using + // them during countdowns (mostly optimal). + // the tradeoff is that "not in combat" includes the scenario of manually targeting an enemy you want to attack, + // even if they're already in melee range, which incurs an annoying 3s delay + // maybe AI mode should separately handle the out of combat form shift + meditate usage? + if (strategy.CombatTimer is < 0 and > -100) { if (state.Chakra < 5 && state.Unlocked(AID.Meditation)) return AID.Meditation; - if (strategy.CombatTimer > -20 && state.FormShiftLeft < 5 && state.Unlocked(AID.FormShift)) + if ( + strategy.FormShiftUse == Strategy.FormShiftStrategy.Automatic + && state.FormShiftLeft < 3 + && state.CanFormShift + ) return AID.FormShift; - if (strategy.PreCombatFormShift && state.FormShiftLeft < 2 && state.Unlocked(AID.FormShift)) - return AID.FormShift; + if (strategy.CombatTimer > -10) + { + // form shift on countdown. TODO: ignore Never here? don't think there's ever any reason not to use it on countdown + if ( + strategy.FormShiftUse == Strategy.FormShiftStrategy.Automatic + && strategy.CombatTimer < -9 + && state.FormShiftLeft < 15 + && state.Unlocked(AID.FormShift) + ) + return AID.FormShift; - if (strategy.CombatTimer > -100) return AID.None; + } } if (!HaveTarget(state, strategy)) { - if (state.Chakra < 5 && state.Unlocked(AID.Meditation)) + if (state.Chakra < 5 && state.Unlocked(AID.Meditation) && strategy.MeditationUse != CommonRotation.Strategy.OffensiveAbilityUse.Delay) return AID.Meditation; + if (strategy.FormShiftUse == Strategy.FormShiftStrategy.Automatic && state.CanFormShift && state.FormShiftLeft < 3) + return AID.FormShift; + + if (strategy.NextNadi == Strategy.NadiChoice.LunarDowntime && state.BeastCount < 3 && state.PerfectBalanceLeft > 0) + return AID.ShadowOfTheDestroyer; + + if (strategy.NextNadi == Strategy.NadiChoice.SolarDowntime && state.PerfectBalanceLeft > 0) + return state.BeastCount switch + { + 0 => AID.ShadowOfTheDestroyer, + 1 => AID.FourPointFury, + 2 => AID.Rockbreaker, + _ => AID.None + }; + return AID.None; } - if (state.Unlocked(AID.SixSidedStar) && strategy.SSSUse == Strategy.OffensiveAbilityUse.Force) + if (state.RangeToTarget > 3 && strategy.DashUse == Strategy.DashStrategy.GapClose && state.CD(CDGroup.Thunderclap) <= 60 && state.Unlocked(AID.Thunderclap)) + return AID.Thunderclap; + + if (state.Unlocked(AID.SixSidedStar) && strategy.SSSUse == CommonRotation.Strategy.OffensiveAbilityUse.Force) return AID.SixSidedStar; - if (state.BestBlitz != AID.MasterfulBlitz) + if (strategy.UseSTQOpener && state.LostExcellenceLeft > 0 && state.FoPLeft == 0) + return AID.SixSidedStar; + + if (state.BestBlitz != AID.MasterfulBlitz && strategy.NumBlitzTargets > 0 && ShouldBlitz(state, strategy)) return state.BestBlitz; // TODO: calculate optimal DK spam before SSS if ( - strategy.SSSUse == Strategy.OffensiveAbilityUse.Automatic - && strategy.FightEndIn > state.GCD - && strategy.FightEndIn < state.GCD + state.AttackGCDTime + strategy.SSSUse == CommonRotation.Strategy.OffensiveAbilityUse.Automatic + && strategy.ActualFightEndIn < state.GCD + state.AttackGCDTime + SSSApplicationDelay && state.Unlocked(AID.SixSidedStar) ) return AID.SixSidedStar; + if (state.Unlocked(AID.DragonKick) && ShouldDKSpam(state, strategy)) + return AID.DragonKick; + return GetNextComboAction(state, strategy); } @@ -292,12 +443,12 @@ public static (Positional, bool) GetNextPositional(State state, Strategy strateg if (isCastingGcd && !formIsPending && state.PerfectBalanceLeft == 0) gcdsUntilCoeurl -= 1; - var willDemolish = state.Unlocked(AID.Demolish) && WillDemolishExpire(state, strategy, gcdsUntilCoeurl); + var willDemolish = state.Unlocked(AID.Demolish) && NeedDemolishRefresh(state, strategy, gcdsUntilCoeurl); return (willDemolish ? Positional.Rear : Positional.Flank, curForm == Form.Coeurl); } - public static ActionID GetNextBestOGCD(State state, Strategy strategy, float deadline) + public static ActionID GetNextBestOGCD(State state, Strategy strategy, float deadline, float finalOGCDDeadline) { // TODO: potion @@ -311,29 +462,73 @@ public static ActionID GetNextBestOGCD(State state, Strategy strategy, float dea ) return ActionID.MakeSpell(AID.Thunderclap); + if (strategy.PotionUse == CommonRotation.Strategy.OffensiveAbilityUse.Force && state.CanWeave(state.PotionCD, 1.1f, deadline)) + return CommonDefinitions.IDPotionStr; + return new(); } + if (strategy.UseSTQOpener && HaveTarget(state, strategy)) + { + var hsac = BozjaActionID.GetNormal(BozjaHolsterID.BannerHonoredSacrifice); + var fop = BozjaActionID.GetNormal(BozjaHolsterID.LostFontOfPower); + var ex = BozjaActionID.GetNormal(BozjaHolsterID.LostExcellence); + + var hsacInBag = state.BozjaHolster[(int)BozjaHolsterID.BannerHonoredSacrifice] > 0; + var hsacSlot = state.FindDutyActionSlot(hsac, fop); + var exSlot = state.FindDutyActionSlot(ex, fop); + + if (state.LostExcellenceLeft > 0) + { + if (state.HsacLeft > 0) + { + if (state.FoPLeft > 0) + { + if (state.CanWeave(state.PotionCD, 0.6f, deadline)) + return CommonDefinitions.IDPotionStr; + } + + if (state.CanWeave(state.DutyActionCD(fop), 0.6f, deadline)) + return fop; + } + + if (state.CanWeave(state.DutyActionCD(hsac), 0.6f, deadline)) + return hsac; + + if (hsacSlot < 0) + return ActionID.MakeBozjaHolster(BozjaHolsterID.BannerHonoredSacrifice, exSlot); + } + + if (state.Form == Form.Raptor && hsacInBag && exSlot >= 0 && state.CanWeave(state.DutyActionCD(ex), 0.6f, deadline)) + return ex; + } + if (state.GCD <= 0.800f && ShouldUseRoF(state, strategy, deadline)) - return ActionID.MakeSpell(AID.RiddleOfFire); + { + // this is checked separately here because other functions (notably ShouldUsePB) make decisions + // based on whether RoF is expected to be off cooldown by a given time + var shouldRoFDelayed = strategy.FireUse switch + { + Strategy.FireStrategy.DelayBeast1 => state.BeastCount >= 1, + Strategy.FireStrategy.DelayBeast2 => state.BeastCount >= 2, + Strategy.FireStrategy.DelayBeast3 => state.BeastCount == 3, + _ => true + }; + if (shouldRoFDelayed) + return ActionID.MakeSpell(AID.RiddleOfFire); + } - if (ShouldUsePB(state, strategy, deadline)) - return ActionID.MakeSpell(AID.PerfectBalance); + if (strategy.PotionUse == CommonRotation.Strategy.OffensiveAbilityUse.Force && state.CanWeave(state.PotionCD, 1.1f, deadline)) + return CommonDefinitions.IDPotionStr; if (ShouldUseBrotherhood(state, strategy, deadline)) return ActionID.MakeSpell(AID.Brotherhood); + if (ShouldUsePB(state, strategy, deadline)) + return ActionID.MakeSpell(AID.PerfectBalance); + // 2. steel peek, if have chakra - if ( - state.Unlocked(AID.SteelPeak) - && state.Chakra == 5 - && state.CanWeave(CDGroup.SteelPeak, 0.6f, deadline) - && (state.CD(CDGroup.RiddleOfFire) > 0 // prevent early use in opener - || strategy.FireUse == Strategy.FireStrategy.Delay - || strategy.FireUse == Strategy.FireStrategy.DelayUntilBrotherhood - || !state.Unlocked(AID.RiddleOfFire) - ) - ) + if (ShouldUseTFC(state, strategy, deadline)) { // L15 Steel Peak is 180p // L40 Howling Fist is 100p/target => HF at 2+ targets @@ -352,10 +547,10 @@ public static ActionID GetNextBestOGCD(State state, Strategy strategy, float dea if (ShouldUseRoW(state, strategy, deadline)) return ActionID.MakeSpell(AID.RiddleOfWind); - if (ShouldUseTrueNorth(state, strategy) && state.CanWeave(state.CD(CDGroup.TrueNorth) - 45, 0.6f, deadline)) + if (ShouldUseTrueNorth(state, strategy, finalOGCDDeadline) && state.CanWeave(state.CD(CDGroup.TrueNorth) - 45, 0.6f, deadline)) return ActionID.MakeSpell(AID.TrueNorth); - if (ShouldDash(state, strategy, deadline)) + if (ShouldDash(state, strategy)) return ActionID.MakeSpell(AID.Thunderclap); // no suitable oGCDs... @@ -366,20 +561,37 @@ private static Form GetEffectiveForm(State state, Strategy strategy) { if (state.PerfectBalanceLeft > state.GCD) { + Strategy.FormChoice[] formOverrides = [strategy.PBForm1, strategy.PBForm2, strategy.PBForm3]; + switch (formOverrides[state.BeastCount]) + { + case Strategy.FormChoice.Opo: + return Form.OpoOpo; + case Strategy.FormChoice.Coeurl: + return Form.Coeurl; + case Strategy.FormChoice.Raptor: + return Form.Raptor; + default: + break; + } + + bool canCoeurl, canRaptor, canOpo; + var nextNadi = strategy.NextNadi; // if a blitz is already in progress, finish it even if buffs would fall off in the process, since celestial revolution is always a mistake var forcedLunar = nextNadi == Strategy.NadiChoice.Lunar || state.ForcedLunar; var forcedSolar = nextNadi == Strategy.NadiChoice.Solar || state.ForcedSolar; - var canCoeurl = !forcedLunar; - var canRaptor = !forcedLunar; - // slightly annoying conditional because this is always true in lunar, but only true in solar if we haven't used it yet, just like the others - var canOpo = !state.ForcedSolar || state.BeastChakra.All(b => b != BeastChakra.OPOOPO); - - foreach (var chak in state.BeastChakra) - { - canCoeurl &= chak != BeastChakra.COEURL; - canRaptor &= chak != BeastChakra.RAPTOR; - } + canCoeurl = !forcedLunar; + canRaptor = !forcedLunar; + canOpo = true; + + if (!state.HasBothNadi) + foreach (var chak in state.BeastChakra) + { + canCoeurl &= chak != BeastChakra.COEURL; + canRaptor &= chak != BeastChakra.RAPTOR; + if (forcedSolar) + canOpo &= chak != BeastChakra.OPOOPO; + } // big pile of conditionals to check whether this is a forced solar (buffs are running out). // odd windows are planned out such that buffed demo was used right before perfect balance, so this @@ -387,23 +599,25 @@ private static Form GetEffectiveForm(State state, Strategy strategy) // see ShouldUsePB for more context if (canCoeurl && canRaptor) { - if (WillDemolishExpire(state, strategy, 2)) + if (state.DisciplinedFistLeft == 0) + return Form.Raptor; + if (NeedDemolishRefresh(state, strategy, 2)) return Form.Coeurl; - if (WillDFExpire(state, 2)) + if (NeedDFRefresh(state, strategy, 2)) return Form.Raptor; } else if (canCoeurl) { - if (state.BeastCount == 1 && WillDemolishExpire(state, strategy, 1)) + if (state.BeastCount == 1 && NeedDemolishRefresh(state, strategy, 1)) return Form.Coeurl; - else if (state.BeastCount == 2 && WillDemolishExpire(state, strategy, 5)) + else if (state.BeastCount == 2 && NeedDemolishRefresh(state, strategy, 5)) return Form.Coeurl; } else if (canRaptor) { - if (state.BeastCount == 1 && WillDFExpire(state, 1)) + if (state.BeastCount == 1 && NeedDFRefresh(state, strategy, 1)) return Form.Raptor; - else if (state.BeastCount == 2 && WillDFExpire(state, 4)) + else if (state.BeastCount == 2 && NeedDFRefresh(state, strategy, 4)) return Form.Raptor; } @@ -416,7 +630,9 @@ private static Form GetEffectiveForm(State state, Strategy strategy) // if we try to delay both lunar/solar until RoF is up, like the standard opener (which is just BH3), // pre-PB demolish will fall off for multiple GCDs; // so early non-demo solar is the only way to prevent clipping - var isBH2 = state.FireLeft == 0 && (forcedSolar || !state.HaveSolar); + + // TODO: full demo is more potency than any single gcd, so we should use opo before demo if a refresh is imminent + var isBH2 = state.FireLeft == 0 && (forcedSolar || !state.HasSolar) && state.Unlocked(AID.RiddleOfFire); if (isBH2) return canRaptor ? Form.Raptor : canCoeurl ? Form.Coeurl : Form.OpoOpo; @@ -424,33 +640,56 @@ private static Form GetEffectiveForm(State state, Strategy strategy) } if (state.FormShiftLeft > state.GCD) + { + switch (strategy.FormShiftForm) + { + case Strategy.FormChoice.Automatic: + break; + case Strategy.FormChoice.Coeurl: + return Form.Coeurl; + case Strategy.FormChoice.Raptor: + return Form.Raptor; + default: + return Form.OpoOpo; + } + + if (NeedDemolishRefresh(state, strategy, 2) && state.DisciplinedFistLeft > state.GCD) + return Form.Coeurl; + return Form.OpoOpo; + } return state.Form; } - private static bool ShouldDash(State state, Strategy strategy, float deadline) - { - if ( - state.RangeToTarget <= 3 - || !state.Unlocked(AID.Thunderclap) - || !state.CanWeave(state.CD(CDGroup.Thunderclap) - 60, 0.6f, deadline) - || strategy.DashUse == Strategy.DashStrategy.Forbid - ) - return false; + private static bool ShouldBlitz(State state, Strategy strategy) + => state.DisciplinedFistLeft > state.GCD && + strategy.BlitzUse switch + { + Strategy.BlitzStrategy.Delay => false, + Strategy.BlitzStrategy.DelayUntilMultiTarget => strategy.NumBlitzTargets > 1 || state.BlitzLeft < state.AttackGCDTime, + _ => true, + }; - if (strategy.DashUse == Strategy.DashStrategy.GapClose) - return true; + private static bool ShouldDKSpam(State state, Strategy strategy) + => strategy.DragonKickUse switch + { + Strategy.DragonKickStrategy.Filler => state.LeadenFistLeft == 0 && state.DisciplinedFistLeft > state.GCD, + _ => false, + }; - // someone early pulled - if ( - strategy.DashUse == Strategy.DashStrategy.Automatic - && strategy.CombatTimer > 0 - && strategy.CombatTimer < 3 - ) - return true; + private static bool ShouldDash(State state, Strategy strategy) + { + if (!state.Unlocked(AID.Thunderclap) || state.CD(CDGroup.Thunderclap) > 60) + return false; - return false; + return strategy.DashUse switch + { + Strategy.DashStrategy.Automatic => strategy.CombatTimer > 0 && strategy.CombatTimer < 1 && state.RangeToTarget > 3, + Strategy.DashStrategy.Forbid => false, + Strategy.DashStrategy.GapClose => state.RangeToTarget > 3, + _ => false, + }; } private static bool ShouldUseRoF(State state, Strategy strategy, float deadline) @@ -465,6 +704,9 @@ private static bool ShouldUseRoF(State state, Strategy strategy, float deadline) if (strategy.FireUse == Strategy.FireStrategy.Force) return true; + if (!HaveTarget(state, strategy) || strategy.ActualFightEndIn < 20) + return false; + // prevent early use in standard opener return state.DisciplinedFistLeft > state.GCD; } @@ -473,14 +715,17 @@ private static bool ShouldUseRoW(State state, Strategy strategy, float deadline) { if ( !state.Unlocked(AID.RiddleOfWind) - || strategy.WindUse == Strategy.OffensiveAbilityUse.Delay + || strategy.WindUse == CommonRotation.Strategy.OffensiveAbilityUse.Delay || !state.CanWeave(CDGroup.RiddleOfWind, 0.6f, deadline) ) return false; - if (strategy.WindUse == Strategy.OffensiveAbilityUse.Force) + if (strategy.WindUse == CommonRotation.Strategy.OffensiveAbilityUse.Force) return true; + if (!HaveTarget(state, strategy) || strategy.ActualFightEndIn < 15) + return false; + // thebalance recommends using RoW like an oGCD dot, so we use on cooldown as long as buffs have been used first return state.CD(CDGroup.RiddleOfFire) > 0 && state.CD(CDGroup.Brotherhood) > 0; } @@ -489,14 +734,17 @@ private static bool ShouldUseBrotherhood(State state, Strategy strategy, float d { if ( !state.Unlocked(AID.Brotherhood) - || strategy.BrotherhoodUse == Strategy.OffensiveAbilityUse.Delay + || strategy.BrotherhoodUse == CommonRotation.Strategy.OffensiveAbilityUse.Delay || !state.CanWeave(CDGroup.Brotherhood, 0.6f, deadline) ) return false; - if (strategy.BrotherhoodUse == Strategy.OffensiveAbilityUse.Force) + if (strategy.BrotherhoodUse == CommonRotation.Strategy.OffensiveAbilityUse.Force) return true; + if (!HaveTarget(state, strategy) || strategy.ActualFightEndIn < 15) + return false; + // opener timing mostly important as long as rof is used first, we just want to align with party buffs - // the default opener is bhood after first bootshine // later uses can be asap @@ -509,78 +757,170 @@ private static bool ShouldUsePB(State state, Strategy strategy, float deadline) state.PerfectBalanceLeft > 0 || !state.Unlocked(AID.PerfectBalance) || !state.CanWeave(state.CD(CDGroup.PerfectBalance) - 40, 0.6f, deadline) - || strategy.PerfectBalanceUse == Strategy.OffensiveAbilityUse.Delay + || strategy.PerfectBalanceUse == CommonRotation.Strategy.OffensiveAbilityUse.Delay ) - return false; + return LogWhy(false, "PB", $"PBLeft = {state.PerfectBalanceLeft}, cd = {state.CD(CDGroup.PerfectBalance)}"); - if (strategy.PerfectBalanceUse == Strategy.OffensiveAbilityUse.Force) - return true; + if (strategy.PerfectBalanceUse == CommonRotation.Strategy.OffensiveAbilityUse.Force) + return LogWhy(true, "PB", "forced"); + + if (!HaveTarget(state, strategy) || strategy.ActualFightEndIn < state.GCD + state.AttackGCDTime * 3) + return LogWhy(false, "PB", $"target={HaveTarget(state, strategy)}, fight end={strategy.ActualFightEndIn}"); // with enough haste/low enough GCD (< 1.6, currently exclusive to bozja), double lunar is possible without dropping buffs // via lunar -> opo -> snakes -> pb -> lunar // this is the only time PB use is not directly after an opo GCD if (state.Form == Form.Coeurl && state.FireLeft > deadline + state.AttackGCDTime * 3) - return !WillDFExpire(state, 5) && !WillDemolishExpire(state, strategy, 3); + return LogWhy( + !NeedDFRefresh(state, strategy, 5) && !NeedDemolishRefresh(state, strategy, 3), + "PB", + $"nonstandard (coeurl) lunar, DF={state.DisciplinedFistLeft}, Demo={state.TargetDemolishLeft}" + ); if (state.Form != Form.Raptor) - return false; + return LogWhy(false, "PB", "not in raptor"); // bh1 and bh3 even windows where RoF is used no earlier than 2 GCDs before this; also odd windows where // natural demolish happens during RoF - // before level 68/RoF unlock, we have nothing to plan our blitzes around, so just use PB whenever it's off cooldown + // before level 68 (RoF unlock) we have nothing to plan our blitzes around, so just use PB whenever it's off cooldown // as long as buffs won't fall off + // TODO: before level 60 (blitz unlock) PB is just a free opo GCD generator so use it right after DF + demo if (ShouldUseRoF(state, strategy, deadline) || state.FireLeft > deadline + state.AttackGCDTime * 3 || !state.Unlocked(AID.RiddleOfFire)) { if (!CanSolar(state, strategy)) - return !WillDFExpire(state, 5) && !WillDemolishExpire(state, strategy, 6); + { + return LogWhy( + !NeedDFRefresh(state, strategy, 5) && !NeedDemolishRefresh(state, strategy, 6), + "PB", + $"BH1 (RoF active or imminent), solar unavailable, DF={state.DisciplinedFistLeft}, Demo={state.TargetDemolishLeft}" + ); + } // see haste note above; delay standard even window PB2 in favor of double lunar - if (WillDFExpire(state, 3) && !WillDemolishExpire(state, strategy, 5)) - return false; + if (NeedDFRefresh(state, strategy, 3) && !NeedDemolishRefresh(state, strategy, 4)) + return LogWhy(false, "PB", $"BH1 (RoF active or imminent), DF expiring = {state.DisciplinedFistLeft}"); - return true; + return LogWhy(true, "PB", "BH1 (RoF active or imminent)"); } // odd windows where natural demolish happens before RoF, at most 3 GCDs prior - raptor GCD is forced to // be twin snakes if this is the case, so we don't need to check DF timer if (!CanSolar(state, strategy) && ShouldUseRoF(state, strategy, state.GCD + state.AttackGCDTime)) - return !WillDemolishExpire(state, strategy, 7); - - // bhood 2 window: natural demolish happens in the middle of RoF. i don't remember exactly why this is the rule - // but the first blitz has to be RP + return LogWhy( + !NeedDemolishRefresh(state, strategy, 7), + "PB", + $"odd window, solar unavailable, RoF imminent, demo = {state.TargetDemolishLeft}" + ); + + // bhood 2 window: natural demolish happens in the middle of RoF. it's possible that only the blitz itself + // gets the RoF buff, so BH2 consists of + // 1. PB -> "weak" non-OPO gcds until RoF is active + // 2. RoF -> RP + // 3. opo, DF, demolish + // 4. PB -> lunar if ( CanSolar(state, strategy) && !ShouldUseRoF(state, strategy, deadline) && ShouldUseRoF(state, strategy, deadline + state.AttackGCDTime * 3) ) - return !WillDemolishExpire(state, strategy, 7); + return LogWhy(!NeedDemolishRefresh(state, strategy, 7), "PB", $"BH2 (early unbuffed solar), demo = {state.TargetDemolishLeft}"); - return false; + // forced solar (cdplan or because we would otherwise overcap lunar) + // (we are guaranteed to be in raptor form due to conditional above) + if ((strategy.NextNadi == Strategy.NadiChoice.Solar || state.HasLunar && !state.HasSolar) && state.CD(CDGroup.RiddleOfFire) == 0) + return LogWhy(true, "PB", "Solar forced"); + + return LogWhy(false, "PB", "fallback"); } - private static bool ShouldUseTrueNorth(State state, Strategy strategy) + private static bool ShouldUseTrueNorth(State state, Strategy strategy, float lastOgcdDeadline) { if ( - strategy.TrueNorthUse == Strategy.OffensiveAbilityUse.Delay + strategy.TrueNorthUse == CommonRotation.Strategy.OffensiveAbilityUse.Delay || state.TrueNorthLeft > state.AnimationLock ) return false; - if (strategy.TrueNorthUse == Strategy.OffensiveAbilityUse.Force) + if (strategy.TrueNorthUse == CommonRotation.Strategy.OffensiveAbilityUse.Force) + return true; + if (!HaveTarget(state, strategy)) + return false; + + var positionalIsWrong = strategy.NextPositionalImminent && !strategy.NextPositionalCorrect; + + // always late weave true north if possible (it's annoying for it to be used immediately) + // but prioritize Riddle of Fire over it + if (ShouldUseRoF(state, strategy, lastOgcdDeadline)) + return positionalIsWrong; + else + return positionalIsWrong && state.GCD <= 0.800; + } + + private static bool ShouldUseTFC(State state, Strategy strategy, float deadline) + { + if ( + !state.Unlocked(AID.SteelPeak) + || state.Chakra < 5 + || strategy.TFCUse == CommonRotation.Strategy.OffensiveAbilityUse.Delay + || !state.CanWeave(CDGroup.SteelPeak, 0.6f, deadline) + ) + return false; + + if (strategy.TFCUse == CommonRotation.Strategy.OffensiveAbilityUse.Force) return true; - return strategy.NextPositionalImminent && !strategy.NextPositionalCorrect; + // prevent early use in opener + return state.CD(CDGroup.RiddleOfFire) > 0 || !state.Unlocked(AID.RiddleOfFire); } + // UseAOE is only true if enemies are in range public static bool HaveTarget(State state, Strategy strategy) => state.TargetingEnemy || strategy.UseAOE; - private static bool WillDemolishExpire(State state, Strategy strategy, int gcds) => !strategy.UseAOE && WillStatusExpire(state, gcds, state.TargetDemolishLeft); - private static bool WillDFExpire(State state, int gcds) => WillStatusExpire(state, gcds, state.DisciplinedFistLeft); - private static bool WillStatusExpire(State state, int gcds, float statusDuration) => statusDuration < state.GCD + (state.AttackGCDTime * gcds); + private static bool NeedDemolishRefresh(State state, Strategy strategy, int gcds) + { + // don't care + if (strategy.UseAOE) + return false; + + if (strategy.DemolishUse == CommonRotation.Strategy.OffensiveAbilityUse.Force) + return true; + + if (strategy.DemolishUse == CommonRotation.Strategy.OffensiveAbilityUse.Delay) + return false; + + if (WillStatusExpire(state, gcds, state.TargetDemolishLeft)) + // snap is 280 (if flank) potency + // demo is 310 (if rear) potency after 3 ticks: 100 + 70 * 3 + // TODO: this should actually be calculating from the time when we expect to refresh demolish, rather than naively adding duration to the current one, but it probably works for most purposes? + return true; // strategy.ActualFightEndIn > state.TargetDemolishLeft + 9; + + return false; + } + + private static bool NeedDFRefresh(State state, Strategy strategy, int gcds) + { + if (strategy.DisciplinedFistUse == CommonRotation.Strategy.OffensiveAbilityUse.Force) + return true; + + if (strategy.DisciplinedFistUse == CommonRotation.Strategy.OffensiveAbilityUse.Delay) + return false; + + return WillStatusExpire(state, gcds, state.DisciplinedFistLeft); + } + + private static bool WillStatusExpire(State state, int gcds, float statusDuration) + => statusDuration < state.GCD + state.AttackGCDTime * gcds; private static bool CanSolar(State state, Strategy strategy) => strategy.NextNadi switch { Strategy.NadiChoice.Solar => true, Strategy.NadiChoice.Lunar => false, - _ => !state.HaveSolar + _ => !state.HasSolar }; + + private static T LogWhy(T value, string tag, string message) + { + if (Debug) + Service.Log($"[{tag}] {value}: {message}"); + return value; + } } diff --git a/BossMod/CooldownPlanner/PlanDefinitions.cs b/BossMod/CooldownPlanner/PlanDefinitions.cs index 276c2948fc..c35cc71330 100644 --- a/BossMod/CooldownPlanner/PlanDefinitions.cs +++ b/BossMod/CooldownPlanner/PlanDefinitions.cs @@ -1,3 +1,6 @@ +using System; +using System.Collections.Generic; + namespace BossMod; public static class PlanDefinitions @@ -110,14 +113,24 @@ private static ClassData DefineMNK() c.CooldownTracks.Add(new("Mantra", ActionID.MakeSpell(MNK.AID.Mantra), 42)); c.StrategyTracks.Add(new("Dash", typeof(MNK.Rotation.Strategy.DashStrategy))); c.StrategyTracks.Add(new("TrueN", typeof(CommonRotation.Strategy.OffensiveAbilityUse))); + c.StrategyTracks.Add(new("DF", typeof(CommonRotation.Strategy.OffensiveAbilityUse))); + c.StrategyTracks.Add(new("Demo", typeof(CommonRotation.Strategy.OffensiveAbilityUse))); c.StrategyTracks.Add(new("Nadi", typeof(MNK.Rotation.Strategy.NadiChoice))); c.StrategyTracks.Add(new("RoF", typeof(MNK.Rotation.Strategy.FireStrategy))); c.StrategyTracks.Add(new("RoW", typeof(CommonRotation.Strategy.OffensiveAbilityUse))); - c.StrategyTracks.Add(new("BHood", typeof(CommonRotation.Strategy.OffensiveAbilityUse))); - c.StrategyTracks.Add( - new("PerfBal", typeof(CommonRotation.Strategy.OffensiveAbilityUse)) - ); + c.StrategyTracks.Add(new("BH", typeof(CommonRotation.Strategy.OffensiveAbilityUse))); + c.StrategyTracks.Add(new("TFC", typeof(CommonRotation.Strategy.OffensiveAbilityUse))); + c.StrategyTracks.Add(new("Meditate", typeof(CommonRotation.Strategy.OffensiveAbilityUse))); + c.StrategyTracks.Add(new("PB", typeof(CommonRotation.Strategy.OffensiveAbilityUse))); + c.StrategyTracks.Add(new("PB1", typeof(MNK.Rotation.Strategy.FormChoice))); + c.StrategyTracks.Add(new("PB2", typeof(MNK.Rotation.Strategy.FormChoice))); + c.StrategyTracks.Add(new("PB3", typeof(MNK.Rotation.Strategy.FormChoice))); + c.StrategyTracks.Add(new("FS", typeof(MNK.Rotation.Strategy.FormShiftStrategy))); + c.StrategyTracks.Add(new("FSForm", typeof(MNK.Rotation.Strategy.FormChoice))); + c.StrategyTracks.Add(new("Blitz", typeof(MNK.Rotation.Strategy.BlitzStrategy))); + c.StrategyTracks.Add(new("DK", typeof(MNK.Rotation.Strategy.DragonKickStrategy))); c.StrategyTracks.Add(new("SSS", typeof(CommonRotation.Strategy.OffensiveAbilityUse))); + c.StrategyTracks.Add(new("Potion", typeof(CommonRotation.Strategy.OffensiveAbilityUse))); return c; } diff --git a/BossMod/Data/NetworkState.cs b/BossMod/Data/NetworkState.cs index 277b492069..6dc9eb4244 100644 --- a/BossMod/Data/NetworkState.cs +++ b/BossMod/Data/NetworkState.cs @@ -2,7 +2,10 @@ public sealed class NetworkState { - public readonly record struct ServerIPC(Network.ServerIPC.PacketID ID, ushort Opcode, uint Epoch, uint SourceServerActor, DateTime SendTimestamp, IReadOnlyList Payload); + public readonly record struct ServerIPC(Network.ServerIPC.PacketID ID, ushort Opcode, uint Epoch, uint SourceServerActor, DateTime SendTimestamp, byte[] Payload) + { + public readonly byte[] Payload = Payload; + }; public uint IDScramble; diff --git a/BossMod/Framework/ActionManagerEx.cs b/BossMod/Framework/ActionManagerEx.cs index 550299756b..f02db3160c 100644 --- a/BossMod/Framework/ActionManagerEx.cs +++ b/BossMod/Framework/ActionManagerEx.cs @@ -13,24 +13,12 @@ namespace BossMod; // 1. automatic action execution (provided by autorotation or ai modules, if enabled); does nothing if no automatic actions are provided // 2. effective animation lock reduction (a-la xivalex) // 3. framerate-dependent cooldown reduction -// imagine game is running at exactly 100fps (10ms frame time), and action is queued when remaining cooldown is 5ms -// on next frame (+10ms), cooldown will be reduced and clamped to 0, action will be executed and it's cooldown set to X ms - so next time it can be pressed at X+10 ms -// if we were running with infinite fps, cooldown would be reduced to 0 and action would be executed slightly (5ms) earlier -// we can't fix that easily, but at least we can fix the cooldown after action execution - so that next time it can be pressed at X+5ms -// we do that by reducing actual cooldown by difference between previously-remaining cooldown and frame delta, if action is executed at first opportunity // 4. slidecast assistant aka movement block // cast is interrupted if player moves when remaining cast time is greater than ~0.5s (moving during that window without interrupting is known as slidecasting) // this feature blocks WSAD input to prevent movement while this would interrupt a cast, allowing slidecasting efficiently while just holding movement button // other ways of moving (eg LMB+RMB, jumping etc) are not blocked, allowing for emergency movement even while the feature is active // movement is blocked a bit before cast start and unblocked as soon as action effect packet is received // 5. preserving character facing direction -// when any action is executed, character is automatically rotated to face the target (this can be disabled in-game, but it would simply block an action if not facing target instead) -// this makes maintaining uptime during gaze mechanics unnecessarily complicated (requiring either moving or rotating mouse back-and-forth in non-legacy camera mode) -// this feature remembers original rotation before executing an action and then attempts to restore it -// just like any 'manual' way, it is not 100% reliable: -// * client rate-limits rotation updates, so even for instant casts there is a short window of time (~0.1s) following action execution when character faces a target on server -// * for movement-affecting abilities (jumps, charges, etc) rotation can't be restored until animation ends -// * for casted abilities, rotation isn't restored until slidecast window starts, as otherwise cast is interrupted // 6. ground-targeted action queueing // ground-targeted actions can't be queued, making using them efficiently tricky // this feature allows queueing them, plus provides options to execute them automatically either at target's position or at cursor's position @@ -46,21 +34,19 @@ unsafe sealed class ActionManagerEx : IDisposable public ActionID QueuedAction => new((ActionType)_inst->QueuedActionType, _inst->QueuedActionId); public float EffectiveAnimationLock => _inst->AnimationLock + CastTimeRemaining; // animation lock starts ticking down only when cast ends, so this is the minimal time until next action can be requested + public float AnimationLockDelayEstimate => _animLockTweak.DelayEstimate; public Event ActionRequested = new(); public Event ActionEffectReceived = new(); - public AnimationLockTweak AnimLockTweak = new(); public InputOverride InputOverride = new(); public ActionManagerConfig Config = Service.Config.Get(); public CommonActions.NextAction AutoQueue; public bool MoveMightInterruptCast { get; private set; } // if true, moving now might cause cast interruption (for current or queued cast) private readonly ActionManager* _inst = ActionManager.Instance(); - private float _lastReqInitialAnimLock; - private int _lastReqSequence = -1; - private float _useActionInPast; // if >0 while using an action, cooldown/anim lock will be reduced by this amount as if action was used a bit in the past - private (Angle pre, Angle post)? _restoreRotation; // if not null, we'll try restoring rotation to pre while it is equal to post - private int _restoreCntr; + private readonly AnimationLockTweak _animLockTweak = new(); + private readonly CooldownDelayTweak _cooldownTweak = new(); + private readonly RestoreRotationTweak _restoreRotTweak = new(); private readonly HookAddress _updateHook; private readonly HookAddress _useActionLocationHook; @@ -94,9 +80,9 @@ public void Dispose() public void FaceTarget(Vector3 position, ulong unkObjID = 0xE0000000) => _inst->AutoFaceTargetPosition(&position, unkObjID); public void FaceDirection(WDir direction) { - var player = Service.ClientState.LocalPlayer; + var player = GameObjectManager.Instance()->Objects.IndexSorted[0].Value; if (player != null) - FaceTarget(player.Position + new Vector3(direction.X, 0, direction.Z)); + FaceTarget(player->Position.ToSystem() + direction.ToVec3()); } public void GetCooldown(ref Cooldown result, RecastDetail* data) @@ -184,21 +170,13 @@ private bool ExecuteAction(ActionID action, ulong targetId, Vector3 targetPos) private void UpdateDetour(ActionManager* self) { - var dt = Framework.Instance()->FrameDeltaTime; + var fwk = Framework.Instance(); + var dt = fwk->GameSpeedMultiplier * fwk->FrameDeltaTime; var imminentAction = _inst->ActionQueued ? QueuedAction : AutoQueue.Action; var imminentActionAdj = imminentAction.Type == ActionType.Spell ? new(ActionType.Spell, GetAdjustedActionID(imminentAction.ID)) : imminentAction; var imminentRecast = imminentActionAdj ? _inst->GetRecastGroupDetail(GetRecastGroup(imminentActionAdj)) : null; - if (Config.RemoveCooldownDelay) - { - var cooldownOverflow = imminentRecast != null && imminentRecast->IsActive != 0 ? imminentRecast->Elapsed + dt - imminentRecast->Total : dt; - var animlockOverflow = dt - _inst->AnimationLock; - _useActionInPast = Math.Min(cooldownOverflow, animlockOverflow); - if (_useActionInPast >= dt) - _useActionInPast = 0; // nothing prevented us from casting it before, so do not adjust anything... - else if (_useActionInPast > 0.1f) - _useActionInPast = 0.1f; // upper limit for time adjustment - } + _cooldownTweak.StartAdjustment(_inst->AnimationLock, imminentRecast != null && imminentRecast->IsActive != 0 ? imminentRecast->Total - imminentRecast->Elapsed : 0, dt); _updateHook.Original(self); // check whether movement is safe; block movement if not and if desired @@ -207,14 +185,10 @@ private void UpdateDetour(ActionManager* self) bool blockMovement = Config.PreventMovingWhileCasting && MoveMightInterruptCast; // restore rotation logic; note that movement abilities (like charge) can take multiple frames until they allow changing facing - if (_restoreRotation != null && !MoveMightInterruptCast) + var player = GameObjectManager.Instance()->Objects.IndexSorted[0].Value; + if (player != null && !MoveMightInterruptCast && _restoreRotTweak.TryRestore(player->Rotation.Radians(), out var restore)) { - var curRot = (Service.ClientState.LocalPlayer?.Rotation ?? 0).Radians(); - //Service.Log($"[AMEx] Restore rotation: {curRot.Rad}: {_restoreRotation.Value.post.Rad}->{_restoreRotation.Value.pre.Rad}"); - if (_restoreRotation.Value.post.AlmostEqual(curRot, 0.01f)) - FaceDirection(_restoreRotation.Value.pre.ToDirection()); - else if (--_restoreCntr == 0) - _restoreRotation = null; + FaceDirection(restore.ToDirection()); } // note: if we cancel movement and start casting immediately, it will be canceled some time later - instead prefer to delay for one frame @@ -247,7 +221,7 @@ private void UpdateDetour(ActionManager* self) } } - _useActionInPast = 0; // clear any potential adjustments + _cooldownTweak.StopAdjustment(); // clear any potential adjustments if (blockMovement) InputOverride.BlockMovement(); @@ -257,12 +231,12 @@ private void UpdateDetour(ActionManager* self) private bool UseActionLocationDetour(ActionManager* self, CSActionType actionType, uint actionId, ulong targetId, Vector3* location, uint extraParam) { - var pc = Service.ClientState.LocalPlayer; + var player = GameObjectManager.Instance()->Objects.IndexSorted[0].Value; var prevSeq = _inst->LastUsedActionSequence; - var prevRot = pc?.Rotation ?? 0; + var prevRot = player != null ? player->Rotation.Radians() : default; bool ret = _useActionLocationHook.Original(self, actionType, actionId, targetId, location, extraParam); var currSeq = _inst->LastUsedActionSequence; - var currRot = pc?.Rotation ?? 0; + var currRot = player != null ? player->Rotation.Radians() : default; if (currSeq != prevSeq) { HandleActionRequest(new((ActionType)actionType, actionId), currSeq, targetId, *location, prevRot, currRot); @@ -272,12 +246,12 @@ private bool UseActionLocationDetour(ActionManager* self, CSActionType actionTyp private bool UseBozjaFromHolsterDirectorDetour(PublicContentBozja* self, uint holsterIndex, uint slot) { - var pc = Service.ClientState.LocalPlayer; - var prevRot = pc?.Rotation ?? 0; + var player = GameObjectManager.Instance()->Objects.IndexSorted[0].Value; + var prevRot = player != null ? player->Rotation.Radians() : default; var res = _useBozjaFromHolsterDirectorHook.Original(self, holsterIndex, slot); + var currRot = player != null ? player->Rotation.Radians() : default; if (res) { - var currRot = pc?.Rotation ?? 0; var entry = (BozjaHolsterID)self->State.HolsterActions[(int)holsterIndex]; HandleActionRequest(ActionID.MakeBozjaHolster(entry, (int)slot), 0, 0xE0000000, default, prevRot, currRot); } @@ -317,13 +291,11 @@ private void ProcessPacketActionEffectDetour(uint casterID, Character* casterObj _processPacketActionEffectHook.Original(casterID, casterObj, targetPos, header, effects, targets); var currAnimLock = _inst->AnimationLock; - if (casterID != UIState.Instance()->PlayerState.EntityId || header->SourceSequence == 0 && _lastReqSequence != 0) + if (casterID != UIState.Instance()->PlayerState.EntityId || header->SourceSequence == 0 && !header->ForceAnimationLock) { // this action is either executed by non-player, or is non-player-initiated // TODO: reconsider the condition: // - some actions with SourceSequence != 0 are special-cased in code (NIN's ten/chi/jin) and apparently don't trigger anim-lock, verify - // - actions can have 'force anim lock' flag set, and then trigger anim-lock despite SourceSequence == 0, verify e.g. bozja holster actions - // - auto is the most common cast with SourceSequence == 0; can it happen while waiting for reholster response?.. // - do we want to do non-anim-lock related things (eg unblock movement override) when we get action with 'force anim lock' flag? if (currAnimLock != prevAnimLock) Service.Log($"[AMEx] Animation lock updated by non-player-initiated action: #{header->SourceSequence} {casterID:X} {info.Action} {prevAnimLock:f3} -> {currAnimLock:f3}"); @@ -334,53 +306,30 @@ private void ProcessPacketActionEffectDetour(uint casterID, Character* casterObj InputOverride.UnblockMovement(); // unblock input unconditionally on successful cast (I assume there are no instances where we need to immediately start next GCD?) // animation lock delay update - float animLockDelay = _lastReqInitialAnimLock - prevAnimLock; - float animLockReduction = 0; - if (_lastReqSequence == header->SourceSequence) - { - if (_lastReqInitialAnimLock > 0) - { - AnimLockTweak.SanityCheck(packetAnimLock, header->AnimationLock); - animLockReduction = AnimLockTweak.Apply(_inst->AnimationLock, animLockDelay); - _inst->AnimationLock -= animLockReduction; - } - } - else if (currAnimLock != prevAnimLock) - { - Service.Log($"[AMEx] Animation lock updated by action with unexpected sequence ID #{header->SourceSequence}: {prevAnimLock:f3} -> {currAnimLock:f3}"); - } - - Service.Log($"[AMEx] AEP #{header->SourceSequence} {prevAnimLock:f3} {info.Action} -> ALock={currAnimLock:f3} (delayed by {animLockDelay:f3}-{animLockReduction:f3}), CTR={CastTimeRemaining:f3}, GCD={GCD():f3}"); - _lastReqSequence = -1; + var animLockReduction = _animLockTweak.Apply(header->SourceSequence, prevAnimLock, _inst->AnimationLock, packetAnimLock, header->AnimationLock, out var animLockDelay); + _inst->AnimationLock -= animLockReduction; + Service.Log($"[AMEx] AEP #{header->SourceSequence} {prevAnimLock:f3} {info.Action} -> ALock={currAnimLock:f3} (delayed by {animLockDelay:f3}) -> {_inst->AnimationLock:f3}), Flags={header->Flags:X}, CTR={CastTimeRemaining:f3}, GCD={GCD():f3}"); } - private void HandleActionRequest(ActionID action, int seq, ulong targetID, Vector3 targetPos, float prevRot, float currRot) + private void HandleActionRequest(ActionID action, uint seq, ulong targetID, Vector3 targetPos, Angle prevRot, Angle currRot) { - _lastReqInitialAnimLock = _inst->AnimationLock; - _lastReqSequence = seq; + _animLockTweak.RecordRequest(seq, _inst->AnimationLock); + _restoreRotTweak.Preserve(prevRot, currRot); MoveMightInterruptCast = CastTimeRemaining > 0; - if (prevRot != currRot && Config.RestoreRotation) - { - _restoreRotation = (prevRot.Radians(), currRot.Radians()); - _restoreCntr = 2; // not sure why - but sometimes after successfully restoring rotation it is snapped back on next frame; TODO investigate - //Service.Log($"[AMEx] Restore start: {currRot} -> {prevRot}"); - } var recast = _inst->GetRecastGroupDetail(GetRecastGroup(action)); - if (_useActionInPast > 0) - { - if (CastTimeRemaining > 0) - _inst->CastTimeElapsed += _useActionInPast; - else - _inst->AnimationLock = Math.Max(0, _inst->AnimationLock - _useActionInPast); - if (recast != null) - recast->Elapsed += _useActionInPast; - } + if (CastTimeRemaining > 0) + _inst->CastTimeElapsed += _cooldownTweak.Adjustment; + else + _inst->AnimationLock = Math.Max(0, _inst->AnimationLock - _cooldownTweak.Adjustment); + + if (recast != null) + recast->Elapsed += _cooldownTweak.Adjustment; - var recastElapsed = recast != null ? recast->Elapsed : 0; - var recastTotal = recast != null ? recast->Total : 0; + var (castElapsed, castTotal) = _inst->CastSpellId != 0 ? (_inst->CastTimeElapsed, _inst->CastTimeTotal) : (0, 0); + var (recastElapsed, recastTotal) = recast != null ? (recast->Elapsed, recast->Total) : (0, 0); Service.Log($"[AMEx] UAL #{seq} {action} @ {targetID:X} / {Utils.Vec3String(targetPos)}, ALock={_inst->AnimationLock:f3}, CTR={CastTimeRemaining:f3}, CD={recastElapsed:f3}/{recastTotal:f3}, GCD={GCD():f3}"); - ActionRequested.Fire(new(action, targetID, targetPos, (uint)seq, _inst->AnimationLock, _inst->CastSpellId != 0 ? _inst->CastTimeElapsed : 0, _inst->CastSpellId != 0 ? _inst->CastTimeTotal : 0, recastElapsed, recastTotal)); + ActionRequested.Fire(new(action, targetID, targetPos, seq, _inst->AnimationLock, castElapsed, castTotal, recastElapsed, recastTotal)); } } diff --git a/BossMod/Framework/Service.cs b/BossMod/Framework/Service.cs index a3499cfae4..5d8534dbc1 100644 --- a/BossMod/Framework/Service.cs +++ b/BossMod/Framework/Service.cs @@ -12,22 +12,23 @@ public sealed class Service #pragma warning disable CS8618 [PluginService] public static IPluginLog Logger { get; private set; } [PluginService] public static IDataManager DataManager { get; private set; } - [PluginService] public static IClientState ClientState { get; private set; } - [PluginService] public static IObjectTable ObjectTable { get; private set; } - [PluginService] public static IPartyList PartyList { get; private set; } [PluginService] public static IChatGui ChatGui { get; private set; } [PluginService] public static IGameGui GameGui { get; private set; } [PluginService] public static IGameInteropProvider Hook { get; private set; } [PluginService] public static ISigScanner SigScanner { get; private set; } - [PluginService] public static IJobGauges JobGauges { get; private set; } - [PluginService] public static IKeyState KeyState { get; private set; } [PluginService] public static ICondition Condition { get; private set; } - [PluginService] public static ITargetManager TargetManager { get; private set; } [PluginService] public static IFramework Framework { get; private set; } [PluginService] public static ITextureProvider Texture { get; private set; } [PluginService] public static ICommandManager CommandManager { get; private set; } [PluginService] public static IDtrBar DtrBar { get; private set; } [PluginService] public static DalamudPluginInterface PluginInterface { get; private set; } + // TODO: get rid of stuff below in favour of CS + [PluginService] public static IClientState ClientState { get; private set; } + [PluginService] public static IObjectTable ObjectTable { get; private set; } + [PluginService] public static IPartyList PartyList { get; private set; } + [PluginService] public static ITargetManager TargetManager { get; private set; } + [PluginService] public static IJobGauges JobGauges { get; private set; } + [PluginService] public static IKeyState KeyState { get; private set; } #pragma warning restore CS8618 #pragma warning disable CA2211 diff --git a/BossMod/Framework/Utils.cs b/BossMod/Framework/Utils.cs index 547c3b98e5..64c3eeff70 100644 --- a/BossMod/Framework/Utils.cs +++ b/BossMod/Framework/Utils.cs @@ -1,5 +1,4 @@ using Dalamud.Game.ClientState.Objects.Types; -using JetBrains.Annotations; using System.Globalization; using System.Reflection; using System.Runtime.InteropServices; @@ -25,6 +24,7 @@ public static string ObjectKindString(GameObject obj) public static Vector3 XYZ(this Vector4 v) => new(v.X, v.Y, v.Z); public static Vector2 XZ(this Vector4 v) => new(v.X, v.Z); public static Vector2 XZ(this Vector3 v) => new(v.X, v.Z); + public static Vector3 ToSystem(this FFXIVClientStructs.FFXIV.Common.Math.Vector3 v) => new(v.X, v.Y, v.Z); public static bool AlmostEqual(float a, float b, float eps) => MathF.Abs(a - b) <= eps; public static bool AlmostEqual(Vector3 a, Vector3 b, float eps) => (a - b).LengthSquared() <= eps * eps; diff --git a/BossMod/Modules/Endwalker/Ultimate/TOP/P5Delta.cs b/BossMod/Modules/Endwalker/Ultimate/TOP/P5Delta.cs index 034d275d35..cfdd0e7b0e 100644 --- a/BossMod/Modules/Endwalker/Ultimate/TOP/P5Delta.cs +++ b/BossMod/Modules/Endwalker/Ultimate/TOP/P5Delta.cs @@ -238,7 +238,7 @@ private IEnumerable SafeSpotOffsets(int slot) if (p.PartnerSlot < 0 || _eyeDir == default) yield break; // no safe spots yet - if (p.RocketPunch == null) + if (NumPunchesSpawned < PartyState.MaxPartySize) { // no punches yet, show all 4 possible spots if (p.IsLocal) diff --git a/BossMod/Network/PacketDecoder.cs b/BossMod/Network/PacketDecoder.cs index c8dbb77b58..076b7f9455 100644 --- a/BossMod/Network/PacketDecoder.cs +++ b/BossMod/Network/PacketDecoder.cs @@ -55,7 +55,7 @@ public void LogNode(TextNode n, string prefix) private TextNode? DecodePacket(PacketID id, byte* payload) => id switch { PacketID.RSVData when (RSVData*)payload is var p => new($"{MemoryHelper.ReadStringNullTerminated((nint)p->Key)} = {MemoryHelper.ReadString((nint)p->Value, p->ValueLength)} [{p->ValueLength}]"), - PacketID.Countdown when (ServerIPC.Countdown*)payload is var p => new($"{p->Time}s from {DecodeActor(p->SenderID)}{(p->FailedInCombat != 0 ? " fail-in-combat" : "")} '{MemoryHelper.ReadStringNullTerminated((nint)p->Text)}' u={p->u4:X4} {p->u9:X2} {p->u10:X2}"), + PacketID.Countdown when (Countdown*)payload is var p => new($"{p->Time}s from {DecodeActor(p->SenderID)}{(p->FailedInCombat != 0 ? " fail-in-combat" : "")} '{MemoryHelper.ReadStringNullTerminated((nint)p->Text)}' u={p->u4:X4} {p->u9:X2} {p->u10:X2}"), PacketID.CountdownCancel when (CountdownCancel*)payload is var p => new($"from {DecodeActor(p->SenderID)} '{MemoryHelper.ReadStringNullTerminated((nint)p->Text)}' u={p->u4:X4} {p->u6:X4}"), PacketID.StatusEffectList when (StatusEffectList*)payload is var p => DecodeStatusEffectList(p), PacketID.StatusEffectListEureka when (StatusEffectListEureka*)payload is var p => DecodeStatusEffectList(&p->Data, $", rank={p->Rank}/{p->Element}/{p->u2}, pad={p->pad3:X2}"), diff --git a/FFXIVClientStructs b/FFXIVClientStructs index 705119e450..0d84c97361 160000 --- a/FFXIVClientStructs +++ b/FFXIVClientStructs @@ -1 +1 @@ -Subproject commit 705119e450a671a76166cec525364414380c5e90 +Subproject commit 0d84c97361201fc53e54ef485460f46171701e32