commit f3ab7c58c3e46b78f586e0e8bfe23a4069a660aa Author: NemophilaHare <1+nemophilahare@noreply.localhost> Date: Tue Oct 13 22:55:05 2026 +0800 Initial import of BardFrontline diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..649fca9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +bin/ +obj/ +.refs/ +dist/ +Tests/bin/ +Tests/obj/ + +# Local credentials and IDE state. +.env +.env.* +!.env.example +.vs/ +*.user +*.suo diff --git a/BardFrontline.csproj b/BardFrontline.csproj new file mode 100644 index 0000000..fa2a3cf --- /dev/null +++ b/BardFrontline.csproj @@ -0,0 +1,25 @@ + + + net10.0-windows + x64 + enable + enable + true + 0.2.1 + 0.2.1.0 + true + $(APPDATA)\XIVLauncher\addon\Hooks\dev + false + + + + + $(HooksDevPath)\%(Identity).dll + false + + + + + + + diff --git a/BardFrontline.json b/BardFrontline.json new file mode 100644 index 0000000..79afd2d --- /dev/null +++ b/BardFrontline.json @@ -0,0 +1,12 @@ +{ + "Author": "AinaSnow", + "Name": "Bard Frontline", + "InternalName": "BardFrontline", + "AssemblyVersion": "0.2.1.0", + "DalamudApiLevel": 15, + "ApplicableVersion": "any", + "LoadRequiredState": 0, + "Punchline": "吟游诗人纷争前线与烈羽争锋自动循环。", + "Description": "支持纷争前线与烈羽争锋的独立诗人循环。玩家优先、鹰眼收割、完整决策诊断、自适应穿插;可选稳定选敌、手动接管、爆发、范围选敌、集火防御和目标分类。驾驶机甲时暂停。使用 /bardpvp 打开设置;每次加载默认关闭。", + "Tags": ["PvP", "Bard", "Frontline", "RivalWings"] +} diff --git a/Configuration.cs b/Configuration.cs new file mode 100644 index 0000000..f7dd37d --- /dev/null +++ b/Configuration.cs @@ -0,0 +1,12 @@ +using BardFrontline.Core; +using Dalamud.Configuration; + +namespace BardFrontline; + +[Serializable] +public sealed class Configuration : IPluginConfiguration +{ + public int Version { get; set; } = 1; + public Settings Settings { get; set; } = new(); + // Runtime enable state is deliberately not serialized. +} diff --git a/Core/Actions.cs b/Core/Actions.cs new file mode 100644 index 0000000..fde826d --- /dev/null +++ b/Core/Actions.cs @@ -0,0 +1,53 @@ +namespace BardFrontline.Core; + +// IDs and rotation predicates are referenced from the local RSR ActionId/Status/Rotation.resx +// and BardRotation.cs. No RSR assembly or source project is linked. +public enum Skill : uint +{ + Guard = 29054, Elixir = 29055, Purify = 29056, Sprint = 29057, + PowerfulShot = 29391, PitchPerfect = 29392, ApexArrow = 29393, BlastArrow = 29394, + SilentNocturne = 29395, RepellingShot = 29399, WardensPaean = 29400, + FinalFantasia = 29401, Recuperate = 29711, HarmonicArrow = 41464, + EncoreOfLight = 41467, Dervish = 43249, Bravery = 43250, EagleEyeShot = 43251, +} + +public static class Statuses +{ + public const uint Guard = 3054, Repertoire = 3137, March = 3138, BlastReady = 3142, + Fantasia = 3144, EncoreReady = 4312, Sprint = 1342, Dervish = 4478, Bravery = 4479, + RoleDervish = 4489, RoleBravery = 4490, RoleEagle = 4491, Warmachina = 1420; + public static readonly uint[] Controls = [1343, 1344, 1345, 1347, 3219, 3085]; + // Named invincibilities from RSR's status data; damage reduction (e.g. Phalanx) is not immunity. + public static readonly HashSet Invincible = + [82, 325, 394, 529, 656, 671, 775, 776, 895, 969, 981, 1302, 1570, 1697, 1829, 4410, 4539, 4875]; + + public static bool IsInvulnerability(uint statusId, uint icon) => statusId != Guard + && (Invincible.Contains(statusId) || icon == 15024); +} + +public static class Actions +{ + public static readonly Skill[] All = Enum.GetValues(); + public static bool Matches(Skill skill, uint adjustedId) => adjustedId == (uint)skill + || skill == Skill.HarmonicArrow && adjustedId is 41465 or 41466 or 41964; + public static bool IsHostile(Skill s) => s is Skill.PowerfulShot or Skill.PitchPerfect or Skill.ApexArrow + or Skill.BlastArrow or Skill.SilentNocturne or Skill.RepellingShot or Skill.HarmonicArrow + or Skill.EncoreOfLight or Skill.EagleEyeShot; + public static uint Root(Skill s) => (uint)(s switch + { + Skill.PitchPerfect => Skill.PowerfulShot, + Skill.BlastArrow => Skill.ApexArrow, + Skill.EncoreOfLight => Skill.FinalFantasia, + _ => s, + }); + public static string Name(Skill s) => s switch + { + Skill.Guard => "防御 Guard", Skill.Elixir => "军用圣灵药", Skill.Purify => "净化", + Skill.Sprint => "冲刺", Skill.Recuperate => "自疗 Recuperate", Skill.WardensPaean => "光阴神的礼赞", + Skill.FinalFantasia => "英雄的幻想曲(极限技)", Skill.RepellingShot => "后跃射击", + Skill.PowerfulShot => "强力射击", Skill.PitchPerfect => "完美音调", Skill.ApexArrow => "绝峰箭", + Skill.BlastArrow => "爆破箭", Skill.HarmonicArrow => "和弦箭", Skill.SilentNocturne => "默者的夜曲", + Skill.EncoreOfLight => "英雄的安可 Encore of Light", Skill.Bravery => "Bravery", Skill.Dervish => "Dervish", + Skill.EagleEyeShot => "Eagle Eye Shot", _ => s.ToString(), + }; +} diff --git a/Core/Battlefield.cs b/Core/Battlefield.cs new file mode 100644 index 0000000..1927404 --- /dev/null +++ b/Core/Battlefield.cs @@ -0,0 +1,51 @@ +namespace BardFrontline.Core; + +public enum BattlefieldMode { None, Frontline, RivalWings } + +public static class Battlefield +{ + // BNpcName row IDs from local client reference data, never localized display-name matching. + public static ObjectiveKind Classify(BattlefieldMode mode, bool npc, bool warmachina, uint nameId) + { + if (warmachina && mode == BattlefieldMode.RivalWings) return ObjectiveKind.Mech; + if (!npc) return ObjectiveKind.Other; + if (mode == BattlefieldMode.Frontline && nameId is >= 4822 and <= 4837 or >= 4840 and <= 4845) return ObjectiveKind.Ice; + if (mode != BattlefieldMode.RivalWings) return ObjectiveKind.Other; + return nameId switch + { + 6857 or 6858 => ObjectiveKind.Core, + >= 6859 and <= 6862 => ObjectiveKind.Tower, + >= 6863 and <= 6868 => ObjectiveKind.Mech, + >= 6869 and <= 6872 or 7889 or 7890 or 14252 or 14253 => ObjectiveKind.Infantry, + 7891 or 7892 or 7906 => ObjectiveKind.Mercenary, + _ => ObjectiveKind.Other, + }; + } + public static string KindName(ObjectiveKind kind) => kind switch + { + ObjectiveKind.Mech => "机甲", ObjectiveKind.Infantry => "兵线", ObjectiveKind.Mercenary => "佣兵", + ObjectiveKind.Tower => "防御塔", ObjectiveKind.Core => "核心", ObjectiveKind.Ice => "冰块", _ => "其他目标", + }; + // TerritoryIntendedUse values from the client sheet / FFXIVClientStructs enum. + public static BattlefieldMode Identify(uint intendedUse, bool pvp) => !pvp ? BattlefieldMode.None : intendedUse switch + { + 18 => BattlefieldMode.Frontline, + 39 => BattlefieldMode.RivalWings, + _ => BattlefieldMode.None, + }; + + public static string Name(BattlefieldMode mode) => mode switch + { + BattlefieldMode.Frontline => "纷争前线", + BattlefieldMode.RivalWings => "烈羽争锋", + _ => "不支持的区域", + }; + + // Content key 1 is the Rival Wings ManeuversArmor row. Check both it and the + // warmachina status to cover transitions without relying on an ordinary mount flag. + public static bool IsPiloting(BattlefieldMode mode, bool pilotingCondition, bool warmachinaStatus, uint armor) => + mode == BattlefieldMode.RivalWings && (pilotingCondition || warmachinaStatus || armor != 0); + + public static bool IsBattleNpcCandidate(byte subKind, uint owner) => + subKind is 0 or 1 or 5 && owner is 0 or 0xE0000000; +} diff --git a/Core/ControlAndTiming.cs b/Core/ControlAndTiming.cs new file mode 100644 index 0000000..e80d015 --- /dev/null +++ b/Core/ControlAndTiming.cs @@ -0,0 +1,53 @@ +namespace BardFrontline.Core; + +public sealed class ManualControl +{ + private bool initialized; + private ulong lastTarget; + private ushort lastSequence; + private double until; + public bool Forced { get; set; } + public double TargetChangedAt { get; private set; } + public bool Active(double now) => Forced || now < until; + public void Observe(double now, ulong target, ushort sequence, bool pluginPending, Settings c) + { + if (initialized && (target != lastTarget || sequence != lastSequence && !pluginPending) && c.DetectManualControl) + until = now + c.ManualControlSeconds; + if (!initialized || target != lastTarget) TargetChangedAt = now; + initialized = true; lastTarget = target; lastSequence = sequence; + if (!c.DetectManualControl) until = 0; + } + public void OwnTarget(ulong id, double now) { if (id != lastTarget) TargetChangedAt = now; lastTarget = id; } + public void OwnAction(ushort sequence) => lastSequence = sequence; + public void Resume() { Forced = false; until = 0; initialized = false; } + public void ResetObservations() { initialized = false; until = 0; TargetChangedAt = 0; } +} + +public sealed class WeaveTiming +{ + private readonly Queue samples = new(); + private (double Start, double? Ack, double Unlock)? pending; + public int SampleCount => samples.Count; + public float Budget => samples.Count < 3 ? .65f : Math.Clamp(samples.Order().ElementAt((int)Math.Ceiling(samples.Count * .9) - 1) + .05f, .5f, 1.25f); + public void Submitted(double now, bool cast) { pending = cast ? null : (now, null, now); } + public void Observe(double now, bool acknowledged, float animationLock, bool queued, bool casting) + { + if (pending is not { } p) return; + if (now - p.Start > 2 || casting) { pending = null; return; } + if (acknowledged && p.Ack == null) p.Ack = now; + p.Unlock = Math.Max(p.Unlock, now + Math.Max(0, animationLock)); + pending = p; + if (p.Ack == null || animationLock > 0 || queued) return; + var duration = (float)(Math.Max(p.Ack.Value, p.Unlock) - p.Start); + pending = null; + if (duration is < .05f or > 1.5f) return; + samples.Enqueue(duration); while (samples.Count > 20) samples.Dequeue(); + } + public bool Allows(float remaining, Skill skill, bool adaptive, BurstStrategy strategy = BurstStrategy.Normal, bool casting = false) => + skill == Skill.EncoreOfLight || remaining <= 0 || remaining >= (adaptive ? Budget : .65f) + // This only waives our extra GCD reservation; native locks/recasts still gate dispatch. + || strategy == BurstStrategy.Pressure && !casting && skill is (Skill.HarmonicArrow or Skill.PitchPerfect + or Skill.BlastArrow or Skill.ApexArrow or Skill.SilentNocturne or Skill.EagleEyeShot + or Skill.Bravery or Skill.Dervish or Skill.FinalFantasia); + public void Reset() { samples.Clear(); pending = null; } +} diff --git a/Core/DecisionAudit.cs b/Core/DecisionAudit.cs new file mode 100644 index 0000000..a21d2ff --- /dev/null +++ b/Core/DecisionAudit.cs @@ -0,0 +1,55 @@ +namespace BardFrontline.Core; + +public sealed record SkillAudit(Skill Skill, ulong TargetId, string Result); +public sealed record DecisionFrame(long Number, double Time, string Phase, ActionDecision? Decision, IReadOnlyList Skills); + +public static class DecisionAudit +{ + public static IReadOnlyList Evaluate(CombatSnapshot? s, Settings c, ActionDecision? selected, + string phase, Func nativeCheck, Func? targetProbe = null) + { + var result = new List(); + var cache = new Dictionary<(Skill, ulong), string?>(); + string? Check(Skill skill, Actor target) + { + if (!cache.TryGetValue((skill, target.Id), out var value)) cache[(skill, target.Id)] = value = nativeCheck(skill, target); + return value; + } + var hasEnemy = s != null && s.Enemies.Any(a => Actions.All.Any(skill => TargetSelector.Relevant(skill, s, c) + && TargetSelector.Allowed(a, c, skill) && (targetProbe?.Invoke(skill, a) ?? Check(skill, a) == null))); + var pool = s == null ? [] : TargetSelector.CombatCandidates(s, c, new Availability(Check)); + foreach (var skill in Actions.All) + { + if (selected?.Skill == skill) + { result.Add(new(skill, selected.TargetId, $"本轮选中 → {selected.TargetId:X}:{selected.Reason}")); continue; } + var reason = s == null ? phase : Strategy.PolicyReason(skill, s, c); + if (reason == null && skill is Skill.Bravery or Skill.Dervish or Skill.FinalFantasia && !hasEnemy) reason = "没有可攻击的增益目标"; + if (reason == null && skill == Skill.Sprint && hasEnemy) reason = "存在有效攻击目标,不自动冲刺"; + if (reason != null) { result.Add(new(skill, 0, reason)); continue; } + var state = s!; + IEnumerable candidates = Actions.IsHostile(skill) ? pool + : skill == Skill.WardensPaean ? new[] { state.Player }.Concat(c.HelpParty ? state.Party : []) + : [state.Player]; + var failures = new HashSet(); + Actor? usable = null; + foreach (var target in TargetSelector.Ordered(candidates, c, state.CurrentTarget).OrderBy(a => a.IsObjective)) + { + if (Actions.IsHostile(skill) && !TargetSelector.Allowed(target, c, skill)) + { failures.Add("目标被死亡 / 防御 / 免疫 / 分类设置排除"); continue; } + if (skill == Skill.EagleEyeShot && c.EagleStrategy == EagleStrategy.FinisherOnly && target.EffectiveHp > (ulong)c.EagleFinisherHp) + { failures.Add("严格收割:有效 HP 高于阈值"); continue; } + if (skill == Skill.WardensPaean && (!target.Alive || !target.Targetable || !Statuses.Controls.Any(target.Has))) continue; + var failure = Check(skill, target); + if (failure != null) { failures.Add(failure); continue; } + usable = target; break; + } + result.Add(usable == null + ? new(skill, 0, failures.Count == 0 ? "没有符合策略的目标" : string.Join(";", failures.Take(4))) + : new(skill, usable.Id, selected != null ? $"可用 → {usable.Name};本轮优先选择 {Actions.Name(selected.Skill)}" + : $"就绪候选 → {usable.Name};当前执行阶段:{phase}")); + } + return result; + } + private sealed class Availability(Func check) : IActionAvailability + { public bool CanUse(Skill skill, Actor target) => check(skill, target) == null; } +} diff --git a/Core/ExecutionPolicy.cs b/Core/ExecutionPolicy.cs new file mode 100644 index 0000000..fdafd98 --- /dev/null +++ b/Core/ExecutionPolicy.cs @@ -0,0 +1,50 @@ +namespace BardFrontline.Core; + +public sealed record RuntimeConditions(bool Enabled, bool LoggedIn, bool ChangingArea, BattlefieldMode Mode, + bool DutyStarted, bool DutyCompleted, bool Bard, bool Alive, bool Mounted, bool Occupied, bool CoreReady, bool Piloting = false); + +public static class ExecutionPolicy +{ + public static string? PauseReason(RuntimeConditions s) + { + if (!s.Enabled) return "已关闭"; + if (!s.LoggedIn) return "等待角色登录"; + if (s.ChangingArea) return "切换区域"; + if (s.Mode is not (BattlefieldMode.Frontline or BattlefieldMode.RivalWings)) return "仅在纷争前线 / 烈羽争锋运行"; + if (!s.DutyStarted || s.DutyCompleted) return "等待对局开始 / 对局已结束"; + if (s.Piloting) return "驾驶机甲中,暂停诗人循环"; + if (!s.Bard) return "仅支持吟游诗人"; + if (!s.Alive) return "角色死亡"; + if (s.Mounted) return "坐骑上暂停"; + if (s.Occupied) return "角色正在交互"; + return s.CoreReady ? null : "核心技能数据缺失"; + } + + public static bool CanDispatch(bool queued, float animationLock, bool casting, bool pending, bool guard) => + !queued && animationLock <= 0 && !casting && !pending && !guard; + + public static bool CanWeave(float sharedRecastRemaining) => sharedRecastRemaining <= 0 || sharedRecastRemaining >= .65f; + + // Encore is a time-limited LB follow-up. An extra GCD reservation must not starve it + // under stacked haste; the runtime still enforces casting, animation lock and native recast. + public static bool CanWeave(float sharedRecastRemaining, Skill skill) => + skill == Skill.EncoreOfLight || CanWeave(sharedRecastRemaining); + + public static bool SharesGcd(int primary, int additional, int shared) => shared >= 0 && (primary == shared || additional == shared); + public static float RecastRemaining(bool active, float total, float elapsed) => + active && float.IsFinite(total) && float.IsFinite(elapsed) ? Math.Max(0, total - elapsed) : 0; + + public static bool OwnsCast(uint ownedId, ulong ownedTarget, ushort ownedSequence, + uint castId, ulong castTarget, ushort usedSequence) => + ownedId == castId && ownedTarget == castTarget && ownedSequence == usedSequence; + + public static bool ShouldCancelCast(Skill castSkill, bool owned, bool invalidTarget, bool enemyGuard, + bool ignoreGuard, ActionDecision? emergency, bool nearbyThreat, bool hasControl) + { + if (!owned) return false; + if (Actions.IsHostile(castSkill) && (invalidTarget || enemyGuard && !ignoreGuard && castSkill != Skill.EagleEyeShot)) return true; + if (emergency == null) return false; + // Recovering HP is the point of elixir. Do not cancel a safe drink just because Recuperate came off CD. + return castSkill != Skill.Elixir || nearbyThreat || hasControl; + } +} diff --git a/Core/Models.cs b/Core/Models.cs new file mode 100644 index 0000000..7a9639e --- /dev/null +++ b/Core/Models.cs @@ -0,0 +1,214 @@ +using System.Numerics; +namespace BardFrontline.Core; + +public enum TargetOrder { LowestHp, LowestHpRatio, Nearest, AttackMarker } +public enum EagleStrategy { FinisherFirst, FinisherOnly } +public enum BurstStrategy { Normal, AlignBuffs, ManualWindow, Pressure } +public enum ObjectiveKind { Other, Ice, Mech, Infantry, Mercenary, Tower, Core } + +public sealed class Settings +{ + public bool AutoAttack { get; set; } = true; + public bool AttackObjectives { get; set; } = true; + public bool IgnoreEnemyGuard { get; set; } + public bool HelpParty { get; set; } + public bool ShowOverlay { get; set; } = true; + public bool StableTargets { get; set; } + public float TargetHoldSeconds { get; set; } = .6f; + public int TargetHpMargin { get; set; } = 2000; + public bool DetectManualControl { get; set; } + public float ManualControlSeconds { get; set; } = 3; + public bool AdaptiveWeaving { get; set; } = true; + public BurstStrategy Burst { get; set; } + public int BurstCharges { get; set; } = 3; + public float BurstMaxHoldSeconds { get; set; } = 4; + public bool PreferAoe { get; set; } + public int AoeMinTargets { get; set; } = 2; + public bool FocusDefense { get; set; } + public int FocusAttackers { get; set; } = 3; + public float FocusGuardPercent { get; set; } = 40; + public bool ManualRepelling { get; set; } + public HashSet DisabledObjectives { get; set; } = []; + public TargetOrder TargetOrder { get; set; } + public float GuardHpPercent { get; set; } = 15; + public int RecuperateMissingHp { get; set; } = 15000; + public float ElixirPercent { get; set; } = 100f / 3; + public EagleStrategy EagleStrategy { get; set; } = EagleStrategy.FinisherFirst; + public int EagleFinisherHp { get; set; } = 10000; + public HashSet DisabledSkills { get; set; } = []; + public HashSet DisabledPurifyStatuses { get; set; } = []; + public bool Enabled(Skill skill) => !DisabledSkills.Contains(skill); + public void Normalize() + { + DisabledSkills ??= []; DisabledPurifyStatuses ??= []; + DisabledObjectives ??= []; + TargetHoldSeconds = Clamp(TargetHoldSeconds, 0, 3, .6f); + TargetHpMargin = Math.Clamp(TargetHpMargin, 0, 20000); + ManualControlSeconds = Clamp(ManualControlSeconds, .5f, 15, 3); + BurstMaxHoldSeconds = Clamp(BurstMaxHoldSeconds, .5f, 10, 4); + FocusGuardPercent = Clamp(FocusGuardPercent, 1, 80, 40); + FocusAttackers = Math.Clamp(FocusAttackers, 2, 10); + BurstCharges = Math.Clamp(BurstCharges, 1, 4); + AoeMinTargets = Math.Clamp(AoeMinTargets, 2, 10); + if (!Enum.IsDefined(Burst)) Burst = BurstStrategy.Normal; + GuardHpPercent = float.IsFinite(GuardHpPercent) ? Math.Clamp(GuardHpPercent, 1, 100) : 15; + ElixirPercent = float.IsFinite(ElixirPercent) ? Math.Clamp(ElixirPercent, 1, 100) : 100f / 3; + RecuperateMissingHp = Math.Clamp(RecuperateMissingHp, 1, 100000); + EagleFinisherHp = Math.Clamp(EagleFinisherHp, 1, 100000); + if (!Enum.IsDefined(TargetOrder)) TargetOrder = TargetOrder.LowestHp; + if (!Enum.IsDefined(EagleStrategy)) EagleStrategy = EagleStrategy.FinisherFirst; + } + private static float Clamp(float value, float min, float max, float fallback) => + float.IsFinite(value) ? Math.Clamp(value, min, max) : fallback; +} + +public sealed record Actor(ulong Id, string Name, uint Hp, uint MaxHp, float Distance, + bool Enemy, bool Targetable, bool Dead, int AttackMarker, HashSet Status) +{ + public bool IsObjective { get; init; } + public bool ThreateningObjective { get; init; } + public ObjectiveKind Kind { get; init; } + public Vector3 Position { get; init; } + public float HitboxRadius { get; init; } + public ulong TargetId { get; init; } + public ulong CastTargetId { get; init; } + public bool Invulnerable { get; init; } + public IReadOnlyDictionary StatusRemaining { get; init; } = new Dictionary(); + public uint ShieldHp { get; init; } + public ulong EffectiveHp => (ulong)Hp + ShieldHp; + public double HpRatio => MaxHp == 0 ? 1 : (double)Hp / MaxHp; + public bool Alive => !Dead && Hp > 0 && MaxHp > 0; + public bool Has(uint status) => Status.Contains(status); + public float Remaining(uint status) => Has(status) && StatusRemaining.TryGetValue(status, out var seconds) + && float.IsFinite(seconds) && seconds > 0 ? seconds : float.PositiveInfinity; +} + +public sealed record CombatSnapshot( + Actor Player, uint Mp, uint MaxMp, bool InCombat, bool Moving, double AliveSeconds, + bool LimitFull, IReadOnlyList Enemies, IReadOnlyList Party, ulong CurrentTarget, + bool GuardPending = false, bool CleansePending = false) +{ + public double TargetAge { get; init; } = double.PositiveInfinity; + public bool ManualControl { get; init; } + public bool BurstWindow { get; init; } + public int HarmonicCharges { get; init; } + public double HarmonicReadySeconds { get; init; } + public double RecentHpLossRatio { get; init; } + public IReadOnlyDictionary Shapes { get; init; } = new Dictionary(); +} + +public sealed record ActionDecision(Skill Skill, ulong TargetId, string Reason, bool Emergency = false); + +// The only native boundary used by the decision engine. A fake implementation drives offline tests. +public interface IActionAvailability +{ + bool CanUse(Skill skill, Actor target); +} + +public static class TargetSelector +{ + public static bool Allowed(Actor a, Settings c, Skill skill) => + (!a.IsObjective || c.AttackObjectives && !c.DisabledObjectives.Contains(a.Kind) && skill != Skill.EagleEyeShot) + && Eligible(a, skill, c.IgnoreEnemyGuard); + + public static bool Relevant(Skill skill, CombatSnapshot s, Settings c) => Actions.IsHostile(skill) && c.Enabled(skill) + && !s.ManualControl && Strategy.HoldReason(skill, s, c) == null + && (skill != Skill.EagleEyeShot || s.Player.Has(Statuses.RoleEagle) && c.EagleStrategy == EagleStrategy.FinisherFirst) + && (skill != Skill.EncoreOfLight || s.Player.Has(Statuses.EncoreReady)) + && (skill != Skill.PitchPerfect || s.Player.Has(Statuses.Repertoire)) + && (skill != Skill.PowerfulShot || !s.Player.Has(Statuses.Repertoire)) + && (skill != Skill.BlastArrow || s.Player.Has(Statuses.BlastReady)) + && (skill != Skill.ApexArrow || !s.Player.Has(Statuses.BlastReady)) + && (skill is not (Skill.RepellingShot or Skill.SilentNocturne) || !s.Player.Has(Statuses.Repertoire)); + + // Restrict the entire attack rotation to players when any usable attack can reach one. + // Per-skill fallback alone would let a high-priority skill on ice preempt an available player attack. + public static IReadOnlyList CombatCandidates(CombatSnapshot s, Settings c, IActionAvailability availability) + { + var players = s.Enemies.Where(a => !a.IsObjective).ToArray(); + return players.Any(a => Actions.All.Any(skill => Relevant(skill, s, c) && Allowed(a, c, skill) + && availability.CanUse(skill, a))) ? players : s.Enemies; + } + + public static bool Eligible(Actor a, Skill skill, bool ignoreGuard) => a.Enemy && a.Alive && a.Targetable + && !a.Invulnerable && !a.Status.Overlaps(Statuses.Invincible) + && !a.Has(1307) && !a.Has(1720) + && (ignoreGuard || skill == Skill.EagleEyeShot || !a.Has(Statuses.Guard)); + + public static IEnumerable Ordered(IEnumerable actors, Settings c, ulong current) + { + var first = c.TargetOrder switch + { + TargetOrder.LowestHpRatio => actors.OrderBy(a => (double)a.HpRatio), + TargetOrder.Nearest => actors.OrderBy(a => (double)a.Distance), + TargetOrder.AttackMarker => actors.OrderBy(a => (double)(a.AttackMarker > 0 ? a.AttackMarker : 99)), + _ => actors.OrderBy(a => (double)a.Hp), + }; + return (c.TargetOrder == TargetOrder.AttackMarker ? first.ThenBy(a => a.Hp) : first) + .ThenBy(a => a.Distance).ThenBy(a => a.Id == current ? 0 : 1).ThenBy(a => a.Id); + } + + public static Actor? Select(CombatSnapshot s, Settings c, Skill skill, IActionAvailability availability) + { + var ordered = Ordered(s.Enemies.Where(a => Allowed(a, c, skill)), c, s.CurrentTarget).OrderBy(a => a.IsObjective); + if (!c.StableTargets && !(c.PreferAoe && s.Shapes.ContainsKey(skill))) + return ordered.FirstOrDefault(a => availability.CanUse(skill, a)); + var candidates = ordered.Where(a => availability.CanUse(skill, a)).ToArray(); + var best = candidates.FirstOrDefault(); + if (best == null) return null; + if (c.PreferAoe && s.Shapes.TryGetValue(skill, out var shape)) + { + var ranked = candidates.Where(a => a.IsObjective == best.IsObjective) + .Select(a => (Actor: a, Count: Strategy.AoeCount(s, c, skill, a, shape))).OrderByDescending(x => x.Count).First(); + if (ranked.Count >= c.AoeMinTargets) return ranked.Actor; + } + return Stabilize(s, c, best, candidates); + } + + public static Actor? Stabilize(CombatSnapshot s, Settings c, Actor? best, IEnumerable candidates) + { + if (best == null || !c.StableTargets || s.TargetAge >= c.TargetHoldSeconds) return best; + var current = candidates.FirstOrDefault(a => a.Id == s.CurrentTarget); + if (current == null || current.IsObjective != best.IsObjective) return best; + var majorImprovement = c.TargetOrder switch + { + TargetOrder.LowestHp => (long)current.Hp - best.Hp >= c.TargetHpMargin, + TargetOrder.LowestHpRatio => current.HpRatio - best.HpRatio >= .05, + TargetOrder.Nearest => current.Distance - best.Distance >= 3, + _ => best.AttackMarker > 0 && (current.AttackMarker == 0 || best.AttackMarker < current.AttackMarker), + }; + return majorImprovement ? best : current; + } + + public static Actor? EagleFinisher(CombatSnapshot s, Settings c, IActionAvailability availability) => + !c.AutoAttack || !c.Enabled(Skill.EagleEyeShot) || !s.Player.Has(Statuses.RoleEagle) ? null + : s.Enemies.Where(a => !a.IsObjective && Eligible(a, Skill.EagleEyeShot, c.IgnoreEnemyGuard) + && a.EffectiveHp <= (ulong)c.EagleFinisherHp) + .OrderBy(a => a.EffectiveHp).ThenBy(a => a.Distance) + .ThenBy(a => a.Id == s.CurrentTarget ? 0 : 1).ThenBy(a => a.Id) + .FirstOrDefault(a => availability.CanUse(Skill.EagleEyeShot, a)); + + // Readiness checks resources/cooldowns but ignores execution locks. Selecting a target never sends an action + // or retargets an action that the game has already accepted. + public static Actor? PlanTarget(CombatSnapshot s, Settings c, IActionAvailability readiness, ITargetProbe probe) + { + if (!c.AutoAttack || s.ManualControl || !s.Player.Alive || s.Player.Has(Statuses.Guard) || s.GuardPending) return null; + var finisher = EagleFinisher(s, c, readiness); + if (finisher != null) return finisher; + var ordered = Ordered(s.Enemies, c, s.CurrentTarget).OrderBy(a => a.IsObjective).ToArray(); + // Prefer the same usable player/objective pool as dispatch, avoiding display flicker back to + // a player whose only in-range attack is unavailable while actually firing at an objective. + var ready = ordered.FirstOrDefault(a => Actions.All.Any(skill => Relevant(skill, s, c) + && Allowed(a, c, skill) && readiness.CanUse(skill, a))); + if (ready != null) return Stabilize(s, c, ready, ordered.Where(a => Actions.All.Any(skill => Relevant(skill, s, c) + && Allowed(a, c, skill) && readiness.CanUse(skill, a)))); + return ordered.FirstOrDefault(a => Actions.All.Any(skill => + Relevant(skill, s, c) && Allowed(a, c, skill) + && (skill != Skill.EagleEyeShot || readiness.CanUse(skill, a)) + && probe.CanTarget(skill, a))); + } + + // Threats deliberately do not use attack eligibility, Guard, line of sight, or invincibility filters. + public static bool HasNearbyThreat(CombatSnapshot s) => s.Enemies.Any(a => (!a.IsObjective || a.ThreateningObjective) + && a.Enemy && a.Alive && a.Distance <= 30); +} diff --git a/Core/RequestGate.cs b/Core/RequestGate.cs new file mode 100644 index 0000000..2ea529f --- /dev/null +++ b/Core/RequestGate.cs @@ -0,0 +1,44 @@ +namespace BardFrontline.Core; + +// One outstanding native request. Cooldown changes alone are insufficient for a cleanse: wait for acknowledgement. +public sealed class RequestGate +{ + public ActionDecision? Pending { get; private set; } + private double sentAt; + private ushort beforeSequence; + private bool sequenceObserved; + private ushort requestSequence; + private readonly Dictionary<(Skill, ulong), double> failures = []; + public string Reason { get; private set; } = ""; + + public void Submitted(ActionDecision decision, double now, ushort before, ushort after) + { + Pending = decision; sentAt = now; beforeSequence = before; + sequenceObserved = after != before; requestSequence = after; Reason = "等待动作确认"; + } + public bool Update(double now, ushort used, ushort handled, bool guardActive, bool cleansingStateChanged, bool ownedCastInProgress = false) + { + if (Pending == null) return false; + if (!sequenceObserved && used != beforeSequence) { sequenceObserved = true; requestSequence = used; } + var acknowledged = sequenceObserved && unchecked((short)(handled - requestSequence)) >= 0; + var elapsed = now - sentAt; + var release = Pending.Skill switch + { + Skill.Guard => guardActive || acknowledged && elapsed >= .5, + Skill.Purify or Skill.WardensPaean => cleansingStateChanged || acknowledged && elapsed >= .5, + _ => acknowledged && elapsed >= .1, + }; + if (release) { Pending = null; Reason = ""; return false; } + if (ownedCastInProgress) return true; + if (elapsed < 2) return true; + Fail(Pending, now, "动作确认超时,短暂退避"); Pending = null; + return false; + } + public bool IsBlocked(Skill s, ulong id, double now) => failures.TryGetValue((s, id), out var until) && until > now; + public void Fail(ActionDecision d, double now, string reason) + { + failures[(d.Skill, d.TargetId)] = now + .5; Reason = reason; + foreach (var key in failures.Where(x => x.Value <= now).Select(x => x.Key).ToArray()) failures.Remove(key); + } + public void Reset() { Pending = null; failures.Clear(); Reason = ""; } +} diff --git a/Core/Rotation.cs b/Core/Rotation.cs new file mode 100644 index 0000000..73ed16d --- /dev/null +++ b/Core/Rotation.cs @@ -0,0 +1,112 @@ +namespace BardFrontline.Core; + +public sealed class Rotation +{ + private static readonly (Skill Skill, uint Status)[] TimedAttacks = + [(Skill.PitchPerfect, Statuses.Repertoire), (Skill.BlastArrow, Statuses.BlastReady), (Skill.EncoreOfLight, Statuses.EncoreReady)]; + public static bool IsExpiring(Skill skill, Actor self) => TimedAttacks.Any(p => p.Skill == skill && self.Remaining(p.Status) <= 3); + + public ActionDecision? Decide(CombatSnapshot s, Settings c, IActionAvailability availability, bool emergencyOnly = false) + { + if (s.ManualControl || !s.Player.Alive || s.Player.Has(Statuses.Guard) || s.GuardPending) return null; + var attackSnapshot = s; + ActionDecision? Use(Skill skill, Actor target, string why, bool emergency = false) => + Strategy.PolicyReason(skill, s, c) == null && availability.CanUse(skill, target) ? new(skill, target.Id, why, emergency) : null; + ActionDecision? Attack(Skill skill) => !c.AutoAttack || Strategy.PolicyReason(skill, s, c) != null ? null + : TargetSelector.Select(attackSnapshot, c, skill, availability) is { } t ? new(skill, t.Id, "RSR 攻击优先级") : null; + var self = s.Player; + ActionDecision? d; + if (!s.CleansePending && Statuses.Controls.Any(self.Has)) + { + if ((d = Use(Skill.WardensPaean, self, "自身解控", true)) != null) return d; + if (Statuses.Controls.Any(id => self.Has(id) && !c.DisabledPurifyStatuses.Contains(id)) + && (d = Use(Skill.Purify, self, "净化控制", true)) != null) return d; + } + if (s.AliveSeconds >= 5) + { + if (s.InCombat && Strategy.GuardNeeded(s, c) + && (d = Use(Skill.Guard, self, Strategy.FocusDanger(s, c) ? "集火 / 快速掉血防御" : "低血量防御", true)) != null) return d; + if ((long)self.MaxHp - self.Hp >= Strategy.HealDeficit(s, c) + && (d = Use(Skill.Recuperate, self, "恢复生命", true)) != null) return d; + } + if (emergencyOnly) return null; + if (c.HelpParty && !s.CleansePending) + { + foreach (var member in s.Party.Where(a => a.Id != self.Id && a.Alive && a.Targetable && Statuses.Controls.Any(a.Has)) + .OrderBy(a => a.HpRatio).ThenBy(a => a.Distance).ThenBy(a => a.Id)) + if ((d = Use(Skill.WardensPaean, member, "队友解控")) != null) return d; + } + if (s.AliveSeconds >= 5 && !s.Moving && !TargetSelector.HasNearbyThreat(s) + && (self.HpRatio * 100 <= c.ElixirPercent || s.MaxMp > 0 && (double)s.Mp / s.MaxMp * 100 <= c.ElixirPercent) + && (d = Use(Skill.Elixir, self, "安全补给")) != null) return d; + + // Finishers are attack decisions: survival always comes first. Guard is bypassed by Eagle itself. + if (TargetSelector.EagleFinisher(s, c, availability) is { } finish) + return new(Skill.EagleEyeShot, finish.Id, $"优先收割:有效 HP {finish.EffectiveHp:N0}"); + + if (c.AutoAttack) + { + attackSnapshot = s with { Enemies = TargetSelector.CombatCandidates(s, c, availability) }; + foreach (var proc in TimedAttacks.Where(p => IsExpiring(p.Skill, self)).OrderBy(p => self.Remaining(p.Status))) + if ((d = Attack(proc.Skill)) != null) return d with { Reason = "优先消耗即将过期的技能状态" }; + // Consume the LB follow-up at the next legal action opportunity. Leaving it behind + // every ready weaponskill and proc generator can starve it throughout the LB buff. + if (self.Has(Statuses.EncoreReady) && (d = Attack(Skill.EncoreOfLight)) != null) + return d with { Reason = "优先释放极限技后续:英雄的安可" }; + } + + // Build the weapon choice first. Abilities weave only when the execution adapter permits it. + ActionDecision? weapon = null; + if (c.AutoAttack) + { + if (!self.Has(Statuses.March) && !self.Has(Statuses.BlastReady)) weapon = Attack(Skill.ApexArrow); + if (c.Burst == BurstStrategy.Pressure) + { + // Spend ready follow-ups before draining multiple Harmonic charges. + if (self.Has(Statuses.Repertoire)) weapon ??= Attack(Skill.PitchPerfect); + if (self.Has(Statuses.BlastReady)) weapon ??= Attack(Skill.BlastArrow); + } + weapon ??= Attack(Skill.HarmonicArrow); + if (self.Has(Statuses.Repertoire)) weapon ??= Attack(Skill.PitchPerfect); + if (self.Has(Statuses.BlastReady)) weapon ??= Attack(Skill.BlastArrow); + if (!self.Has(Statuses.BlastReady)) weapon ??= Attack(Skill.ApexArrow); + if (!self.Has(Statuses.Repertoire)) weapon ??= Attack(Skill.PowerfulShot); + } + // Target existence is independent of recast/resources; checked by the adapter via the probe interface. + var hasEnemy = s.Enemies.Any(a => Actions.All.Any(skill => TargetSelector.Relevant(skill, s, c) + && TargetSelector.Allowed(a, c, skill) + && (availability is ITargetProbe probe ? probe.CanTarget(skill, a) : true))); + if (c.AutoAttack && s.InCombat && hasEnemy) + { + if (self.Has(Statuses.RoleBravery) && !self.Has(Statuses.Bravery) + && (d = Use(Skill.Bravery, self, "职能增益")) != null) return d; + if (self.Has(Statuses.RoleDervish) && !self.Has(Statuses.Dervish) + && (d = Use(Skill.Dervish, self, "职能增益")) != null) return d; + if (s.LimitFull && !self.Has(Statuses.Fantasia) && !self.Has(Statuses.EncoreReady) + && (d = Use(Skill.FinalFantasia, self, "极限技已满")) != null) return d; + } + // Pressure spends offensive cooldowns before Harmonic/filler, but keeps ready arrow follow-ups. + // Repelling remains in its original fallback position so this mode does not force extra retreats. + if (c.AutoAttack && c.Burst == BurstStrategy.Pressure + && (weapon == null || weapon.Skill is Skill.HarmonicArrow or Skill.PowerfulShot)) + { + if (c.EagleStrategy == EagleStrategy.FinisherFirst && (d = Attack(Skill.EagleEyeShot)) != null) + return d with { Reason = "主动爆发:提前使用鹰眼" }; + if ((d = Attack(Skill.SilentNocturne)) != null) + return d with { Reason = "主动爆发:沉默衔接完美音调" }; + } + // Normal mode preserves ready weapons over offensive weaving to prevent GCD starvation. + if (weapon != null) return weapon; + if (!self.Has(Statuses.Repertoire)) + { + if ((d = Attack(Skill.RepellingShot)) != null) return d; + if ((d = Attack(Skill.SilentNocturne)) != null) return d; + } + if (c.EagleStrategy == EagleStrategy.FinisherFirst && self.Has(Statuses.RoleEagle) + && (d = Attack(Skill.EagleEyeShot)) != null) return d; + if (s.Moving && !hasEnemy && !self.Has(Statuses.Sprint)) return Use(Skill.Sprint, self, "移动冲刺"); + return null; + } +} + +public interface ITargetProbe { bool CanTarget(Skill skill, Actor target); } diff --git a/Core/Strategy.cs b/Core/Strategy.cs new file mode 100644 index 0000000..7b76250 --- /dev/null +++ b/Core/Strategy.cs @@ -0,0 +1,89 @@ +using System.Numerics; +namespace BardFrontline.Core; + +public sealed record AoeShape(bool Line, float Radius, float HalfWidth = 2); + +public static class Strategy +{ + public static int FocusCount(CombatSnapshot s) => s.Enemies.Count(a => a.Enemy && a.Alive && a.Distance <= 30 + && (!a.IsObjective || a.ThreateningObjective) && (a.TargetId == s.Player.Id || a.CastTargetId == s.Player.Id)); + public static bool FocusDanger(CombatSnapshot s, Settings c) => c.FocusDefense && s.InCombat + && (FocusCount(s) >= c.FocusAttackers || s.RecentHpLossRatio >= .25); + public static bool GuardNeeded(CombatSnapshot s, Settings c) => s.Player.HpRatio * 100 <= c.GuardHpPercent + || FocusDanger(s, c) && s.Player.HpRatio * 100 <= c.FocusGuardPercent; + public static int HealDeficit(CombatSnapshot s, Settings c) => FocusDanger(s, c) + ? Math.Min(c.RecuperateMissingHp, 6000) : c.RecuperateMissingHp; + + public static string? HoldReason(Skill skill, CombatSnapshot s, Settings c) + { + if (skill == Skill.RepellingShot && c.ManualRepelling) return "后跳由玩家手动释放"; + var burstSkill = skill is Skill.HarmonicArrow or Skill.Bravery or Skill.Dervish or Skill.FinalFantasia; + if (!burstSkill || c.Burst is BurstStrategy.Normal or BurstStrategy.Pressure || s.BurstWindow) return null; + if (c.Burst == BurstStrategy.ManualWindow) return "保留爆发:等待手动开启爆发窗口"; + if (skill != Skill.HarmonicArrow || s.HarmonicCharges >= c.BurstCharges || s.HarmonicReadySeconds >= c.BurstMaxHoldSeconds + || s.Player.Has(Statuses.Bravery) || s.Player.Has(Statuses.Fantasia) + || s.Enemies.Any(a => !a.IsObjective && TargetSelector.Allowed(a, c, skill) && a.Distance <= 25 && a.EffectiveHp <= 12000)) return null; + return $"等待爆发增益 / {c.BurstCharges} 充能(最多 {c.BurstMaxHoldSeconds:F1}s)"; + } + + public static int AoeCount(CombatSnapshot s, Settings c, Skill skill, Actor center, AoeShape shape) + { + var origin = new Vector2(s.Player.Position.X, s.Player.Position.Z); + var aim = new Vector2(center.Position.X, center.Position.Z); + var delta = aim - origin; + var direction = delta.LengthSquared() > .001f ? Vector2.Normalize(delta) : Vector2.Zero; + return s.Enemies.Count(a => + { + if (!TargetSelector.Allowed(a, c, skill) || Math.Abs(a.Position.Y - center.Position.Y) > 5) return false; + var point = new Vector2(a.Position.X, a.Position.Z); + if (!shape.Line) return Vector2.Distance(point, aim) <= shape.Radius + a.HitboxRadius; + if (direction == Vector2.Zero) return a.Id == center.Id; + var along = Vector2.Dot(point - origin, direction); + return along >= 0 && along <= shape.Radius + a.HitboxRadius + && Math.Abs(direction.X * (point.Y - origin.Y) - direction.Y * (point.X - origin.X)) <= shape.HalfWidth + a.HitboxRadius; + }); + } + + // Shared by execution and diagnostics: a skill not visited due to an earlier return still gets a current policy reason. + public static string? PolicyReason(Skill skill, CombatSnapshot s, Settings c) + { + if (!c.Enabled(skill)) return "技能已关闭"; + if (s.ManualControl) return "手动接管:暂停自动动作"; + if (!s.Player.Alive || s.GuardPending || s.Player.Has(Statuses.Guard)) return "死亡 / 自身防御,暂停自动动作"; + if (!c.AutoAttack && (Actions.IsHostile(skill) || skill is Skill.FinalFantasia or Skill.Bravery or Skill.Dervish)) return "自动攻击已关闭"; + var self = s.Player; + var status = skill switch { Skill.PitchPerfect => Statuses.Repertoire, Skill.BlastArrow => Statuses.BlastReady, + Skill.EncoreOfLight => Statuses.EncoreReady, Skill.EagleEyeShot => Statuses.RoleEagle, + Skill.Bravery => Statuses.RoleBravery, Skill.Dervish => Statuses.RoleDervish, _ => 0u }; + if (status != 0 && !self.Has(status)) return $"缺少前置状态 / 职能装备 ({status})"; + if (skill == Skill.PowerfulShot && self.Has(Statuses.Repertoire) || skill == Skill.ApexArrow && self.Has(Statuses.BlastReady)) return "技能已变换为后续技能"; + if (skill is Skill.RepellingShot or Skill.SilentNocturne && self.Has(Statuses.Repertoire)) return "已有完美音调,避免覆盖"; + if (skill is Skill.Guard or Skill.Recuperate or Skill.Elixir && s.AliveSeconds < 5) return "复活后 5 秒保护"; + if (skill == Skill.Guard && (!s.InCombat || !GuardNeeded(s, c))) return "未达到防御阈值 / 集火条件"; + if (skill == Skill.Recuperate && (long)self.MaxHp - self.Hp < HealDeficit(s, c)) return "缺失 HP 未达到自疗阈值"; + if (skill == Skill.Purify && (s.CleansePending || !Statuses.Controls.Any(id => self.Has(id) && !c.DisabledPurifyStatuses.Contains(id)))) return "无启用的净化控制 / 正在等待解控"; + if (skill == Skill.WardensPaean && (s.CleansePending || !Statuses.Controls.Any(self.Has) + && (!c.HelpParty || !s.Party.Any(a => a.Alive && a.Targetable && Statuses.Controls.Any(a.Has))))) return "无礼赞解控目标 / 正在等待解控"; + if (skill == Skill.Elixir && (s.Moving || TargetSelector.HasNearbyThreat(s) + || self.HpRatio * 100 > c.ElixirPercent && (s.MaxMp == 0 || (double)s.Mp / s.MaxMp * 100 > c.ElixirPercent))) return "补给条件不满足:资源 / 移动 / 周围威胁"; + if (skill is Skill.Bravery or Skill.Dervish or Skill.FinalFantasia && (!s.InCombat || !s.Enemies.Any(a => a.Enemy && a.Alive))) return "进攻增益需要战斗和敌人"; + if (skill == Skill.Bravery && self.Has(Statuses.Bravery) || skill == Skill.Dervish && self.Has(Statuses.Dervish)) return "增益已存在"; + if (skill == Skill.FinalFantasia && (!s.LimitFull || self.Has(Statuses.Fantasia) || self.Has(Statuses.EncoreReady))) return "极限量谱未满 / 增益或安可尚未消耗"; + if (skill == Skill.Sprint && (!s.Moving || self.Has(Statuses.Sprint))) return "未移动 / 冲刺已存在"; + return HoldReason(skill, s, c); + } +} + +public sealed class HealthPressure +{ + private readonly Queue<(double Time, uint Loss)> losses = new(); + private uint? lastHp; + public double Observe(double now, uint hp, uint maxHp) + { + if (lastHp is { } prior && prior > hp) losses.Enqueue((now, prior - hp)); + lastHp = hp; + while (losses.TryPeek(out var item) && now - item.Time > 2) losses.Dequeue(); + return maxHp == 0 ? 0 : losses.Sum(x => (double)x.Loss) / maxHp; + } + public void Reset() { losses.Clear(); lastHp = null; } +} diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000..0b69576 --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,5 @@ + + + + + diff --git a/Plugin.cs b/Plugin.cs new file mode 100644 index 0000000..59ca83e --- /dev/null +++ b/Plugin.cs @@ -0,0 +1,220 @@ +using System.Numerics; +using BardFrontline.Core; +using BardFrontline.Runtime; +using Dalamud.Bindings.ImGui; +using Dalamud.Game.Command; +using Dalamud.Plugin; +using Dalamud.Plugin.Services; + +namespace BardFrontline; + +public sealed class Plugin : IDalamudPlugin +{ + private readonly IDalamudPluginInterface pi; + private readonly ICommandManager commands; + private readonly IFramework framework; + private readonly Configuration configuration; + private readonly CombatRuntime runtime; + private bool settingsOpen; + private string? frozenDiagnostic; + + public Plugin(IDalamudPluginInterface pluginInterface, ICommandManager commandManager, + IFramework framework, IObjectTable objects, IClientState client, ICondition condition, + IDutyState duty, IDataManager data, ITargetManager targets, IPluginLog log) + { + pi = pluginInterface; commands = commandManager; this.framework = framework; + configuration = pi.GetPluginConfig() as Configuration ?? new(); + configuration.Settings ??= new(); configuration.Settings.Normalize(); + runtime = new(objects, client, condition, duty, data, targets, log, configuration.Settings); + commands.AddHandler("/bardpvp", new CommandInfo(OnCommand) + { HelpMessage = "设置;on/off/toggle 开关;manual/auto 手动接管/恢复;burst 开启10秒爆发,burst off 结束;backstep 切换后跳手动/自动。" }); + pi.UiBuilder.Draw += Draw; + pi.UiBuilder.OpenConfigUi += OpenSettings; + pi.UiBuilder.OpenMainUi += OpenSettings; + framework.Update += runtime.Update; + } + + private void OpenSettings() => settingsOpen = true; + private void OnCommand(string command, string args) + { + lock (runtime.SyncRoot) HandleCommand(args); + } + private void HandleCommand(string args) + { + switch (args.Trim().ToLowerInvariant()) + { + case "on": runtime.Enabled = true; break; + case "off": runtime.Enabled = false; break; + case "toggle": runtime.Enabled = !runtime.Enabled; break; + case "manual": runtime.ManualPaused = true; break; + case "auto": runtime.ManualPaused = false; break; + case "burst": runtime.StartBurst(); break; + case "burst off": runtime.StopBurst(); break; + case "backstep": configuration.Settings.ManualRepelling = !configuration.Settings.ManualRepelling; Save(); break; + case "backstep manual": configuration.Settings.ManualRepelling = true; Save(); break; + case "backstep auto": configuration.Settings.ManualRepelling = false; Save(); break; + default: settingsOpen = true; break; + } + } + private void Save() { configuration.Settings.Normalize(); pi.SavePluginConfig(configuration); } + private void RunToggle() + { + var running = runtime.Enabled; + if (ImGui.Checkbox("自动运行##master", ref running)) runtime.Enabled = running; + ImGui.TextWrapped(runtime.State); + ImGui.TextWrapped($"模式:{runtime.ModeText}"); + ImGui.TextWrapped(runtime.ControlText); + if (ImGui.Button(runtime.ManualPaused ? "恢复自动控制" : "手动接管")) runtime.ManualPaused = !runtime.ManualPaused; + ImGui.SameLine(); if (ImGui.Button("爆发 10 秒")) runtime.StartBurst(); + } + private void Draw() + { + lock (runtime.SyncRoot) DrawLocked(); + } + private void DrawLocked() + { + var c = configuration.Settings; + if (c.ShowOverlay) + { + ImGui.SetNextWindowSize(new Vector2(330, 0), ImGuiCond.FirstUseEver); + if (ImGui.Begin("诗人前线##BardFrontlineHUD", ImGuiWindowFlags.AlwaysAutoResize)) + { + RunToggle(); + ImGui.TextWrapped($"目标:{runtime.TargetText}"); + ImGui.TextWrapped($"下一技能:{runtime.NextText}"); + if (ImGui.Button("设置")) settingsOpen = true; + } + ImGui.End(); + } + if (!settingsOpen) return; + ImGui.SetNextWindowSize(new Vector2(570, 680), ImGuiCond.FirstUseEver); + if (ImGui.Begin("Bard Frontline 设置", ref settingsOpen)) + { + RunToggle(); + ImGui.TextWrapped("支持纷争前线和烈羽争锋;驾驶机甲时暂停诗人循环。每次加载总开关默认关闭。使用 /bardpvp off 可随时停止。后跳会改变角色位置。关闭后不再提交新动作,游戏已经接受的动作仍由游戏完成。"); + ImGui.Separator(); + Check("自动攻击循环与进攻增益", c.AutoAttack, v => c.AutoAttack = v); + Check("无有效玩家时攻击战场目标", c.AttackObjectives, v => c.AttackObjectives = v); + ImGui.TextWrapped("普通玩家优先;无可用玩家攻击时,攻击冰块、敌方兵线、建筑和机甲等合法目标。鹰眼仅对未驾驶机甲的玩家使用。"); + Check("无视敌方 Guard", c.IgnoreEnemyGuard, v => c.IgnoreEnemyGuard = v); + ImGui.TextWrapped("允许向 Guard 目标释放攻击;不改变实际伤害减免。自身 Guard 期间始终暂停自动技能。"); + Check("礼赞帮助小队成员解除控制", c.HelpParty, v => c.HelpParty = v); + Check("显示状态浮窗", c.ShowOverlay, v => c.ShowOverlay = v); + var order = (int)c.TargetOrder; + if (ImGui.Combo("选敌排序", ref order, "当前 HP 最低\0HP 比例最低\0距离最近\0攻击标记 1–8 优先\0")) + { c.TargetOrder = (TargetOrder)order; Save(); } + var guard = c.GuardHpPercent; + if (ImGui.SliderFloat("防御 HP %", ref guard, 1, 100, "%.0f%%")) { c.GuardHpPercent = guard; Save(); } + var heal = c.RecuperateMissingHp; + if (ImGui.InputInt("自疗所需缺失 HP", ref heal, 1000, 5000)) { c.RecuperateMissingHp = heal; Save(); } + var elixir = c.ElixirPercent; + if (ImGui.SliderFloat("补给 HP / MP %", ref elixir, 1, 100, "%.1f%%")) { c.ElixirPercent = elixir; Save(); } + ImGui.TextWrapped("补给还要求静止、30 米内无敌方玩家;烈羽争锋的敌方机甲、兵线和建筑也计入威胁。碎冰战冰块不计入。防御、自疗、补给在复活 5 秒后启用。"); + if (ImGui.CollapsingHeader("Eagle Eye Shot 收割", ImGuiTreeNodeFlags.DefaultOpen)) + { + var strategy = (int)c.EagleStrategy; + if (ImGui.Combo("鹰眼使用策略", ref strategy, "优先收割,否则按原循环\0严格保留,只用于收割\0")) + { c.EagleStrategy = (EagleStrategy)strategy; Save(); } + var hp = c.EagleFinisherHp; + if (ImGui.InputInt("收割有效 HP 上限", ref hp, 500, 2000)) { c.EagleFinisherHp = hp; Save(); } + ImGui.TextWrapped("有效 HP = 当前 HP + 吸收护盾。范围内满足阈值的敌人按有效 HP 最低优先,鹰眼抢在普通输出与极限技前释放;生存技能仍优先。阈值是筛选条件,不是精确伤害预测;可根据实战伤害调整。需装备鹰眼职能技能。"); + } + if (ImGui.CollapsingHeader("技能独立开关", ImGuiTreeNodeFlags.DefaultOpen)) + foreach (var skill in Actions.All) + { + var active = c.Enabled(skill); + if (ImGui.Checkbox($"{Actions.Name(skill)}##skill{(uint)skill}", ref active)) + { if (active) c.DisabledSkills.Remove(skill); else c.DisabledSkills.Add(skill); Save(); } + } + if (ImGui.CollapsingHeader("选敌稳定与手动接管")) + { + Check("启用选敌稳定", c.StableTargets, v => c.StableTargets = v); + Float("目标保持秒数", c.TargetHoldSeconds, 0, 3, v => c.TargetHoldSeconds = v); + Integer("切换 HP 优势", c.TargetHpMargin, 0, 20000, v => c.TargetHpMargin = v); + ImGui.TextWrapped("默认仍即时选择最低当前 HP。开启后短时间保持有效目标;明显更优的目标、目标失效或鹰眼收割可立即切换。"); + Check("检测手动切目标 / 其他来源动作后暂时接管", c.DetectManualControl, v => c.DetectManualControl = v); + Float("自动接管持续秒数", c.ManualControlSeconds, .5f, 15, v => c.ManualControlSeconds = v); + ImGui.TextWrapped("接管期间暂停所有自动动作和选敌,包括生存技能。按钮或 /bardpvp manual 持续接管,auto 恢复。自动检测无法区分手动动作与其他插件动作。"); + } + if (ImGui.CollapsingHeader("穿插、爆发与范围攻击")) + { + Check("自适应穿插预算", c.AdaptiveWeaving, v => c.AdaptiveWeaving = v); + ImGui.TextWrapped(runtime.TimingText); + ImGui.TextWrapped("根据本插件瞬发动作的确认及动画锁样本估算;不足 3 个样本时使用原来的 0.65 秒。不会绕过游戏锁。"); + var burst = (int)c.Burst; + if (ImGui.Combo("爆发策略", ref burst, "原循环,不保留\0和弦箭等待增益 / 充能\0手动爆发窗口\0主动爆发\0")) + { c.Burst = (BurstStrategy)burst; Save(); } + if (c.Burst == BurstStrategy.Pressure) + ImGui.TextWrapped("优先消耗已就绪的完美音调 / 爆破箭,提前使用鹰眼和沉默。瞬发输出及进攻增益不额外等待穿插空间,可能短暂推迟普攻;仍遵守鹰眼保留策略、游戏动作锁和技能条件。生存及后跳设置不变。"); + Integer("和弦目标充能", c.BurstCharges, 1, 4, v => c.BurstCharges = v); + Float("等待增益最多秒数", c.BurstMaxHoldSeconds, .5f, 10, v => c.BurstMaxHoldSeconds = v); + ImGui.TextWrapped("手动窗口仅保留和弦箭、职能增益及极限技;安可、鹰眼收割和生存不等待。按钮或 /bardpvp burst 开启 10 秒窗口。"); + if (ImGui.Button("结束爆发窗口")) runtime.StopBurst(); + Check("范围技能优先更多有效目标", c.PreferAoe, v => c.PreferAoe = v); + Integer("范围选敌最少命中数", c.AoeMinTargets, 2, 10, v => c.AoeMinTargets = v); + ImGui.TextWrapped("仍优先玩家。按客户端范围和目标位置估算圆形 / 直线覆盖;未达到数量时回到原排序。命中数是估算,不保证实际命中。"); + } + if (ImGui.CollapsingHeader("战场目标分类")) + { + foreach (var kind in Enum.GetValues()) + { + var attack = !c.DisabledObjectives.Contains(kind); + if (ImGui.Checkbox($"攻击{Battlefield.KindName(kind)}", ref attack)) + { if (attack) c.DisabledObjectives.Remove(kind); else c.DisabledObjectives.Add(kind); Save(); } + } + ImGui.TextWrapped("分类开关受战场目标总开关控制;全开保持原有偏好。未知类型归入其他目标。关闭攻击某类不会忽略它的补给威胁。"); + } + if (ImGui.CollapsingHeader("集火生存与后跳")) + { + Check("启用集火 / 快速掉血防御", c.FocusDefense, v => c.FocusDefense = v); + Integer("集火指向人数", c.FocusAttackers, 2, 10, v => c.FocusAttackers = v); + Float("集火时防御 HP %", c.FocusGuardPercent, 1, 80, v => c.FocusGuardPercent = v); + ImGui.TextWrapped("附近敌人目标或读条指向自己达到人数,或 2 秒累计掉血达到最大 HP 的 25% 时,按集火阈值防御,并将自疗缺失阈值降低到最多 6000。目标指向只是风险信号。"); + Check("后跳改为手动释放", c.ManualRepelling, v => c.ManualRepelling = v); + ImGui.TextWrapped("默认保持自动后跳。可用 /bardpvp backstep 随时切换;手动模式由你按游戏技能,不影响其他循环。"); + } + if (ImGui.CollapsingHeader("净化触发控制")) + { + string[] labels = ["眩晕", "加重", "止步", "沉默", "深度冻结", "自然的奇迹"]; + for (var i = 0; i < Statuses.Controls.Length; i++) + { + var id = Statuses.Controls[i]; var active = !c.DisabledPurifyStatuses.Contains(id); + if (ImGui.Checkbox(labels[i], ref active)) + { if (active) c.DisabledPurifyStatuses.Remove(id); else c.DisabledPurifyStatuses.Add(id); Save(); } + } + } + if (ImGui.CollapsingHeader("运行诊断")) + { + if (ImGui.Button("复制诊断")) ImGui.SetClipboardText(frozenDiagnostic ?? runtime.DiagnosticText()); + ImGui.SameLine(); + if (ImGui.Button(frozenDiagnostic == null ? "冻结快照" : "恢复实时")) + frozenDiagnostic = frozenDiagnostic == null ? runtime.DiagnosticText() : null; + ImGui.TextWrapped($"最近请求:{runtime.LastAction}"); + if (frozenDiagnostic != null) ImGui.TextWrapped(frozenDiagnostic); + else + { + ImGui.TextWrapped(runtime.AuditSummary); + ImGui.TextWrapped("每 0.25 秒重建完整快照。就绪检查与执行锁分开;复制内容含资源、状态、候选目标和近期事件。"); + foreach (var (skill, reason) in runtime.Diagnostics) ImGui.TextWrapped($"{Actions.Name(skill)}:{reason}"); + } + } + } + ImGui.End(); + } + private void Check(string label, bool value, Action apply) + { if (ImGui.Checkbox(label, ref value)) { apply(value); Save(); } } + private void Float(string label, float value, float min, float max, Action apply) + { if (ImGui.SliderFloat(label, ref value, min, max, "%.2f")) { apply(value); Save(); } } + private void Integer(string label, int value, int min, int max, Action apply) + { if (ImGui.SliderInt(label, ref value, min, max)) { apply(value); Save(); } } + + public void Dispose() + { + framework.Update -= runtime.Update; + commands.RemoveHandler("/bardpvp"); + pi.UiBuilder.Draw -= Draw; + pi.UiBuilder.OpenConfigUi -= OpenSettings; + pi.UiBuilder.OpenMainUi -= OpenSettings; + runtime.Dispose(); + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b6a660 --- /dev/null +++ b/README.md @@ -0,0 +1,141 @@ +# Bard Frontline + +独立的吟游诗人 PVP Dalamud 插件。支持已开始的纷争前线和烈羽争锋对局,自动选敌、主动攻击、执行诗人循环与生存技能,不依赖 RotationSolverReborn。 + +## 安装和使用 + +1. 将发行包解压到一个固定目录,保留 `BardFrontline.dll` 与 `BardFrontline.json` 在同一目录。 +2. 在 Dalamud 的开发插件设置中添加 `BardFrontline.dll` 的完整路径,保存后在开发插件列表加载它。 +3. 输入 `/bardpvp` 打开中文设置,或使用浮窗的“自动运行”开关。 +4. **每次加载默认关闭自动运行**。`/bardpvp on` 开启,`/bardpvp off` 关闭,`/bardpvp toggle` 切换。 +5. 需要使用 Eagle Eye Shot 时,请在游戏中装备该 PVP 职能技能。诊断页会显示未装备的职能技能。 + +设置在 Dalamud 插件配置中自动保存。关闭设置窗不会停止运行;关闭总开关后不再提交新动作,游戏已经接受的动作仍由游戏完成。 + +本版运行基线为 .NET 10 / x64 / Dalamud API 15。编译引用来自本机 `Hooks\dev` 的成品 DLL;源码目录中的 Dalamud / FFXIVClientStructs 不参与编译。没有游戏版本号白名单;缺失单项技能会跳过并显示原因,缺失核心技能会暂停。 + +### 0.2.1:可选主动爆发 + +针对输出技能释放偏慢,新增 `/bardpvp` →「穿插、爆发与范围攻击」→「爆发策略」→ **主动爆发**。升级保留原策略,需手动选中此项;选择后自动保存,无需反复按爆发按钮。 + +- 已就绪的完美音调和爆破箭优先于连续和弦箭;缺少进行曲时的绝峰箭仍优先。 +- 普通鹰眼和沉默提前到和弦箭及强力射击之前,仍受技能开关、前置状态和原生可用性检查限制。鹰眼“严格保留,只用于收割”仍生效;已有完美音调时不重复用沉默覆盖。 +- 瞬发攻击(后跳除外)、职能增益和极限技不额外预留穿插空间,可能短暂推迟普攻。原生动作确认、动画锁、动作队列、冷却、读条及目标检查继续生效。读条动作不享受这项放宽。 +- 生存阈值、玩家优先、安可及鹰眼收割优先级、后跳设置保留。诊断新增当前爆发 / 鹰眼策略,便于确认实际启用的设置。 + +离线测试验证了决策变化,尚未验证游戏内伤害收益和实际释放节奏。 + +### 0.2.0:六项优化 + +1. **完整决策诊断**:每 0.25 秒生成完整快照,所有技能均给出选中、未满足策略、目标过滤、原生检查、穿插等待或未获优先级的结果。复制内容包括版本、模式、资源、状态、动作队列、待确认动作、目标分类及近期 40 条事件。支持冻结快照再复制;就绪检查忽略正在执行的读条和动画锁,实际提交仍受执行阶段限制。 +2. **选敌稳定与手动接管**:可选 0.6 秒目标保持,当前 HP 优势达到 2000 则立即切换;目标失效、玩家优先及鹰眼收割不受保持限制。其他排序按各自的显著差异判断。自动检测手动切目标或其他来源动作后可接管 3 秒;显式接管持续到手动恢复。接管暂停所有自动动作,包括生存,不取消已经接受的动作。自动检测也会识别其他插件动作,无法区分动作来源。 +3. **自适应穿插**:默认启用。以本插件瞬发动作的确认和动画锁结束为样本,最近 20 个样本的 90 分位加 50ms 作为预算,限制在 0.5–1.25 秒。少于 3 个样本回退到旧值 0.65 秒;读条、超时样本不参与。可关闭以使用固定预算。安可、鹰眼收割和即将过期状态沿用原来的额外预留豁免,但不绕过游戏锁。 +4. **烈羽争锋目标细分**:按 BNpcName ID 和机甲状态区分机甲、兵线、佣兵、防御塔、核心及其他目标,另有冰块分类。每类可独立关闭,默认全部允许,受战场目标总开关控制;未知 ID 归入其他。攻击开关不移除补给威胁,友军仍由原生目标及阵营检查过滤。 +5. **可选爆发与范围策略**:默认原循环不变。和弦箭可等待增益 / 指定充能,默认最多保留 4 秒;手动爆发模式将和弦箭、职能增益、极限技保留到 10 秒爆发窗口,生存、鹰眼和安可不等待。范围策略可按客户端圆形半径、直线长度及宽度估算覆盖数,达到默认 2 个有效目标才改变选敌,仍优先普通玩家。覆盖数仅是几何估算,无法保证移动、地形和服务端判定后的实际命中。 +6. **集火生存和后跳切换**:可选集火判断结合 30 米内敌人的当前目标 / 读条目标及 2 秒累计 HP 损失。默认阈值为 3 名敌人指向自己或累计掉血 25%;危险时 HP ≤40% 可防御,自疗缺失阈值降低至不超过 6000。目标指向是风险信号,并非确定将受到攻击。后跳可以单独切到手动模式,其余循环继续。 + +**默认偏好保留**:最低当前 HP、玩家优先、鹰眼优先收割、原循环爆发方式、自动后跳及原生存阈值不变;稳定选敌、自动接管检测、范围策略和集火防御默认关闭。旧配置缺少的新字段自动使用这些默认值。自适应穿插是默认启用的执行层优化,设置中可以回到固定预算。 + +| 命令 | 用途 | +|---|---| +| `/bardpvp manual` | 持续手动接管 | +| `/bardpvp auto` | 恢复自动控制,不改变总开关 | +| `/bardpvp burst` | 开启 10 秒爆发窗口 | +| `/bardpvp burst off` | 结束爆发窗口 | +| `/bardpvp backstep` | 切换后跳自动 / 手动 | +| `/bardpvp backstep manual` / `auto` | 明确指定后跳模式 | + +浮窗提供接管、恢复和爆发按钮;详细选项位于设置的新增折叠项中。手动后跳由玩家按游戏技能,插件不替玩家判断后退位置是否安全。 + +### 0.1.4:烈羽争锋支持 + +- 通过战场类型识别烈羽争锋(TerritoryIntendedUse 39),保留纷争前线(18);水晶冲突、训练区域和其他内容仍不运行。浮窗及复制诊断显示当前模式。 +- 驾驶机甲时暂停全部自动诗人技能及选敌,清空待处理请求;离开机甲并满足普通运行条件后自动恢复。检测机甲条件、搭乘状态和当前战场机甲数据,不驾驶或操纵机甲技能。 +- 普通玩家优先;无有效玩家攻击时,可攻击敌方兵线、建筑、机甲等客户端允许攻击的战场目标,受原有战场目标开关控制。机甲按战场目标处理,鹰眼不用于机甲收割;礼赞不对机甲使用。 +- 烈羽争锋的敌方战斗 NPC 和机甲均计入 30 米补给威胁,包括暂时不可选中或免疫的敌方单位;关闭战场目标攻击也不会忽略这些威胁。碎冰战的冰块仍不阻止补给。 +- 发行 ZIP 仅包含 DLL 和 JSON。新版模式识别、兵线 / 建筑分类和上机甲 / 下机甲行为尚需实机验证。 + +### 0.1.3:安可释放与和弦箭变体修复 + +- 英雄的安可(Encore of Light,41467)有执行许可且目标合法时,优先于普通武器技能、后跳、沉默和普通鹰眼释放,不再等到状态接近过期。紧急生存、鹰眼收割及其他即将过期的攻击状态仍在其前。 +- 安可不受额外的共享冷却穿插预留限制,避免叠加加速后连续错过释放窗口;仍检查游戏动作锁、读条、冷却、射程及目标状态。 +- 修复和弦箭变为 41465 / 41466 / 41964 时被误判为错误技能的问题;使用客户端返回的合法变体执行原生检查和动作请求,不允许其他未知变换。 +- 目标诊断细分为 Guard、免疫、目标类型、原生拒绝、射程 / 视线错误码等,附检查目标;新增“复制诊断”,包括版本、安可执行许可和最近候选检查。旧版截图中的合并提示不足以确认具体目标拒绝原因,新版仍需游戏内复测。 + +### 0.1.2:战场目标与循环优化 + +- 默认玩家优先:只要有可用攻击能命中敌方玩家,整轮攻击只选择玩家;否则攻击冰块等可攻击的战场目标。目标组内仍按所选 HP / 距离 / 标记排序。设置中可关闭战场目标攻击。 +- 通过战斗 NPC 类型及客户端原生可攻击性识别目标,不依赖名字;排除宠物、召唤物、友军及不可攻击物件。尚需在实际碎冰战确认目标识别。 +- 鹰眼始终只对玩家使用,冰块残血不会消耗鹰眼。补给威胁扫描仍只统计敌方玩家。 +- 完美音调、爆破箭、Encore of Light 的前置状态剩余不超过 3 秒时优先消耗;多个状态即将过期时先用剩余最短的可用技能。生存和鹰眼收割仍在其前。 +- 按实际主冷却组及附加冷却组判断共享冷却,独立冷却的武器技能也检查穿插窗口;忽略已停止计时的冷却残值。即将过期的技能与鹰眼收割可跳过额外穿插预留,但仍遵守客户端动作限制。 + +### 0.1.1:目标响应与鹰眼 Guard 处理 + +- 选敌独立于动作确认和动画锁更新,已有目标每个决策周期重新比较 HP,无需等待当前目标死亡。决策最短间隔由 50ms 降为 20ms,新对象扫描间隔由 100ms 降为 50ms;实际更新间隔受游戏帧率限制。 +- 游戏画面目标可以提前切换,但已经提交的技能仍保持原目标,不会覆盖动作队列或绕过动画锁。手动读条期间不自动切换目标;自身 Guard 期间仍暂停。 +- Guard 不进入通用无敌分类;鹰眼独立检查自身射程及可用性,即使附近只有开启 Guard 的敌人,也不要求先找到普通射击的目标。 +- 鹰眼不可用时,不会因为未来可能收割而让普通攻击一直盯住 Guard 目标。 + +## 默认行为 + +- **普通选敌**:当前 HP 最低,然后距离最近;完全相同时保留当前目标。不把护盾计入普通选敌排序,也不优先标记。可切换为 HP 比例、距离或攻击标记 1–8 排序。 +- **主动开战**:有有效敌人即可攻击,无需手动进入战斗。默认玩家优先,无可用攻击能命中玩家时攻击冰块等战场目标;不会操作需要交互的物件。 +- **技能开关**:各技能均可独立关闭,默认开启;可单独停止攻击和进攻增益,保留共通生存技能。 +- **Guard**:默认避开敌方 Guard。开启“无视敌方 Guard”后允许普通技能攻击,伤害减免仍由游戏决定。Eagle Eye Shot 本身允许攻击 Guard 目标。 +- **自身 Guard**:持续期间停止所有自动动作,避免插件解除防御。此规则不受敌方 Guard 开关影响。 +- **后跳**:默认开启,实际释放会使角色后退;可在技能列表关闭。 +- **范围限制**:非诗人、非纷争前线 / 烈羽争锋、对局未开始或已结束、切区、死亡、上坐骑、驾驶机甲时暂停。没有自动移动、寻路、上下坐骑或操作机甲功能。 + +## Eagle Eye Shot 优先收割 + +默认策略为 **“优先收割,否则按原循环”**:有符合条件的玩家目标时,鹰眼优先于普通输出、进攻增益及极限技。没有符合条件的目标时,鹰眼仍在原本的攻击能力优先级中对玩家使用。两种策略均不向战场 PVE 目标释放鹰眼。 + +收割目标按 **当前 HP + 吸收护盾** 筛选,默认上限 **10,000**;候选中选择有效 HP 最低的敌人,再比较距离及当前目标。鹰眼按自己的客户端射程检查,不受普通射击的较短射程限制。 + +阈值是可调整的经验值,不是精确伤害预测。客户端只提供护盾百分比,插件换算为护盾 HP;实际伤害还会受职业调整、增伤、减伤和状态同步影响。因此,进入阈值不等于保证击杀。可结合实战调整阈值。 + +另有“严格保留,只用于收割”策略,不符合阈值时留住鹰眼。自身生存技能始终优先,自动动作始终尊重游戏的动画锁、读条和动作队列。不会为了鹰眼主动取消正在进行的读条。 + +## 循环 + +普通武器技能优先级参考本地 RSR:缺少 Frontliner's March 时的 Apex Arrow → Harmonic Arrow → Pitch Perfect → Blast Arrow → Apex Arrow → Powerful Shot。技能状态、变换及充能条件不满足时跳过。前置状态剩余不超过 3 秒的 Pitch Perfect / Blast Arrow / Encore of Light 在普通循环和进攻增益之前消耗,避免状态过期;不改变平时的 Harmonic Arrow 充能使用策略。 + +安可有执行许可时优先于普通输出消耗;普通攻击能力顺序为无 Repertoire 时的 Repelling Shot → 无 Repertoire 时的 Silent Nocturne → Eagle Eye Shot。鹰眼收割分支在这些攻击前检查。可用武器技能优先于其余普通能力穿插,临近共享冷却结束时保留窗口;鹰眼收割、安可和即将过期的技能不因这个额外预留而延后。 + +Final Fantasia 在战斗中有有效敌人且极限量谱满时使用;获得 Encore of Light Ready(4312)后,在下一个符合动作条件的时机优先释放安可。只使用实际装备的 Bravery、Dervish 或 Eagle Eye Shot 职能技能。 + +## 生存与补给 + +优先顺序:自身礼赞解控 → 净化 → 防御 → 自疗 → 可选队友礼赞 → 安全补给 → 鹰眼收割 → 即将过期的攻击状态 → 安可 → 增益、极限技及攻击循环。 + +| 功能 | 默认条件 | +|---|---| +| 礼赞 | 自身存在可解除控制;帮助小队成员默认关闭 | +| 净化 | 存在已启用的控制类型,且礼赞不可用 | +| 防御 | 战斗中 HP ≤15% | +| 自疗 | 缺失 HP ≥15,000,MP 和冷却满足客户端要求 | +| 军用圣灵药 | HP 或 MP ≤三分之一,静止且 30 米内无存活敌方玩家;烈羽争锋还检查敌方兵线、建筑和机甲,碎冰战冰块不阻止补给 | +| 冲刺 | 移动中、无有效攻击目标、没有冲刺状态 | + +防御、自疗、补给在登录/复活后等待 5 秒。解控不受此等待限制。没有攻击目标时仍检查生存技能。 + +敌人的 Guard、无敌、暂时不可选中或遮挡不会让它从补给威胁扫描中消失。安全喝药时不会因为自疗刚转好而自行打断;出现威胁或控制且有紧急技能可用时,才取消插件发起的补给读条。普通插件读条可为紧急生存技能取消;手动读条不主动取消。 + +## 构建和测试 + +从本目录运行: + +```powershell +.\build.ps1 +``` + +默认引用目录:`C:\Users\AinaSnow\AppData\Roaming\XIVLauncher\addon\Hooks\dev`,通过 `APPDATA` 自动解析。可覆盖: + +```powershell +.\build.ps1 -HooksDevPath 'D:\Path\To\Hooks\dev' +``` + +项目没有 NuGet 包依赖。`build.ps1` 执行还原、Release 构建、离线测试并在 `dist` 下打包,仅包含 DLL 和 JSON;不会安装到游戏,也不会修改 Hooks 目录。 + +离线测试直接编译生产代码的纯逻辑模块,使用假客户端检查接口。详见 `VERIFICATION.md`,**游戏内验收尚未完成**。 diff --git a/REFERENCES.md b/REFERENCES.md new file mode 100644 index 0000000..72e80aa --- /dev/null +++ b/REFERENCES.md @@ -0,0 +1,26 @@ +# 实现参考 + +独立实现,未链接或随包分发 RSR / Dalamud / FFXIVClientStructs 程序集。 + +本地 RotationSolverReborn 参考点: + +- `RotationSolver/RebornRotations/PVPRotations/Ranged/BRD_Default.PVP.cs`:诗人技能优先级。 +- `RotationSolver.Basic/Rotations/Basic/BardRotation.cs`:技能状态要求和 PVP 技能设置。 +- `RotationSolver.Basic/Rotations/CustomRotation_Actions.cs`:共通技能、职能技能、复活保护与补给条件。 +- `RotationSolver.Basic/Helpers/StatusHelper.cs`:控制列表及无敌状态图标判断。 +- `RotationSolver.Basic/Actions/ActionTargetInfo.cs`、`Helpers/TargetHelper.cs`:目标排序、Guard 过滤与原生目标检查。 +- `RotationSolver.SourceGenerators/Properties/ActionId.resx`、`Status.resx`、`Rotation.resx`:技能/状态 ID 和变换说明。 + +当前工作区内的 Dalamud、FFXIVClientStructs 和 Lumina.Excel schema 仅用于查阅接口与数据字段。实际编译引用用户提供的 Hooks/dev 成品 DLL,引用设为 `Private=false`。 + +技能说明的公开参考:[官方吟游诗人 PVP 指南](https://na.finalfantasyxiv.com/jobguide/bard/#pvp)。本插件使用本地 RSR 优先级和客户端实际可用性,不以网页版本号作运行门禁。 + +与 RSR 优先级的有意差异:鹰眼增加可配置收割优先分支且只对普通玩家使用;玩家优先、无可用玩家攻击时回退到战场目标;优先消耗即将过期的攻击状态;安可优先于普通输出且不受额外穿插预留限制;自身生存高于队友解控;安全补给读条保护;单请求确认与失败退避;只在纷争前线和烈羽争锋运行,驾驶机甲时暂停。 + +和弦箭允许的客户端变体 41464 / 41465 / 41466 / 41964 均来自本地 `ActionId.resx` 的 Harmonic Arrow PVP 条目;不会仅凭技能名字接受任意 ID。 + +烈羽争锋参考:本地 FFXIVClientStructs `TerritoryIntendedUse.RivalWings = 39`、`PlayerState.GetContentValue(1)`(ManeuversArmor)、本地 RSR `Mounted = 1420` 状态条目;模式和单位机制参照[官方烈羽争锋指南](https://actff1.web.sdo.com/project/160810event/page5_2.html)。 + +0.2.0 目标分类参考本地 `BNpcName.resx`:6857–6862 核心 / 防御塔,6863–6868 机甲,6869–6872、7889–7890、14252–14253 兵线,7891–7892、7906 佣兵;4822–4837、4840–4845 冰块。未知 ID 不按显示名猜测,归入其他。 + +范围估算使用客户端 `Action.EffectRange` / `XAxisModifier` 和演员位置 / 碰撞半径,直线宽度计算参考 RSR `ActionTargetInfo.cs`。稳定选敌、爆发保留、集火阈值和自适应预算是本插件的可配置策略,不宣称为游戏的精确伤害或命中预测。 diff --git a/Runtime/CombatRuntime.cs b/Runtime/CombatRuntime.cs new file mode 100644 index 0000000..1fdca82 --- /dev/null +++ b/Runtime/CombatRuntime.cs @@ -0,0 +1,528 @@ +using System.Diagnostics; +using System.Numerics; +using BardFrontline.Core; +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.DutyState; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game; +using FFXIVClientStructs.FFXIV.Client.Game.UI; +using Lumina.Excel.Sheets; +using NativeCharacter = FFXIVClientStructs.FFXIV.Client.Game.Character.Character; +using NativeObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject; +using SheetAction = Lumina.Excel.Sheets.Action; + +namespace BardFrontline.Runtime; + +internal sealed unsafe class CombatRuntime : IActionAvailability, ITargetProbe, IDisposable +{ + private const double DecisionInterval = .02; + private const double DiscoveryInterval = .05; + public object SyncRoot { get; } = new(); + private readonly IObjectTable objects; + private readonly IClientState client; + private readonly ICondition condition; + private readonly IDutyState duty; + private readonly IDataManager data; + private readonly ITargetManager targets; + private readonly IPluginLog log; + private readonly Settings config; + private readonly Rotation rotation = new(); + private readonly RequestGate gate = new(); + private readonly ManualControl manual = new(); + private readonly WeaveTiming weave = new(); + private readonly HealthPressure pressure = new(); + private readonly Dictionary shapes = []; + private readonly Queue decisionHistory = new(); + private DecisionFrame? diagnosticFrame; + private ActionDecision? frameDecision; + private long frameNumber; + private double nextAudit, burstUntil, harmonicReadySince = -1; + private bool auditMode; + private string lastEvent = ""; + public bool ManualPaused { get => manual.Forced; set { if (value) manual.Forced = true; else manual.Resume(); } } + public void StartBurst() => burstUntil = clock.Elapsed.TotalSeconds + 10; + public void StopBurst() => burstUntil = 0; + public string ControlText => $"{(manual.Active(clock.Elapsed.TotalSeconds) ? "手动接管" : "自动控制")} · 爆发窗口 {Math.Max(0, burstUntil - clock.Elapsed.TotalSeconds):F1}s"; + public string TimingText => $"穿插预算 {(config.AdaptiveWeaving ? weave.Budget : .65f):F2}s · 有效样本 {weave.SampleCount}"; + private readonly Stopwatch clock = Stopwatch.StartNew(); + private readonly Dictionary actionRows = []; + private readonly Dictionary diagnostics = []; + private readonly Dictionary publishedDiagnostics = []; + private string diagnosticText = "尚未生成决策快照"; + public string AuditSummary => diagnosticFrame == null ? "尚无快照" : $"快照 #{diagnosticFrame.Number} · {diagnosticFrame.Time:F2}s · {diagnosticFrame.Phase}"; + private ulong[] enemyIds = [], partyIds = []; + private double nextScan, nextTick, aliveSince, movingUntil; + private uint territory; + private BattlefieldMode mode; + private ulong playerId; + private bool wasAlive, completed, enabled, probeCasting, disposed; + private Vector3? lastPosition; + private CombatSnapshot? snapshot; + private (uint Id, ulong Target, ushort Sequence, double At)? ownedCast; + private HashSet cleanseBefore = []; + private ulong cleanseTarget; + + public string State { get; private set; } = "已关闭"; + public string ModeText => Battlefield.Name(mode); + public string TargetText { get; private set; } = "无"; + public string NextText { get; private set; } = "无"; + public string LastAction { get; private set; } = "无"; + public IReadOnlyDictionary Diagnostics => publishedDiagnostics; + public bool Enabled + { + get => enabled; + set { enabled = value; ClearDecisions(); nextAudit = 0; State = value ? "等待运行条件" : "已关闭"; } + } + + public CombatRuntime(IObjectTable objects, IClientState client, ICondition condition, IDutyState duty, + IDataManager data, ITargetManager targets, IPluginLog log, Settings config) + { + this.objects = objects; this.client = client; this.condition = condition; this.duty = duty; + this.data = data; this.targets = targets; this.log = log; this.config = config; + var sheet = data.GetExcelSheet(); + foreach (var skill in Actions.All) + { + if (sheet.TryGetRow((uint)skill, out var row) && row.IsPvP) + { + actionRows[skill] = row; + if (skill is Skill.ApexArrow or Skill.BlastArrow or Skill.PitchPerfect or Skill.EncoreOfLight) + shapes[skill] = new(skill is Skill.ApexArrow or Skill.BlastArrow, row.EffectRange, row.XAxisModifier > 0 ? row.XAxisModifier / 2f : 2); + } + else diagnostics[skill] = "客户端缺少此 PVP 技能"; + } + duty.DutyCompleted += OnDutyCompleted; + } + + private void OnDutyCompleted(IDutyStateEventArgs args) + { lock (SyncRoot) { completed = true; ClearDecisions(); } } + + private void ClearDecisions() + { + gate.Reset(); ownedCast = null; snapshot = null; enemyIds = []; partyIds = []; + manual.ResetObservations(); pressure.Reset(); weave.Reset(); harmonicReadySince = -1; burstUntil = 0; + nextScan = 0; TargetText = "无"; NextText = "无"; probeCasting = false; + } + + private string? CheckContext(IPlayerCharacter? player) + { + mode = data.GetExcelSheet().TryGetRow(client.TerritoryType, out var area) + ? Battlefield.Identify(area.TerritoryIntendedUse.RowId, client.IsPvP) : BattlefieldMode.None; + var state = PlayerState.Instance(); + var piloting = Battlefield.IsPiloting(mode, condition[ConditionFlag.PilotingMech], + player?.StatusList.Any(s => s.StatusId == Statuses.Warmachina) == true, + mode == BattlefieldMode.RivalWings && state != null ? state->GetContentValue(1) : 0); + return ExecutionPolicy.PauseReason(new(enabled, client.IsLoggedIn && player != null, + condition[ConditionFlag.BetweenAreas] || condition[ConditionFlag.BetweenAreas51], mode, + duty.IsDutyStarted, completed, player?.ClassJob.RowId == 23, + player != null && !player.IsDead && player.CurrentHp > 0, + condition[ConditionFlag.Mounted] || condition[ConditionFlag.RidingPillion], + condition[ConditionFlag.Occupied] || condition[ConditionFlag.OccupiedInEvent] || condition[ConditionFlag.OccupiedInCutSceneEvent], + actionRows.ContainsKey(Skill.PowerfulShot) && actionRows.ContainsKey(Skill.Guard) && actionRows.ContainsKey(Skill.Recuperate), piloting)); + } + + public void Update(IFramework framework) + { + lock (SyncRoot) UpdateLocked(); + } + + private void UpdateLocked() + { + if (disposed) return; + var now = clock.Elapsed.TotalSeconds; + if (now < nextTick) return; + nextTick = now + DecisionInterval; + frameDecision = null; frameNumber++; + try + { + Tick(now); + if (now >= nextAudit) { PublishAudit(now); nextAudit = now + .25; } + } + catch (Exception ex) + { + Enabled = false; State = "运行异常,已关闭;详情见 Dalamud 日志"; + log.Error(ex, "BardFrontline stopped after a runtime error."); + publishedDiagnostics.Clear(); foreach (var skill in Actions.All) publishedDiagnostics[skill] = State; + diagnosticText = State + "\n" + ex.Message; + } + } + + private void Tick(double now) + { + var player = objects.LocalPlayer; + if (territory != client.TerritoryType || playerId != (player?.GameObjectId ?? 0)) + { + territory = client.TerritoryType; playerId = player?.GameObjectId ?? 0; + completed = false; wasAlive = false; lastPosition = null; ClearDecisions(); + } + var alive = player != null && !player.IsDead && player.CurrentHp > 0; + if (alive && !wasAlive) aliveSince = now; + wasAlive = alive; + if (player != null) + { + if (lastPosition is { } pos && Vector3.DistanceSquared(pos, player.Position) > .0001f) movingUntil = now + .15; + lastPosition = player.Position; + } + var pause = CheckContext(player); + if (pause != null) { ClearDecisions(); State = pause; return; } + var am = ActionManager.Instance(); + if (am == null) { ClearDecisions(); State = "动作管理器不可用"; return; } + manual.Observe(now, targets.Target?.GameObjectId ?? 0, am->LastUsedActionSequence, gate.Pending != null, config); + var hpLoss = pressure.Observe(now, player!.CurrentHp, player.MaxHp); + snapshot = Capture(player!, now); + snapshot = snapshot with { RecentHpLossRatio = hpLoss }; + var self = snapshot.Player; + if (!config.Enabled(Skill.EncoreOfLight)) diagnostics[Skill.EncoreOfLight] = "已关闭"; + else if (!self.Has(Statuses.EncoreReady)) diagnostics[Skill.EncoreOfLight] = "缺少英雄的安可执行许可 (4312)"; + if (!self.Has(Statuses.RoleEagle)) diagnostics[Skill.EagleEyeShot] = "未装备 Eagle Eye Shot 职能技能"; + if (!self.Has(Statuses.RoleBravery)) diagnostics[Skill.Bravery] = "未装备 Bravery 职能技能"; + if (!self.Has(Statuses.RoleDervish)) diagnostics[Skill.Dervish] = "未装备 Dervish 职能技能"; + var ownsCast = player!.IsCasting && ownedCast is { } casting && ExecutionPolicy.OwnsCast( + casting.Id, casting.Target, casting.Sequence, player.CastActionId, player.CastTargetObjectId, am->LastUsedActionSequence); + var changed = GetBattle(cleanseTarget) is { } cleanseActor + && !cleanseBefore.SetEquals(ReadControls(cleanseActor)); + var waiting = gate.Update(now, am->LastUsedActionSequence, am->LastHandledActionSequence, + self.Has(Statuses.Guard), changed, ownsCast); + weave.Observe(now, gate.Pending == null && am->LastHandledActionSequence == am->LastUsedActionSequence, + am->AnimationLock, am->ActionQueued, player.IsCasting); + if (manual.Active(now)) { weave.Reset(); State = "手动接管:暂停自动动作与选敌"; NextText = "无"; return; } + if (self.Has(Statuses.Guard) || gate.Pending?.Skill == Skill.Guard) + { + State = "保持自身 Guard"; NextText = "无"; return; + } + + // Retarget before action/cast/ack waits. A pending action keeps its original explicit target ID. + // Respect manual casts; plugin-owned casts are not cancelled just because a better target appears. + if (!player.IsCasting || ownsCast) SelectDisplayTarget(); + + if (player!.IsCasting) + { + // A sequence mismatch means the player or another plugin owns this cast. + if (!ownsCast || ownedCast is not { } cast) + { ownedCast = null; State = "等待手动读条完成"; NextText = "无"; return; } + var castTarget = GetBattle(cast.Target); + var hostile = Actions.All.FirstOrDefault(s => (uint)s == cast.Id); + var invalid = castTarget == null || castTarget.IsDead || !castTarget.IsTargetable + || Actions.IsHostile(hostile) && !TargetSelector.Eligible(ReadActor(castTarget, player, true), hostile, ignoreGuard: true); + var enemyGuard = castTarget?.StatusList.Any(x => x.StatusId == Statuses.Guard) == true; + probeCasting = true; + var emergency = rotation.Decide(snapshot, config, this, emergencyOnly: true); + probeCasting = false; + if (ExecutionPolicy.ShouldCancelCast(hostile, ownsCast, invalid, enemyGuard, config.IgnoreEnemyGuard, + emergency, TargetSelector.HasNearbyThreat(snapshot), Statuses.Controls.Any(self.Has))) + { + var ui = UIState.Instance(); + if (ui != null) + { + ui->Hotbar.CancelCast(); ownedCast = null; gate.Reset(); + State = invalid || enemyGuard && !config.IgnoreEnemyGuard ? "目标失效 / Guard:取消插件读条" : "取消插件读条,准备生存技能"; + NextText = emergency == null ? "无" : Actions.Name(emergency.Skill); + } + return; + } + State = cast.Id == (uint)Skill.Elixir ? "补给读条中" : "插件读条中"; + return; + } + if (ownedCast is { } old && (now - old.At > 1 || old.Sequence != am->LastUsedActionSequence)) ownedCast = null; + if (waiting) { State = gate.Reason; return; } + if (!ExecutionPolicy.CanDispatch(am->ActionQueued, am->AnimationLock, player.IsCasting, waiting, self.Has(Statuses.Guard))) + { State = am->ActionQueued ? "等待游戏动作队列" : "等待动画锁"; return; } + + var decision = rotation.Decide(snapshot, config, this); + frameDecision = decision; + NextText = decision == null ? "无" : $"{Actions.Name(decision.Skill)}:{decision.Reason}"; + if (decision == null) + { + State = "等待可用技能 / 有效目标"; + return; + } + Execute(decision, now); + } + + private CombatSnapshot Capture(IPlayerCharacter player, double now) + { + if (now >= nextScan) + { + nextScan = now + DiscoveryInterval; + var enemy = new List(); var party = new List(); + foreach (var obj in objects.OfType()) + { + if (obj.GameObjectId == player.GameObjectId) continue; + if (IsEnemy(player, obj)) enemy.Add(obj.GameObjectId); + else if (obj is IPlayerCharacter && obj.StatusFlags.HasFlag(StatusFlags.PartyMember)) party.Add(obj.GameObjectId); + } + enemyIds = [.. enemy]; partyIds = [.. party]; + } + // Cache only IDs. Resolve live objects and statuses on every decision; never retain native pointers. + var enemies = enemyIds.Select(GetBattle).OfType() + .Where(p => IsEnemy(player, p)).Select(p => ReadActor(p, player, true)).ToArray(); + var partyMembers = partyIds.Select(GetBattle).OfType() + .Where(p => p.StatusFlags.HasFlag(StatusFlags.PartyMember) && !IsEnemy(player, p)) + .Select(p => ReadActor(p, player, false)).ToArray(); + var lb = LimitBreakController.Instance(); + var manager = ActionManager.Instance(); + var charges = manager == null ? 0 : (int)manager->GetCurrentCharges(Resolve(Skill.HarmonicArrow)); + if (charges <= 0 || !config.AutoAttack || manual.Active(now) + || !enemies.Any(a => TargetSelector.Allowed(a, config, Skill.HarmonicArrow) && a.Distance <= 25)) harmonicReadySince = -1; + else if (harmonicReadySince < 0) harmonicReadySince = now; + return new(ReadActor(player, player, false), player.CurrentMp, player.MaxMp, + condition[ConditionFlag.InCombat], now < movingUntil, now - aliveSince, + lb != null && lb->IsPvP && lb->BarUnits > 0 && lb->CurrentUnits >= lb->BarUnits, + enemies, partyMembers, targets.Target?.GameObjectId ?? 0, + gate.Pending?.Skill == Skill.Guard, gate.Pending?.Skill is Skill.Purify or Skill.WardensPaean) + { + TargetAge = now - manual.TargetChangedAt, ManualControl = manual.Active(now), BurstWindow = now < burstUntil, + HarmonicCharges = charges, HarmonicReadySeconds = harmonicReadySince < 0 ? 0 : now - harmonicReadySince, + RecentHpLossRatio = snapshot?.RecentHpLossRatio ?? 0, Shapes = shapes, + }; + } + + private bool IsEnemy(IPlayerCharacter self, IBattleChara other) + { + if (self.GameObjectId == other.GameObjectId || other.StatusFlags.HasFlag(StatusFlags.PartyMember) + || other.StatusFlags.HasFlag(StatusFlags.AllianceMember)) return false; + if (other is not IPlayerCharacter) + { + // Frontline attackable battle NPCs include ice and other destructible objectives. + // Exclude owned summons, companions and noncombat NPC subtypes; never key on localized names. + if (other is not IBattleNpc npc || !Battlefield.IsBattleNpcCandidate((byte)npc.BattleNpcKind, other.OwnerId)) return false; + if (ActionManager.CanUseActionOnTarget((uint)Skill.PowerfulShot, (NativeObject*)other.Address)) return true; + // Rival Wings NPCs remain threats even if temporarily untargetable or immune. + if (mode != BattlefieldMode.RivalWings) return false; + } + if (ActionManager.CanUseActionOnTarget((uint)Skill.PowerfulShot, (NativeObject*)other.Address)) return true; + // Battalion hostility remains valid for untargetable/protected players: necessary for safe elixir checks. + var ours = ((NativeCharacter*)self.Address)->CharacterData.Battalion; + var theirs = ((NativeCharacter*)other.Address)->CharacterData.Battalion; + if (data.GetExcelSheet().TryGetRow(ours, out var row) && theirs < row.IsEnemyTo.Count) + return row.IsEnemyTo[theirs]; + return other.StatusFlags.HasFlag(StatusFlags.Hostile); + } + + private static HashSet ReadControls(IBattleChara actor) => actor.StatusList + .Where(x => Statuses.Controls.Contains(x.StatusId)).Select(x => x.StatusId).ToHashSet(); + private IBattleChara? GetBattle(ulong id) => id == 0 ? null : objects.SearchById(id) as IBattleChara; + private bool IsWarmachina(IBattleChara actor) => mode == BattlefieldMode.RivalWings + && actor.StatusList.Any(s => s.StatusId == Statuses.Warmachina); + + private Actor ReadActor(IBattleChara actor, IPlayerCharacter player, bool enemy) + { + var distance = Math.Max(0, Vector3.Distance(player.Position, actor.Position) - player.HitboxRadius - actor.HitboxRadius); + var marker = 0; + if (enemy && MarkingController.Instance() is var controller && controller != null) + { + int[] slots = [0, 1, 2, 3, 4, 14, 15, 16]; + for (var i = 0; i < slots.Length; i++) + if ((ulong)controller->Markers[slots[i]] == actor.GameObjectId) { marker = i + 1; break; } + } + var kind = Battlefield.Classify(mode, actor is IBattleNpc, IsWarmachina(actor), actor.NameId); + return new(actor.GameObjectId, actor.Name.TextValue, actor.CurrentHp, actor.MaxHp, + distance, enemy, actor.IsTargetable, actor.IsDead, marker, + actor.StatusList.Select(s => s.StatusId).ToHashSet()) + { + IsObjective = actor is IBattleNpc || kind == ObjectiveKind.Mech, + Kind = kind, Position = actor.Position, HitboxRadius = actor.HitboxRadius, TargetId = actor.TargetObjectId, + CastTargetId = actor.IsCasting ? actor.CastTargetObjectId : 0, + ThreateningObjective = mode == BattlefieldMode.RivalWings && (actor is IBattleNpc || IsWarmachina(actor)), + StatusRemaining = actor.GameObjectId == player.GameObjectId + ? actor.StatusList.GroupBy(s => s.StatusId).ToDictionary(g => g.Key, g => g.Max(s => s.RemainingTime)) + : new Dictionary(), + Invulnerable = actor.StatusList.Any(s => Statuses.IsInvulnerability(s.StatusId, s.GameData.Value.Icon)), + ShieldHp = (uint)(((ulong)actor.MaxHp * actor.ShieldPercentage + 99) / 100), + }; + } + + private uint Resolve(Skill skill) + { + var am = ActionManager.Instance(); + return am == null ? 0 : am->GetAdjustedActionId(Actions.Root(skill)); + } + + public bool CanTarget(Skill skill, Actor target) => TargetFailure(skill, target) == null; + + private string? TargetFailure(Skill skill, Actor target) + { + if (!actionRows.ContainsKey(skill)) return "客户端缺少此 PVP 技能"; + var player = objects.LocalPlayer; + var live = GetBattle(target.Id); + if (player == null) return "本地角色不可用"; + if (live == null) return "目标对象已消失"; + if (live.IsDead || live.CurrentHp == 0) return "目标已死亡"; + if (!live.IsTargetable) return "目标当前不可选中"; + var id = Resolve(skill); + // Probe the requested action even when a base button is currently transformed (e.g. basic attack during Repertoire). + if (!Actions.Matches(skill, id)) id = (uint)skill; + var go = (NativeObject*)live.Address; + if (Actions.IsHostile(skill)) + { + if (!IsEnemy(player, live)) return "目标未通过敌方玩家 / 可攻击战场目标识别"; + var current = ReadActor(live, player, true); + if (current.IsObjective && (!config.AttackObjectives || config.DisabledObjectives.Contains(current.Kind) || skill == Skill.EagleEyeShot)) + return $"战场目标被当前策略排除:{Battlefield.KindName(current.Kind)}"; + if (current.Has(Statuses.Guard) && !config.IgnoreEnemyGuard && skill != Skill.EagleEyeShot) return "目标处于 Guard,当前设置避开"; + if (!TargetSelector.Allowed(current, config, skill)) return "目标存在无敌 / 物理免疫等过滤状态"; + if (current.Has(584) && ActionManager.GetActionRange(id) >= 20) return "目标存在远程攻击屏障 (584)"; + } + else if (target.Id != player.GameObjectId && (!live.StatusFlags.HasFlag(StatusFlags.PartyMember) + || skill != Skill.WardensPaean)) return "友方技能目标不是自身 / 允许的小队成员"; + if (!Actions.IsHostile(skill) && IsWarmachina(live)) return "机甲不接受诗人友方辅助技能"; + if (!ActionManager.CanUseActionOnTarget(id, go)) return $"游戏拒绝技能 {id} 对此目标使用"; + // Native per-action range/LoS check honors the sheet's RequiresLineOfSight flag. + var rangeStatus = ActionManager.GetActionInRangeOrLoS(id, (NativeObject*)player.Address, go); + return rangeStatus == 0 ? null : $"原生射程 / 视线检查失败 ({rangeStatus}) · 距离 {target.Distance:F1}m · 技能射程 {ActionManager.GetActionRange(id):F1}m"; + } + + public bool CanUse(Skill skill, Actor target) + { + bool No(string reason) { diagnostics[skill] = reason; return false; } + if (snapshot != null && Strategy.PolicyReason(skill, snapshot, config) is { } policy) return No(policy); + if (!actionRows.TryGetValue(skill, out var row)) return No("客户端缺少此 PVP 技能"); + if (!config.Enabled(skill)) return No("已关闭"); + var am = ActionManager.Instance(); + var player = objects.LocalPlayer; + if (am == null || player == null) return No("角色不可用"); + if (gate.IsBlocked(skill, target.Id, clock.Elapsed.TotalSeconds)) return No("请求失败退避中"); + var id = Resolve(skill); + if (!Actions.Matches(skill, id)) return No($"技能按钮当前变为 {id}"); + if (id != (uint)skill && (!data.GetExcelSheet().TryGetRow(id, out row) || !row.IsPvP)) + return No($"客户端缺少 PVP 技能变体 {id}"); + if (TargetFailure(skill, target) is { } failure) return No($"{failure} · 目标 {target.Name} ({target.Id:X})"); + if (skill == Skill.SilentNocturne && (target.Has(634) || target.Has(1347))) return No("目标已有沉默"); + if (!probeCasting && (am->ActionQueued || am->AnimationLock > 0 || player.IsCasting)) return No("动作队列 / 动画锁 / 读条中"); + var status = am->GetActionStatus(ActionType.Action, id, target.Id, checkRecastActive: true, checkCastingActive: !probeCasting); + if (status != 0) return No($"游戏技能检查:{status}"); + if (am->GetCurrentCharges(id) == 0) return No("没有可用充能"); + if (snapshot?.Moving == true && skill != Skill.PowerfulShot && ActionManager.GetAdjustedCastTime(ActionType.Action, id) > 0) + return No("移动中不能读条"); + // Independent-recast weaponskills also have an animation lock. Classify by recast groups, + // including the additional group, rather than by the sheet's ability/weaponskill category. + var eagleFinisher = skill == Skill.EagleEyeShot && !target.IsObjective && target.EffectiveHp <= (ulong)config.EagleFinisherHp; + var expiring = snapshot != null && Rotation.IsExpiring(skill, snapshot.Player); + if ((!probeCasting || auditMode) && !eagleFinisher && !expiring && (Actions.IsHostile(skill) || skill is Skill.Bravery or Skill.Dervish or Skill.FinalFantasia)) + { + var shared = am->GetRecastGroup((int)ActionType.Action, (uint)Skill.PowerfulShot); + if (shared >= 0 && !ExecutionPolicy.SharesGcd(am->GetRecastGroup((int)ActionType.Action, id), + am->GetAdditionalRecastGroup(ActionType.Action, id), shared)) + { + var recast = am->GetRecastGroupDetail(shared); + var remain = recast == null ? 0 : ExecutionPolicy.RecastRemaining(recast->IsActive, recast->Total, recast->Elapsed); + if (!weave.Allows(remain, skill, config.AdaptiveWeaving, config.Burst, + ActionManager.GetAdjustedCastTime(ActionType.Action, id) > 0)) + return No($"穿插空间不足:剩余 {remain:F2}s,预算 {(config.AdaptiveWeaving ? weave.Budget : .65f):F2}s"); + } + } + diagnostics[skill] = $"可用 · 技能 {id} · 充能 {am->GetCurrentCharges(id)} · 冷却组 {row.CooldownGroup}/{row.AdditionalCooldownGroup}"; + return true; + } + + private void Execute(ActionDecision decision, double now) + { + var am = ActionManager.Instance(); var player = objects.LocalPlayer; + if (am == null || player == null || CheckContext(player) != null || snapshot == null) return; + // Rebuild and re-decide just before dispatch, including all status/resource and target predicates. + var fresh = Capture(player, now); + snapshot = fresh; + var rechecked = rotation.Decide(fresh, config, this); + if (rechecked == null || rechecked.Skill != decision.Skill || rechecked.TargetId != decision.TargetId) + { frameDecision = rechecked; State = "提交前复核变化,等待下一轮"; return; } + var target = GetBattle(decision.TargetId); + if (target == null) return; + if (Actions.IsHostile(decision.Skill)) + { + targets.Target = target; + manual.OwnTarget(target.GameObjectId, now); + TargetText = $"{target.Name.TextValue} · HP {target.CurrentHp:N0}"; + } + if (decision.Skill is Skill.Purify or Skill.WardensPaean) + { cleanseBefore = ReadControls(target); cleanseTarget = target.GameObjectId; } + var id = Resolve(decision.Skill); + var before = am->LastUsedActionSequence; + var success = am->UseAction(ActionType.Action, id, target.GameObjectId); + if (success) + { + gate.Submitted(decision, now, before, am->LastUsedActionSequence); + manual.OwnAction(am->LastUsedActionSequence); + weave.Submitted(now, ActionManager.GetAdjustedCastTime(ActionType.Action, id) > 0); + if (ActionManager.GetAdjustedCastTime(ActionType.Action, id) > 0) + ownedCast = (id, target.GameObjectId, am->LastUsedActionSequence, now); + LastAction = $"{Actions.Name(decision.Skill)} → {target.Name.TextValue}"; + State = "已请求释放,等待确认"; + AddEvent(now, $"提交 {LastAction} · {decision.Reason} · 序列 {am->LastUsedActionSequence}"); + } + else + { + var error = am->GetActionStatus(ActionType.Action, id, target.GameObjectId); + gate.Fail(decision, now, $"{Actions.Name(decision.Skill)} 请求失败 ({error})"); + State = gate.Reason; diagnostics[decision.Skill] = State; + AddEvent(now, State); + } + } + + private void SelectDisplayTarget() + { + if (snapshot == null) return; + Actor? target; + probeCasting = true; + try + { + var planned = config.PreferAoe ? rotation.Decide(snapshot, config, this) : null; + target = planned != null && Actions.IsHostile(planned.Skill) + ? snapshot.Enemies.FirstOrDefault(a => a.Id == planned.TargetId) + : TargetSelector.PlanTarget(snapshot, config, this, this); + } + finally { probeCasting = false; } + if (target == null) { TargetText = "无"; return; } + if (targets.Target?.GameObjectId != target.Id && GetBattle(target.Id) is { } live) + { targets.Target = live; manual.OwnTarget(target.Id, clock.Elapsed.TotalSeconds); } + TargetText = $"{target.Name} · HP {target.Hp:N0}"; + } + + private void AddEvent(double now, string value) + { + if (lastEvent == value) return; + lastEvent = value; decisionHistory.Enqueue($"{now:F2}s {value}"); + while (decisionHistory.Count > 40) decisionHistory.Dequeue(); + } + + private void PublishAudit(double now) + { + var oldProbe = probeCasting; + try + { + probeCasting = true; auditMode = true; + diagnosticFrame = new(frameNumber, now, State, frameDecision, + DecisionAudit.Evaluate(snapshot, config, frameDecision, State, (skill, actor) => CanUse(skill, actor) ? null : diagnostics[skill], CanTarget)); + } + finally { probeCasting = oldProbe; auditMode = false; } + publishedDiagnostics.Clear(); + foreach (var row in diagnosticFrame.Skills) publishedDiagnostics[row.Skill] = row.Result; + AddEvent(now, State); + var am = ActionManager.Instance(); + var context = snapshot == null ? "无有效战斗快照" : $"HP {snapshot.Player.Hp}/{snapshot.Player.MaxHp} · MP {snapshot.Mp}/{snapshot.MaxMp} · 移动 {snapshot.Moving} · LB {snapshot.LimitFull}\n" + + $"集火指向 {Strategy.FocusCount(snapshot)} · 2 秒内累计掉血 {snapshot.RecentHpLossRatio:P0} · 和弦充能 {snapshot.HarmonicCharges}\n" + + "自身状态:" + string.Join(", ", snapshot.Player.Status.Order().Select(id => $"{id}:{snapshot.Player.Remaining(id):F1}s")); + diagnosticText = $"BardFrontline {typeof(CombatRuntime).Assembly.GetName().Version}\n决策快照 #{frameNumber} · 运行时 {now:F2}s · 模式 {ModeText}\n" + + $"执行阶段:{State}\n{ControlText}\n{TimingText}\n目标:{TargetText}\n下一技能:{NextText}\n最近请求:{LastAction}\n" + + $"爆发策略:{config.Burst} · 鹰眼策略:{config.EagleStrategy} · 自动攻击:{config.AutoAttack} · 后跳手动:{config.ManualRepelling}\n" + + $"队列 {am != null && am->ActionQueued} · 动画锁 {(am == null ? 0 : am->AnimationLock):F3}s · 待确认 {gate.Pending?.Skill.ToString() ?? "无"}\n{context}\n" + + "逐技能检查(同一快照;就绪检查忽略正在执行的读条/动画锁,实际提交仍受上方阶段限制):\n" + + string.Join('\n', publishedDiagnostics.Select(pair => $"{Actions.Name(pair.Key)}:{pair.Value}")) + + "\n敌方候选(分类、HP、距离、目标指向):\n" + + string.Join('\n', snapshot?.Enemies.Select(a => $"{a.Name} [{a.Id:X}] {(a.IsObjective ? Battlefield.KindName(a.Kind) : "玩家")} HP {a.Hp}+{a.ShieldHp} {a.Distance:F1}m →{a.TargetId:X} 可选={a.Targetable} 无敌={a.Invulnerable} Guard={a.Has(Statuses.Guard)}") ?? []) + + "\n近期事件:\n" + string.Join('\n', decisionHistory); + } + + public string DiagnosticText() => diagnosticText; + + public void Dispose() + { + lock (SyncRoot) + { + disposed = true; enabled = false; + duty.DutyCompleted -= OnDutyCompleted; + ClearDecisions(); + } + } +} diff --git a/Tests/BardFrontline.Tests.csproj b/Tests/BardFrontline.Tests.csproj new file mode 100644 index 0000000..e996e44 --- /dev/null +++ b/Tests/BardFrontline.Tests.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + enable + + + + + diff --git a/Tests/OptimizationTests.cs b/Tests/OptimizationTests.cs new file mode 100644 index 0000000..05e3d30 --- /dev/null +++ b/Tests/OptimizationTests.cs @@ -0,0 +1,235 @@ +using BardFrontline.Core; +using System.Numerics; +using System.Text.Json; + +static class OptimizationTests +{ + static Actor Self(params uint[] states) => new(1, "self", 60000, 60000, 0, false, true, false, 0, [.. states]); + static Actor Enemy(ulong id, uint hp = 30000) => new(id, $"enemy{id}", hp, 60000, 20, true, true, false, 0, []); + static CombatSnapshot Snap(params Actor[] enemies) => new(Self(), 10000, 10000, true, false, 10, false, enemies, [], 0); + static ActionDecision? Decide(CombatSnapshot s, Settings c, params Skill[] ready) => new Rotation().Decide(s, c, new Fake(ready)); + static void SkillIs(Skill skill, ActionDecision? d) => Suite.Equal(skill, d!.Skill); + + public static void Run(Suite suite) + { + suite.Run("Optimization defaults preserve automatic HP player-first finisher and backstep preferences", () => + { + var c = JsonSerializer.Deserialize("{\"EagleFinisherHp\":9000,\"HelpParty\":true}")!; c.Normalize(); + Suite.True(c.AutoAttack && c.AttackObjectives && !c.IgnoreEnemyGuard && c.HelpParty); + Suite.True(!c.StableTargets && !c.DetectManualControl && !c.ManualRepelling && !c.FocusDefense && !c.PreferAoe); + Suite.Equal(BurstStrategy.Normal, c.Burst); Suite.Equal(EagleStrategy.FinisherFirst, c.EagleStrategy); + Suite.Equal(TargetOrder.LowestHp, c.TargetOrder); Suite.Equal(9000, c.EagleFinisherHp); + Suite.Equal(0, c.DisabledObjectives.Count); + }); + suite.Run("New strategy settings serialize and malformed values normalize", () => + { + var c = new Settings { StableTargets = true, DetectManualControl = true, ManualRepelling = true, + FocusDefense = true, PreferAoe = true, Burst = BurstStrategy.ManualWindow, DisabledObjectives = [ObjectiveKind.Core] }; + var roundTrip = JsonSerializer.Deserialize(JsonSerializer.Serialize(c))!; + Suite.True(roundTrip.ManualRepelling && roundTrip.DisabledObjectives.Contains(ObjectiveKind.Core)); + c.TargetHoldSeconds = float.NaN; c.ManualControlSeconds = float.PositiveInfinity; + c.FocusAttackers = -1; c.BurstCharges = 50; c.AoeMinTargets = 0; c.Burst = (BurstStrategy)99; c.Normalize(); + Suite.Equal(.6f, c.TargetHoldSeconds); Suite.Equal(3f, c.ManualControlSeconds); + Suite.Equal(2, c.FocusAttackers); Suite.Equal(4, c.BurstCharges); Suite.Equal(2, c.AoeMinTargets); + Suite.Equal(BurstStrategy.Normal, c.Burst); + }); + suite.Run("Stable targeting absorbs small HP jitter but default remains instant", () => + { + var s = Snap(Enemy(10, 21000), Enemy(11, 20000)) with { CurrentTarget = 10, TargetAge = .2 }; + Suite.Equal(11ul, Decide(s, new(), Skill.PowerfulShot)!.TargetId); + Suite.Equal(10ul, Decide(s, new() { StableTargets = true }, Skill.PowerfulShot)!.TargetId); + Suite.Equal(11ul, Decide(s with { TargetAge = .7 }, new() { StableTargets = true }, Skill.PowerfulShot)!.TargetId); + }); + suite.Run("Stable targeting yields immediately to major HP advantage or invalid targets", () => + { + var c = new Settings { StableTargets = true }; + var s = Snap(Enemy(10, 30000), Enemy(11, 20000)) with { CurrentTarget = 10, TargetAge = .1 }; + Suite.Equal(11ul, Decide(s, c, Skill.PowerfulShot)!.TargetId); + foreach (var old in new[] { Enemy(10, 19000) with { Dead = true }, Enemy(10, 19000) with { Status = [Statuses.Guard] }, + Enemy(10, 19000) with { Targetable = false } }) + Suite.Equal(11ul, Decide(s with { Enemies = [old, Enemy(11, 20000)] }, c, Skill.PowerfulShot)!.TargetId); + }); + suite.Run("Stability cannot retain an objective over a player or delay an Eagle finisher", () => + { + var c = new Settings { StableTargets = true }; + var s = Snap(Enemy(10, 100) with { IsObjective = true }, Enemy(11, 9000)) with { CurrentTarget = 10, TargetAge = .1 }; + Suite.Equal(11ul, Decide(s, c, Skill.PowerfulShot)!.TargetId); + SkillIs(Skill.EagleEyeShot, Decide(s with { Player = Self(Statuses.RoleEagle) }, c, Skill.EagleEyeShot, Skill.PowerfulShot)); + }); + suite.Run("Planned and dispatched stable targets agree", () => + { + var s = Snap(Enemy(10, 21000), Enemy(11, 20000)) with { CurrentTarget = 10, TargetAge = .1 }; + var c = new Settings { StableTargets = true }; var f = new Fake(Skill.PowerfulShot); + Suite.Equal(10ul, TargetSelector.PlanTarget(s, c, f, f)!.Id); + Suite.Equal(10ul, new Rotation().Decide(s, c, f)!.TargetId); + }); + suite.Run("Plugin target writes and native requests never trigger automatic takeover", () => + { + var control = new ManualControl(); var c = new Settings { DetectManualControl = true }; + control.Observe(0, 10, 1, false, c); control.OwnTarget(11, .1); control.OwnAction(2); + control.Observe(.2, 11, 2, false, c); Suite.True(!control.Active(.2)); + control.Observe(.3, 11, 3, true, c); control.Observe(.4, 11, 3, false, c); Suite.True(!control.Active(.4)); + }); + suite.Run("Manual target and foreign action takeover has bounded expiry", () => + { + var control = new ManualControl(); var c = new Settings { DetectManualControl = true, ManualControlSeconds = 3 }; + control.Observe(0, 10, 65535, false, c); control.Observe(1, 11, 65535, false, c); + Suite.True(control.Active(3.9)); Suite.True(!control.Active(4)); + control.Observe(5, 11, 0, false, c); Suite.True(control.Active(5)); + control.Resume(); Suite.True(!control.Active(5)); + }); + suite.Run("Forced takeover persists across context resets but explicit resume clears it", () => + { + var control = new ManualControl { Forced = true }; control.ResetObservations(); + Suite.True(control.Active(100)); control.Resume(); Suite.True(!control.Active(100)); + control.Observe(0, 10, 1, false, new()); control.Observe(1, 11, 2, false, new()); Suite.True(!control.Active(1)); + }); + suite.Run("Manual control pauses offense survival and target planning", () => + { + var s = Snap(Enemy(10, 100)) with { ManualControl = true, Player = Self(Statuses.RoleEagle) with { Hp = 1000 } }; + var f = new Fake(Actions.All); + Suite.True(new Rotation().Decide(s, new(), f) == null); + Suite.True(TargetSelector.PlanTarget(s, new(), f, f) == null); + }); + suite.Run("Adaptive weave starts at legacy budget and learns observed confirmation latency", () => + { + var w = new WeaveTiming(); Suite.Equal(.65f, w.Budget); + for (var i = 0; i < 3; i++) { w.Submitted(i * 2, false); w.Observe(i * 2 + .1, false, .6f, false, false); w.Observe(i * 2 + .8, true, 0, false, false); } + Suite.Equal(3, w.SampleCount); Suite.True(w.Budget > .8f && w.Budget < .9f); + Suite.True(!w.Allows(.75f, Skill.SilentNocturne, true)); Suite.True(w.Allows(.75f, Skill.SilentNocturne, false)); + Suite.True(w.Allows(.1f, Skill.EncoreOfLight, true)); Suite.True(w.Allows(0, Skill.HarmonicArrow, true)); + }); + suite.Run("Weave learning ignores casts timeouts and pending queues", () => + { + var w = new WeaveTiming(); w.Submitted(0, true); w.Observe(1, true, 0, false, false); Suite.Equal(0, w.SampleCount); + w.Submitted(2, false); w.Observe(5, false, 0, false, false); Suite.Equal(0, w.SampleCount); + w.Submitted(6, false); w.Observe(6.6, true, 0, true, false); Suite.Equal(0, w.SampleCount); + w.Observe(6.8, true, 0, false, false); Suite.Equal(1, w.SampleCount); w.Reset(); Suite.Equal(.65f, w.Budget); + }); + suite.Run("Adaptive sample window is bounded and budget responds to recent samples", () => + { + var w = new WeaveTiming(); + for (var i = 0; i < 30; i++) { w.Submitted(i * 2, false); w.Observe(i * 2 + (i < 5 ? 1.4 : .55), true, 0, false, false); } + Suite.Equal(20, w.SampleCount); Suite.True(w.Budget < .65f && w.Budget >= .5f); + }); + suite.Run("Rival Wings names classify all objective families without localized strings", () => + { + foreach (var (id, kind) in new[] { (6857u, ObjectiveKind.Core), (6860u, ObjectiveKind.Tower), (6864u, ObjectiveKind.Mech), + (6871u, ObjectiveKind.Infantry), (7891u, ObjectiveKind.Mercenary), (7906u, ObjectiveKind.Mercenary), (99999u, ObjectiveKind.Other) }) + Suite.Equal(kind, Battlefield.Classify(BattlefieldMode.RivalWings, true, false, id)); + Suite.Equal(ObjectiveKind.Mech, Battlefield.Classify(BattlefieldMode.RivalWings, false, true, 0)); + Suite.Equal(ObjectiveKind.Other, Battlefield.Classify(BattlefieldMode.Frontline, true, false, 6857)); + Suite.Equal(ObjectiveKind.Ice, Battlefield.Classify(BattlefieldMode.Frontline, true, false, 4822)); + }); + suite.Run("Objective family switches affect attacks but never hide threats", () => + { + foreach (var kind in Enum.GetValues()) + { + var s = Snap(Enemy(10) with { IsObjective = true, ThreateningObjective = true, Kind = kind }); + var c = new Settings { DisabledObjectives = [kind] }; + Suite.True(Decide(s, c, Skill.PowerfulShot) == null); Suite.True(TargetSelector.HasNearbyThreat(s)); + SkillIs(Skill.PowerfulShot, Decide(s, new(), Skill.PowerfulShot)); + } + }); + suite.Run("Original Harmonic behavior remains immediate and aligned mode has bounded hold", () => + { + var s = Snap(Enemy(10)) with { HarmonicCharges = 1 }; + SkillIs(Skill.HarmonicArrow, Decide(s, new(), Skill.HarmonicArrow, Skill.PowerfulShot)); + var c = new Settings { Burst = BurstStrategy.AlignBuffs }; + SkillIs(Skill.PowerfulShot, Decide(s, c, Skill.HarmonicArrow, Skill.PowerfulShot)); + SkillIs(Skill.HarmonicArrow, Decide(s with { HarmonicReadySeconds = 4 }, c, Skill.HarmonicArrow)); + SkillIs(Skill.HarmonicArrow, Decide(s with { HarmonicCharges = 3 }, c, Skill.HarmonicArrow)); + SkillIs(Skill.HarmonicArrow, Decide(s with { Player = Self(Statuses.Bravery) }, c, Skill.HarmonicArrow)); + }); + suite.Run("Manual burst holds only declared burst skills and releases in window", () => + { + var s = Snap(Enemy(10)) with { Player = Self(Statuses.RoleBravery), LimitFull = true }; + var c = new Settings { Burst = BurstStrategy.ManualWindow }; + SkillIs(Skill.PowerfulShot, Decide(s, c, Skill.HarmonicArrow, Skill.Bravery, Skill.FinalFantasia, Skill.PowerfulShot)); + SkillIs(Skill.Bravery, Decide(s with { BurstWindow = true }, c, Skill.Bravery, Skill.PowerfulShot)); + SkillIs(Skill.EncoreOfLight, Decide(s with { Player = Self(Statuses.EncoreReady) }, c, Skill.EncoreOfLight, Skill.PowerfulShot)); + SkillIs(Skill.EagleEyeShot, Decide(s with { Player = Self(Statuses.RoleEagle), Enemies = [Enemy(10, 9000)] }, c, Skill.EagleEyeShot)); + }); + suite.Run("Aoe circle strategy chooses dense players only when enabled", () => + { + var s = Snap(Enemy(10, 1000) with { Position = new(0, 0, 20) }, Enemy(11, 40000) with { Position = new(10, 0, 10) }, + Enemy(12, 50000) with { Position = new(11, 0, 10) }) with + { Player = Self(Statuses.EncoreReady), Shapes = new Dictionary { [Skill.EncoreOfLight] = new(false, 5) } }; + Suite.Equal(10ul, Decide(s, new(), Skill.EncoreOfLight)!.TargetId); + Suite.Equal(11ul, Decide(s, new() { PreferAoe = true }, Skill.EncoreOfLight)!.TargetId); + Suite.Equal(10ul, Decide(s, new() { PreferAoe = true, AoeMinTargets = 4 }, Skill.EncoreOfLight)!.TargetId); + }); + suite.Run("Aoe line counts width range direction elevation and immunity", () => + { + var aim = Enemy(10) with { Position = new(0, 0, 10) }; + var s = Snap(aim, Enemy(11) with { Position = new(1, 0, 20) }, Enemy(12) with { Position = new(0, 0, -5) }, + Enemy(13) with { Position = new(4, 0, 10) }, Enemy(14) with { Position = new(0, 8, 10) }, + Enemy(15) with { Position = new(0, 0, 30) }, Enemy(16) with { Position = new(0, 0, 15), Invulnerable = true }); + Suite.Equal(2, Strategy.AoeCount(s, new(), Skill.ApexArrow, aim, new(true, 25, 2))); + }); + suite.Run("Aoe strategy never diverts from players to a denser objective pack", () => + { + var s = Snap(Enemy(10) with { Position = new(0, 0, 20) }, Enemy(11) with { IsObjective = true }, Enemy(12) with { IsObjective = true }) + with { Player = Self(Statuses.EncoreReady), Shapes = new Dictionary { [Skill.EncoreOfLight] = new(false, 5) } }; + Suite.Equal(10ul, Decide(s, new() { PreferAoe = true }, Skill.EncoreOfLight)!.TargetId); + }); + suite.Run("Focus defense uses enemy target and cast signals without double-counting actors", () => + { + var s = Snap(Enemy(10) with { TargetId = 1, CastTargetId = 1 }, Enemy(11) with { CastTargetId = 1 }, Enemy(12) with { TargetId = 1 }, + Enemy(13) with { TargetId = 1, Enemy = false }, Enemy(14) with { TargetId = 1, Dead = true }, Enemy(15) with { TargetId = 1, Distance = 60 }) + with { Player = Self() with { Hp = 23000 } }; + Suite.Equal(3, Strategy.FocusCount(s)); + SkillIs(Skill.Recuperate, Decide(s, new(), Skill.Guard, Skill.Recuperate)); + SkillIs(Skill.Guard, Decide(s, new() { FocusDefense = true }, Skill.Guard, Skill.Recuperate)); + Suite.True(Decide(s with { AliveSeconds = 4 }, new() { FocusDefense = true }, Skill.Guard, Skill.Recuperate) == null); + }); + suite.Run("Focus pressure lowers healing threshold but never raises it or removes Guard protection", () => + { + var s = Snap() with { Player = Self() with { Hp = 53000 }, RecentHpLossRatio = .3 }; + Suite.True(Decide(s, new(), Skill.Recuperate) == null); + SkillIs(Skill.Recuperate, Decide(s, new() { FocusDefense = true }, Skill.Recuperate)); + Suite.Equal(3000, Strategy.HealDeficit(s, new() { FocusDefense = true, RecuperateMissingHp = 3000 })); + Suite.True(Decide(s with { Player = Self(Statuses.Guard) with { Hp = 53000 } }, new() { FocusDefense = true }, Skill.Recuperate) == null); + }); + suite.Run("HP pressure expires after two seconds and ignores healing increments", () => + { + var p = new HealthPressure(); Suite.Equal(0d, p.Observe(0, 60000, 60000)); + Suite.Equal(.25, p.Observe(.5, 45000, 60000)); Suite.Equal(.25, p.Observe(1, 55000, 60000)); + Suite.Equal(0d, p.Observe(2.6, 55000, 60000)); p.Reset(); Suite.Equal(0d, p.Observe(3, 1000, 60000)); + }); + suite.Run("Manual backstep switch leaves the rest of the rotation and skill toggle intact", () => + { + var s = Snap(Enemy(10) with { Distance = 8 }); + SkillIs(Skill.RepellingShot, Decide(s, new(), Skill.RepellingShot, Skill.SilentNocturne)); + SkillIs(Skill.SilentNocturne, Decide(s, new() { ManualRepelling = true }, Skill.RepellingShot, Skill.SilentNocturne)); + Suite.True(Decide(s, new() { DisabledSkills = [Skill.RepellingShot] }, Skill.RepellingShot) == null); + }); + suite.Run("Decision audit contains every skill and does not carry stale results to another frame", () => + { + var s = Snap(Enemy(10)); var c = new Settings(); + var selected = Decide(s, c, Skill.PowerfulShot); + var rows = DecisionAudit.Evaluate(s, c, selected, "运行", (_, _) => "冷却"); + Suite.Equal(Actions.All.Length, rows.Count); Suite.True(rows.Single(r => r.Skill == Skill.PowerfulShot).Result.Contains("本轮选中")); + Suite.True(rows.Single(r => r.Skill == Skill.EncoreOfLight).Result.Contains("4312")); + var paused = DecisionAudit.Evaluate(null, c, null, "非支持区域", (_, _) => throw new Exception("must not probe")); + Suite.True(paused.All(r => r.TargetId == 0 && r.Result == "非支持区域")); + }); + suite.Run("Decision audit distinguishes toggle policy native failure and higher priority selection", () => + { + var s = Snap(Enemy(10) with { Distance = 8 }) with { Player = Self(Statuses.EncoreReady) }; + var c = new Settings { ManualRepelling = true, DisabledSkills = [Skill.HarmonicArrow] }; + var selected = Decide(s, c, Skill.EncoreOfLight); + var rows = DecisionAudit.Evaluate(s, c, selected, "运行", (skill, _) => skill == Skill.SilentNocturne ? "原生状态 582" : null); + Suite.True(rows.Single(r => r.Skill == Skill.HarmonicArrow).Result.Contains("关闭")); + Suite.True(rows.Single(r => r.Skill == Skill.RepellingShot).Result.Contains("手动")); + Suite.True(rows.Single(r => r.Skill == Skill.SilentNocturne).Result.Contains("582")); + Suite.True(rows.Single(r => r.Skill == Skill.PowerfulShot).Result.Contains("优先选择")); + }); + suite.Run("Audit never claims Sprint ready with a valid attack target", () => + { + var s = Snap(Enemy(10)) with { Moving = true }; + var rows = DecisionAudit.Evaluate(s, new(), null, "等待", (_, _) => null, (_, _) => true); + Suite.True(rows.Single(r => r.Skill == Skill.Sprint).Result.Contains("不自动冲刺")); + }); + } +} diff --git a/Tests/PressureTests.cs b/Tests/PressureTests.cs new file mode 100644 index 0000000..72315c3 --- /dev/null +++ b/Tests/PressureTests.cs @@ -0,0 +1,126 @@ +using BardFrontline.Core; +using System.Text.Json; + +static class PressureTests +{ + static Actor Self(params uint[] states) => new(1, "self", 60000, 60000, 0, false, true, false, 0, [.. states]); + static Actor Enemy(ulong id = 10, uint hp = 30000) => new(id, "enemy", hp, 60000, 18, true, true, false, 0, []); + static CombatSnapshot Snap(params uint[] states) => new(Self(states), 10000, 10000, true, false, 10, false, [Enemy()], [], 0); + static Settings Config() => new() { Burst = BurstStrategy.Pressure }; + static ActionDecision? Decide(CombatSnapshot s, Settings c, params Skill[] ready) => new Rotation().Decide(s, c, new Fake(ready)); + static void Is(Skill skill, ActionDecision? d) => Suite.Equal(skill, d?.Skill); + + public static void Run(Suite suite) + { + suite.Run("Pressure opt-in preserves serialized modes and original default", () => + { + Suite.Equal(BurstStrategy.Normal, new Settings().Burst); + foreach (var mode in Enum.GetValues()) + { + var c = JsonSerializer.Deserialize(JsonSerializer.Serialize(new Settings { Burst = mode }))!; + c.Normalize(); Suite.Equal(mode, c.Burst); + } + Suite.Equal(0, (int)BurstStrategy.Normal); Suite.Equal(1, (int)BurstStrategy.AlignBuffs); + Suite.Equal(2, (int)BurstStrategy.ManualWindow); + }); + suite.Run("Pressure releases regular Eagle before Harmonic and filler without changing default", () => + { + var s = Snap(Statuses.March, Statuses.RoleEagle); + Skill[] ready = [Skill.HarmonicArrow, Skill.PowerfulShot, Skill.EagleEyeShot]; + Is(Skill.HarmonicArrow, Decide(s, new(), ready)); + Is(Skill.EagleEyeShot, Decide(s, Config(), ready)); + }); + suite.Run("Pressure uses silence before filler and still avoids overwriting Repertoire", () => + { + Is(Skill.PowerfulShot, Decide(Snap(), new(), Skill.PowerfulShot, Skill.SilentNocturne)); + Is(Skill.SilentNocturne, Decide(Snap(), Config(), Skill.PowerfulShot, Skill.SilentNocturne)); + Is(Skill.HarmonicArrow, Decide(Snap(Statuses.Repertoire), Config(), Skill.SilentNocturne, Skill.HarmonicArrow)); + }); + suite.Run("Pressure consumes ready procs before multiple Harmonic charges and normal Eagle", () => + { + foreach (var (status, skill) in new[] { (Statuses.Repertoire, Skill.PitchPerfect), (Statuses.BlastReady, Skill.BlastArrow) }) + { + var s = Snap(Statuses.March, status, Statuses.RoleEagle) with { HarmonicCharges = 4 }; + Is(skill, Decide(s, Config(), skill, Skill.HarmonicArrow, Skill.EagleEyeShot)); + Is(Skill.HarmonicArrow, Decide(s, new(), skill, Skill.HarmonicArrow, Skill.EagleEyeShot)); + } + }); + suite.Run("Pressure preserves opener buffs Encore and Eagle finisher precedence", () => + { + Is(Skill.ApexArrow, Decide(Snap(Statuses.RoleEagle), Config(), Skill.ApexArrow, Skill.EagleEyeShot, Skill.HarmonicArrow)); + Is(Skill.Bravery, Decide(Snap(Statuses.RoleBravery, Statuses.RoleEagle), Config(), Skill.Bravery, Skill.EagleEyeShot)); + Is(Skill.FinalFantasia, Decide(Snap(Statuses.RoleEagle) with { LimitFull = true }, Config(), Skill.FinalFantasia, Skill.EagleEyeShot)); + Is(Skill.EncoreOfLight, Decide(Snap(Statuses.EncoreReady, Statuses.RoleEagle), Config(), Skill.EncoreOfLight, Skill.EagleEyeShot)); + Is(Skill.EagleEyeShot, Decide(Snap(Statuses.EncoreReady, Statuses.RoleEagle) with { Enemies = [Enemy(hp: 9000)] }, + Config(), Skill.EncoreOfLight, Skill.EagleEyeShot)); + }); + suite.Run("Pressure respects strict Eagle reservation and unavailable ability fallback", () => + { + var s = Snap(Statuses.RoleEagle); + var c = Config(); c.EagleStrategy = EagleStrategy.FinisherOnly; + Is(Skill.PowerfulShot, Decide(s, c, Skill.PowerfulShot, Skill.EagleEyeShot)); + c = Config(); c.DisabledSkills = [Skill.EagleEyeShot, Skill.SilentNocturne]; + Is(Skill.PowerfulShot, Decide(s, c, Skill.PowerfulShot, Skill.EagleEyeShot, Skill.SilentNocturne)); + var f = new Fake(Skill.EagleEyeShot, Skill.PowerfulShot); f.Charges[Skill.EagleEyeShot] = 0; + Is(Skill.PowerfulShot, new Rotation().Decide(s, Config(), f)); + }); + suite.Run("Pressure never holds charges or buffs for alignment", () => + { + var s = Snap() with { HarmonicCharges = 1, HarmonicReadySeconds = 0 }; + foreach (var skill in new[] { Skill.HarmonicArrow, Skill.Bravery, Skill.Dervish, Skill.FinalFantasia }) + Suite.True(Strategy.HoldReason(skill, s, Config()) == null); + Is(Skill.HarmonicArrow, Decide(s, Config(), Skill.HarmonicArrow, Skill.PowerfulShot)); + }); + suite.Run("Pressure waives extra instant damage weave margin but excludes casts and backstep", () => + { + var w = new WeaveTiming(); + for (var i = 0; i < 3; i++) { w.Submitted(i * 2, false); w.Observe(i * 2 + 1.2, true, 0, false, false); } + foreach (var adaptive in new[] { true, false }) + foreach (var skill in new[] { Skill.HarmonicArrow, Skill.PitchPerfect, Skill.BlastArrow, Skill.ApexArrow, + Skill.EagleEyeShot, Skill.SilentNocturne, Skill.Bravery, Skill.Dervish, Skill.FinalFantasia }) + { + Suite.True(!w.Allows(.1f, skill, adaptive)); + Suite.True(w.Allows(.1f, skill, adaptive, BurstStrategy.Pressure)); + Suite.True(!w.Allows(.1f, skill, adaptive, BurstStrategy.Pressure, casting: true)); + } + Suite.True(!w.Allows(.1f, Skill.RepellingShot, true, BurstStrategy.Pressure)); + Suite.True(!w.Allows(.1f, Skill.PowerfulShot, true, BurstStrategy.Pressure)); + }); + suite.Run("Pressure retains survival Guard manual pause and attack switches", () => + { + var s = Snap(Statuses.RoleEagle); + Is(Skill.Guard, Decide(s with { Player = Self(Statuses.RoleEagle) with { Hp = 8000 } }, + Config(), Skill.Guard, Skill.EagleEyeShot)); + Is(Skill.Recuperate, Decide(s with { Player = Self(Statuses.RoleEagle) with { Hp = 40000 } }, + Config(), Skill.Recuperate, Skill.EagleEyeShot)); + Suite.True(Decide(s with { ManualControl = true }, Config(), Skill.EagleEyeShot) == null); + Suite.True(Decide(s with { Player = Self(Statuses.RoleEagle, Statuses.Guard) }, Config(), Skill.EagleEyeShot) == null); + var c = Config(); c.AutoAttack = false; + Suite.True(Decide(s, c, Skill.EagleEyeShot, Skill.SilentNocturne) == null); + }); + suite.Run("Pressure keeps player-first targeting and Eagle off objectives", () => + { + var objective = Enemy(11, 1000) with { IsObjective = true }; + var s = Snap(Statuses.RoleEagle) with { Enemies = [objective, Enemy()] }; + Suite.Equal(10ul, Decide(s, Config(), Skill.EagleEyeShot, Skill.HarmonicArrow)!.TargetId); + Is(Skill.HarmonicArrow, Decide(s with { Enemies = [objective] }, Config(), Skill.EagleEyeShot, Skill.HarmonicArrow)); + }); + suite.Run("Pressure does not move backstep ahead of filler or override manual backstep", () => + { + var s = Snap() with { Enemies = [Enemy() with { Distance = 8 }] }; + Is(Skill.PowerfulShot, Decide(s, Config(), Skill.RepellingShot, Skill.PowerfulShot)); + var c = Config(); c.ManualRepelling = true; + Suite.True(Decide(s, c, Skill.RepellingShot) == null); + }); + suite.Run("Pressure burst falls back to filler after spending cooldowns and consuming proc", () => + { + var s = Snap(Statuses.March, Statuses.RoleEagle); + Is(Skill.EagleEyeShot, Decide(s, Config(), Skill.EagleEyeShot, Skill.SilentNocturne, Skill.HarmonicArrow, Skill.PowerfulShot)); + Is(Skill.SilentNocturne, Decide(s, Config(), Skill.SilentNocturne, Skill.HarmonicArrow, Skill.PowerfulShot)); + Is(Skill.PitchPerfect, Decide(s with { Player = Self(Statuses.March, Statuses.RoleEagle, Statuses.Repertoire) }, + Config(), Skill.PitchPerfect, Skill.HarmonicArrow)); + Is(Skill.HarmonicArrow, Decide(s, Config(), Skill.HarmonicArrow, Skill.PowerfulShot)); + Is(Skill.PowerfulShot, Decide(s, Config(), Skill.PowerfulShot)); + }); + } +} diff --git a/Tests/Program.cs b/Tests/Program.cs new file mode 100644 index 0000000..617f19e --- /dev/null +++ b/Tests/Program.cs @@ -0,0 +1,781 @@ +using BardFrontline.Core; + +var suite = new Suite(); +var engine = new Rotation(); +Actor Self(uint hp = 60000, params uint[] states) => new(1, "Self", hp, 60000, 0, false, true, false, 0, [.. states]); +Actor Enemy(ulong id = 10, uint hp = 30000, float distance = 20, params uint[] states) => + new(id, "Enemy", hp, 60000, distance, true, true, false, 0, [.. states]); +CombatSnapshot Snap(Actor? player = null, params Actor[] enemies) => + new(player ?? Self(), 10000, 10000, true, false, 10, false, enemies, [], 0); +ActionDecision? Decide(CombatSnapshot s, Fake f, Settings? c = null, bool emergency = false) => engine.Decide(s, c ?? new(), f, emergency); +void IsSkill(ActionDecision? d, Skill skill) => Suite.Equal(skill, d?.Skill); +void None(ActionDecision? d) => Suite.True(d == null, $"Expected no action, got {d}"); + +suite.Run("Default configuration and invalid persisted values", () => +{ + var c = new Settings(); + Suite.True(c.AutoAttack && !c.IgnoreEnemyGuard && !c.HelpParty); + Suite.True(Actions.All.All(c.Enabled)); + c.GuardHpPercent = float.NaN; c.ElixirPercent = float.PositiveInfinity; + c.RecuperateMissingHp = -10; c.TargetOrder = (TargetOrder)99; c.Normalize(); + Suite.Equal(15f, c.GuardHpPercent); Suite.Equal(1, c.RecuperateMissingHp); + Suite.Equal(TargetOrder.LowestHp, c.TargetOrder); +}); +suite.Run("Missing March prioritizes Apex over charged Harmonic", () => + IsSkill(Decide(Snap(null, Enemy()), new(Skill.ApexArrow, Skill.HarmonicArrow, Skill.PowerfulShot)), Skill.ApexArrow)); +suite.Run("March present prioritizes Harmonic", () => + IsSkill(Decide(Snap(Self(60000, Statuses.March), Enemy()), new(Skill.ApexArrow, Skill.HarmonicArrow)), Skill.HarmonicArrow)); +suite.Run("Harmonic is usable at each charge count, zero falls through", () => +{ + foreach (var count in new[] { 1, 2, 3, 4 }) + { + var fake = new Fake(Skill.HarmonicArrow, Skill.PowerfulShot); + fake.Charges[Skill.HarmonicArrow] = count; + IsSkill(Decide(Snap(null, Enemy()), fake), Skill.HarmonicArrow); + } + var empty = new Fake(Skill.HarmonicArrow, Skill.PowerfulShot); + empty.Charges[Skill.HarmonicArrow] = 0; + IsSkill(Decide(Snap(null, Enemy()), empty), Skill.PowerfulShot); +}); +suite.Run("Repertoire uses Pitch and prevents wasteful generators", () => +{ + IsSkill(Decide(Snap(Self(60000, Statuses.Repertoire), Enemy()), new(Skill.PitchPerfect, Skill.PowerfulShot)), Skill.PitchPerfect); + None(Decide(Snap(Self(60000, Statuses.Repertoire), Enemy()), new(Skill.RepellingShot, Skill.SilentNocturne, Skill.PowerfulShot))); +}); +suite.Run("No Repertoire does not attempt Pitch", () => + IsSkill(Decide(Snap(null, Enemy()), new(Skill.PitchPerfect, Skill.PowerfulShot)), Skill.PowerfulShot)); +suite.Run("Blast ready consumes Blast and blocks Apex", () => + IsSkill(Decide(Snap(Self(60000, Statuses.BlastReady), Enemy()), new(Skill.ApexArrow, Skill.BlastArrow)), Skill.BlastArrow)); +suite.Run("No Blast ready falls through to Apex", () => + IsSkill(Decide(Snap(Self(60000, Statuses.March), Enemy()), new(Skill.BlastArrow, Skill.ApexArrow)), Skill.ApexArrow)); +suite.Run("Offensive abilities preserve RSR order", () => +{ + var s = Snap(Self(60000, Statuses.RoleEagle), Enemy(distance: 8)); + IsSkill(Decide(s, new(Skill.RepellingShot, Skill.SilentNocturne, Skill.EagleEyeShot, Skill.EncoreOfLight)), Skill.RepellingShot); + IsSkill(Decide(s, new(Skill.SilentNocturne, Skill.EagleEyeShot, Skill.EncoreOfLight)), Skill.SilentNocturne); + IsSkill(Decide(s, new(Skill.EagleEyeShot, Skill.EncoreOfLight)), Skill.EagleEyeShot); + IsSkill(Decide(s with { Player = Self(60000, Statuses.EncoreReady) }, new(Skill.EncoreOfLight)), Skill.EncoreOfLight); +}); +suite.Run("Repelling out of range falls through to Nocturne", () => + IsSkill(Decide(Snap(null, Enemy(distance: 18)), new(Skill.RepellingShot, Skill.SilentNocturne)), Skill.SilentNocturne)); +suite.Run("Ready weapons prevent offensive ability starvation of GCD", () => + IsSkill(Decide(Snap(null, Enemy(distance: 8)), new(Skill.PowerfulShot, Skill.RepellingShot)), Skill.PowerfulShot)); +suite.Run("Unavailable high priority skills fall through", () => + IsSkill(Decide(Snap(null, Enemy()), new(Skill.PowerfulShot)), Skill.PowerfulShot)); +suite.Run("Disabled offensive skills are never selected", () => +{ + var c = new Settings(); c.DisabledSkills.Add(Skill.HarmonicArrow); + IsSkill(Decide(Snap(null, Enemy()), new(Skill.HarmonicArrow, Skill.PowerfulShot), c), Skill.PowerfulShot); +}); +suite.Run("Limit Break requires full gauge, combat and target", () => +{ + var f = new Fake(Skill.FinalFantasia); + var s = Snap(null, Enemy()) with { LimitFull = true }; + IsSkill(Decide(s, f), Skill.FinalFantasia); + None(Decide(s with { LimitFull = false }, f)); + None(Decide(s with { InCombat = false }, f)); + None(Decide(s with { Enemies = [] }, f)); + None(Decide(s with { Player = Self(60000, Statuses.Fantasia) }, f)); + None(Decide(s with { Player = Self(60000, Statuses.EncoreReady) }, f)); +}); +suite.Run("Encore requires its status and uses hostile target", () => +{ + var f = new Fake(Skill.EncoreOfLight); + None(Decide(Snap(null, Enemy()), f)); + var d = Decide(Snap(Self(60000, Statuses.EncoreReady), Enemy(33)), f); + IsSkill(d, Skill.EncoreOfLight); Suite.Equal(33ul, d!.TargetId); +}); +suite.Run("Only equipped role actions are attempted", () => +{ + var f = new Fake(Skill.Bravery, Skill.Dervish, Skill.EagleEyeShot); + None(Decide(Snap(null, Enemy()), f)); + var bravery = Decide(Snap(Self(60000, Statuses.RoleBravery), Enemy()), f); + IsSkill(bravery, Skill.Bravery); Suite.Equal(1ul, bravery!.TargetId); + IsSkill(Decide(Snap(Self(60000, Statuses.RoleDervish), Enemy()), f), Skill.Dervish); + IsSkill(Decide(Snap(Self(60000, Statuses.RoleEagle), Enemy()), f), Skill.EagleEyeShot); + None(Decide(Snap(Self(60000, Statuses.RoleBravery, Statuses.Bravery), Enemy()), f)); + None(Decide(Snap(Self(60000, Statuses.RoleDervish, Statuses.Dervish), Enemy()), f)); +}); +suite.Run("Own Guard stops all actions including healing and purify", () => + None(Decide(Snap(Self(1000, Statuses.Guard, 1343), Enemy()), new(Actions.All)))); +suite.Run("Pending Guard stops actions before buff arrives", () => + None(Decide(Snap(Self(1000, 1343), Enemy()) with { GuardPending = true }, new(Actions.All)))); +suite.Run("Self cleanse priority is Paean then Purify then Guard", () => +{ + var s = Snap(Self(1000, 1343), Enemy()); + IsSkill(Decide(s, new(Skill.WardensPaean, Skill.Purify, Skill.Guard, Skill.Recuperate)), Skill.WardensPaean); + IsSkill(Decide(s, new(Skill.Purify, Skill.Guard, Skill.Recuperate)), Skill.Purify); + IsSkill(Decide(s, new(Skill.Guard, Skill.Recuperate)), Skill.Guard); +}); +suite.Run("All six cleanse statuses work, disabled Purify trigger is respected", () => +{ + foreach (var id in Statuses.Controls) + { + var s = Snap(Self(60000, id)); + IsSkill(Decide(s, new(Skill.Purify)), Skill.Purify); + var c = new Settings(); c.DisabledPurifyStatuses.Add(id); + None(Decide(s, new(Skill.Purify), c)); + IsSkill(Decide(s, new(Skill.WardensPaean), c), Skill.WardensPaean); + } +}); +suite.Run("Paean acknowledgement delay does not immediately spend Purify", () => + None(Decide(Snap(Self(60000, 1343)) with { CleansePending = true }, new(Skill.Purify)))); +suite.Run("Successful cleanse stops retries after debuff disappears", () => + None(Decide(Snap(), new(Skill.WardensPaean, Skill.Purify)))); +suite.Run("Guard threshold and fallback when unavailable", () => +{ + IsSkill(Decide(Snap(Self(9000)), new(Skill.Guard, Skill.Recuperate)), Skill.Guard); + IsSkill(Decide(Snap(Self(9001)), new(Skill.Guard, Skill.Recuperate)), Skill.Recuperate); + IsSkill(Decide(Snap(Self(1000)), new(Skill.Recuperate)), Skill.Recuperate); + None(Decide(Snap(Self(1000)) with { InCombat = false }, new(Skill.Guard))); +}); +suite.Run("Recuperate works without an enemy, respects deficit and available MP", () => +{ + IsSkill(Decide(Snap(Self(45000)), new(Skill.Recuperate)), Skill.Recuperate); + None(Decide(Snap(Self(45001)), new(Skill.Recuperate))); + var f = new Fake(Skill.Recuperate) { Mp = 1999 }; + None(Decide(Snap(Self(30000)) with { Mp = 1999 }, f)); +}); +suite.Run("Five second revive protection includes boundary", () => +{ + var s = Snap(Self(1000)) with { AliveSeconds = 4.999 }; + None(Decide(s, new(Skill.Guard, Skill.Recuperate, Skill.Elixir))); + IsSkill(Decide(s with { AliveSeconds = 5 }, new(Skill.Guard)), Skill.Guard); + IsSkill(Decide(s with { Player = Self(1000, 1343) }, new(Skill.Purify)), Skill.Purify); +}); +suite.Run("Party Paean is opt-in and lowest HP ratio first", () => +{ + var ally = Enemy(20, 10000, 10, 1343) with { Enemy = false }; + var other = Enemy(21, 20000, 5, 1345) with { Enemy = false }; + var s = Snap() with { Party = [other, ally] }; + None(Decide(s, new(Skill.WardensPaean))); + var d = Decide(s, new(Skill.WardensPaean), new() { HelpParty = true }); + Suite.Equal(20ul, d!.TargetId); + IsSkill(Decide(s with { Player = Self(10000) }, new(Skill.WardensPaean, Skill.Recuperate), new() { HelpParty = true }), Skill.Recuperate); +}); +suite.Run("Elixir stationary resource thresholds and no enemy requirement", () => +{ + var f = new Fake(Skill.Elixir); + IsSkill(Decide(Snap(Self(10000)), f), Skill.Elixir); + IsSkill(Decide(Snap() with { Mp = 1000 }, f), Skill.Elixir); + None(Decide(Snap(), f)); + None(Decide(Snap(Self(10000)) with { Moving = true }, f)); +}); +suite.Run("Guard, invincible, untargetable and occluded enemies remain elixir threats", () => +{ + foreach (var enemy in new[] { Enemy(distance: 30), Enemy(distance: 30, states: [Statuses.Guard]), + Enemy(distance: 30, states: [1302]), Enemy(distance: 30) with { Targetable = false } }) + None(Decide(Snap(Self(10000), enemy), new(Skill.Elixir) { Occluded = [10] })); + IsSkill(Decide(Snap(Self(10000), Enemy(distance: 31)), new(Skill.Elixir)), Skill.Elixir); + IsSkill(Decide(Snap(Self(10000), Enemy() with { Dead = true }), new(Skill.Elixir)), Skill.Elixir); +}); +suite.Run("Sprint requires movement, no attack target, and no existing buff", () => +{ + var s = Snap() with { Moving = true }; + IsSkill(Decide(s, new(Skill.Sprint)), Skill.Sprint); + None(Decide(s with { Moving = false }, new(Skill.Sprint))); + None(Decide(s with { Enemies = [Enemy()] }, new(Skill.Sprint))); + None(Decide(s with { Player = Self(60000, Statuses.Sprint) }, new(Skill.Sprint))); +}); +suite.Run("Automatic attack off preserves common survival skills", () => +{ + var c = new Settings { AutoAttack = false }; + None(Decide(Snap(null, Enemy()), new(Skill.PowerfulShot), c)); + IsSkill(Decide(Snap(Self(30000), Enemy()), new(Skill.PowerfulShot, Skill.Recuperate), c), Skill.Recuperate); +}); +suite.Run("Emergency-only cast probe never suggests offense or routine elixir", () => +{ + None(Decide(Snap(null, Enemy()), new(Actions.All), emergency: true)); + None(Decide(Snap() with { Mp = 0 }, new(Skill.Elixir), emergency: true)); + Suite.True(Decide(Snap(Self(30000)), new(Skill.Recuperate), emergency: true)!.Emergency); +}); +suite.Run("Lowest HP across both hostile groups, not HP ratio or shield", () => +{ + var a = Enemy(11, 10000) with { MaxHp = 20000 }; + var b = Enemy(12, 20000) with { MaxHp = 100000 }; + var s = Snap(null, b, a); + Suite.Equal(11ul, Decide(s, new(Skill.PowerfulShot))!.TargetId); + Suite.Equal(12ul, Decide(s, new(Skill.PowerfulShot), new() { TargetOrder = TargetOrder.LowestHpRatio })!.TargetId); +}); +suite.Run("HP ties use distance then current target then stable ID", () => +{ + var s = Snap(null, Enemy(12, distance: 10), Enemy(11, distance: 10), Enemy(10, distance: 15)); + Suite.Equal(11ul, Decide(s, new(Skill.PowerfulShot))!.TargetId); + Suite.Equal(12ul, Decide(s with { CurrentTarget = 12 }, new(Skill.PowerfulShot))!.TargetId); +}); +suite.Run("Nearest and mark ordering are independently selectable", () => +{ + var s = Snap(null, Enemy(11, 100, 20), Enemy(12, 50000, 10) with { AttackMarker = 2 }, + Enemy(13, 60000, 15) with { AttackMarker = 1 }); + Suite.Equal(12ul, Decide(s, new(Skill.PowerfulShot), new() { TargetOrder = TargetOrder.Nearest })!.TargetId); + Suite.Equal(13ul, Decide(s, new(Skill.PowerfulShot), new() { TargetOrder = TargetOrder.AttackMarker })!.TargetId); +}); +suite.Run("Invalid candidates do not block valid next candidate", () => +{ + var good = Enemy(99, 40000); + var s = Snap(null, Enemy(10, 1) with { Dead = true }, Enemy(11, 2) with { Targetable = false }, + Enemy(12, 3) with { Enemy = false }, Enemy(13, 4, 80), Enemy(14, 5), good); + Suite.Equal(99ul, Decide(s, new(Skill.PowerfulShot) { Occluded = [14] })!.TargetId); +}); +suite.Run("Guard off excludes normal attacks, on allows them", () => +{ + var s = Snap(null, Enemy(states: [Statuses.Guard])); + None(Decide(s, new(Skill.PowerfulShot))); + IsSkill(Decide(s, new(Skill.PowerfulShot), new() { IgnoreEnemyGuard = true }), Skill.PowerfulShot); +}); +suite.Run("Eagle Eye bypasses Guard but not invincibility", () => +{ + var s = Snap(Self(60000, Statuses.RoleEagle), Enemy(states: [Statuses.Guard])); + IsSkill(Decide(s, new(Skill.EagleEyeShot)), Skill.EagleEyeShot); + None(Decide(s with { Enemies = [Enemy(states: [1302, Statuses.Guard])] }, new(Skill.EagleEyeShot), new() { IgnoreEnemyGuard = true })); +}); +suite.Run("All independent skill switches prevent corresponding request", () => +{ + var c = new Settings { DisabledSkills = Actions.All.ToHashSet(), HelpParty = true }; + None(Decide(Snap(Self(1000, 1343, Statuses.RoleEagle, Statuses.EncoreReady), Enemy()), new(Actions.All), c)); +}); +suite.Run("Dead player cannot receive any decision", () => None(Decide(Snap(Self(0), Enemy()), new(Actions.All)))); +suite.Run("Gate waits for new sequence acknowledgement, not stale ack", () => +{ + var g = new RequestGate(); g.Submitted(new(Skill.PowerfulShot, 10, "test"), 0, 10, 11); + Suite.True(g.Update(.5, 11, 10, false, false)); + Suite.True(!g.Update(.6, 11, 11, false, false)); +}); +suite.Run("Gate handles delayed sequence increment", () => +{ + var g = new RequestGate(); g.Submitted(new(Skill.PowerfulShot, 10, "test"), 0, 10, 10); + Suite.True(g.Update(.5, 10, 10, false, false)); + Suite.True(!g.Update(.6, 11, 11, false, false)); +}); +suite.Run("Gate sequence rollover", () => +{ + var g = new RequestGate(); g.Submitted(new(Skill.PowerfulShot, 10, "test"), 0, 65535, 0); + Suite.True(g.Update(.2, 0, 65535, false, false)); + Suite.True(!g.Update(.3, 0, 0, false, false)); +}); +suite.Run("Guard and cleanse guard against delayed status replication", () => +{ + foreach (var skill in new[] { Skill.Guard, Skill.Purify, Skill.WardensPaean }) + { + var g = new RequestGate(); g.Submitted(new(skill, 1, "test"), 0, 1, 2); + Suite.True(g.Update(.1, 2, 2, false, false)); + Suite.True(!g.Update(.5, 2, 2, false, false)); + } +}); +suite.Run("Actual Guard or changed debuff confirms pending status action", () => +{ + var g = new RequestGate(); g.Submitted(new(Skill.Guard, 1, "test"), 0, 1, 2); + Suite.True(!g.Update(.1, 2, 1, true, false)); + g.Submitted(new(Skill.WardensPaean, 1, "test"), 0, 1, 2); + Suite.True(!g.Update(.1, 2, 1, false, true)); +}); +suite.Run("Timeout causes bounded per-skill per-target backoff", () => +{ + var g = new RequestGate(); g.Submitted(new(Skill.PowerfulShot, 10, "test"), 0, 1, 2); + Suite.True(!g.Update(2, 2, 1, false, false)); + Suite.True(g.IsBlocked(Skill.PowerfulShot, 10, 2.1)); + Suite.True(!g.IsBlocked(Skill.PowerfulShot, 11, 2.1)); + Suite.True(!g.IsBlocked(Skill.HarmonicArrow, 10, 2.1)); + Suite.True(!g.IsBlocked(Skill.PowerfulShot, 10, 2.5)); +}); +suite.Run("Reset removes pending actions and stale backoff", () => +{ + var g = new RequestGate(); var d = new ActionDecision(Skill.Guard, 1, "test"); + g.Fail(d, 1, "failure"); g.Submitted(d, 1, 1, 2); g.Reset(); + Suite.True(g.Pending == null && !g.IsBlocked(Skill.Guard, 1, 1.1)); + Suite.True(!g.Update(1.1, 2, 1, false, false)); +}); + +suite.Run("Finisher Eagle preempts normal GCD, buffs and LB", () => +{ + var s = Snap(Self(60000, Statuses.RoleEagle), Enemy(hp: 9000)) with { LimitFull = true }; + var d = Decide(s, new(Skill.EagleEyeShot, Skill.ApexArrow, Skill.FinalFantasia)); + IsSkill(d, Skill.EagleEyeShot); Suite.True(d!.Reason.Contains("收割")); +}); +suite.Run("Finisher changes target to lowest effective HP in Eagle range", () => +{ + var shielded = Enemy(10, 1000, 10) with { ShieldHp = 20000 }; + var distant = Enemy(11, 8000, 38, Statuses.Guard); + var near = Enemy(12, 9000, 10); + var s = Snap(Self(60000, Statuses.RoleEagle), shielded, near, distant) with { CurrentTarget = 10 }; + Suite.Equal(11ul, Decide(s, new(Skill.EagleEyeShot, Skill.PowerfulShot))!.TargetId); +}); +suite.Run("Finisher eligibility includes shield, exact threshold, and invulnerability", () => +{ + var s = Snap(Self(60000, Statuses.RoleEagle), Enemy(hp: 8000) with { ShieldHp = 2000 }); + var c = new Settings { EagleStrategy = EagleStrategy.FinisherOnly }; + IsSkill(Decide(s, new(Skill.EagleEyeShot), c), Skill.EagleEyeShot); + None(Decide(s with { Enemies = [Enemy(hp: 8000) with { ShieldHp = 2001 }] }, new(Skill.EagleEyeShot), c)); + None(Decide(s with { Enemies = [Enemy(hp: 8000) with { Invulnerable = true }] }, new(Skill.EagleEyeShot), c)); +}); +suite.Run("First strategy uses Eagle normally without finisher; reserve strategy holds it", () => +{ + var s = Snap(Self(60000, Statuses.RoleEagle), Enemy()); + IsSkill(Decide(s, new(Skill.EagleEyeShot)), Skill.EagleEyeShot); + None(Decide(s, new(Skill.EagleEyeShot), new() { EagleStrategy = EagleStrategy.FinisherOnly })); + IsSkill(Decide(s, new(Skill.EagleEyeShot, Skill.PowerfulShot)), Skill.PowerfulShot); +}); +suite.Run("Survival and shared native availability still take precedence over finishing", () => +{ + var s = Snap(Self(1000, Statuses.RoleEagle, 1343), Enemy(hp: 1000)); + IsSkill(Decide(s, new(Skill.EagleEyeShot, Skill.Purify)), Skill.Purify); + IsSkill(Decide(s, new(Skill.EagleEyeShot, Skill.Guard)), Skill.Guard); + IsSkill(Decide(s, new(Skill.EagleEyeShot, Skill.Recuperate)), Skill.Recuperate); + IsSkill(Decide(s with { Player = Self(60000, Statuses.RoleEagle) }, new(Skill.PowerfulShot)), Skill.PowerfulShot); + None(Decide(s, new(Skill.EagleEyeShot), emergency: true)); +}); +suite.Run("Finisher setting is adjustable and does not change ordinary HP sorting", () => +{ + var s = Snap(Self(60000, Statuses.RoleEagle), Enemy(10, 11000), Enemy(11, 12000)); + var c = new Settings { EagleFinisherHp = 11000, EagleStrategy = EagleStrategy.FinisherOnly }; + IsSkill(Decide(s, new(Skill.EagleEyeShot, Skill.PowerfulShot), c), Skill.EagleEyeShot); + var shielded = Enemy(10, 5000) with { ShieldHp = 50000 }; + Suite.Equal(10ul, Decide(s with { Enemies = [shielded, Enemy(11, 10000)] }, new(Skill.PowerfulShot))!.TargetId); +}); +suite.Run("Eagle range counts as an attack target even while Eagle is on cooldown", () => +{ + var s = Snap(Self(60000, Statuses.RoleEagle), Enemy(distance: 38)) with { Moving = true }; + None(Decide(s, new(Skill.Sprint))); + IsSkill(Decide(s with { Player = Self() }, new(Skill.Sprint)), Skill.Sprint); +}); +suite.Run("Enabled only in a started Frontline match as living unmounted Bard", () => +{ + var active = new RuntimeConditions(true, true, false, BattlefieldMode.Frontline, true, false, true, true, false, false, true); + Suite.True(ExecutionPolicy.PauseReason(active) == null); + RuntimeConditions[] inactive = [active with { Enabled = false }, active with { LoggedIn = false }, + active with { ChangingArea = true }, active with { Mode = BattlefieldMode.None }, active with { DutyStarted = false }, + active with { DutyCompleted = true }, active with { Bard = false }, active with { Alive = false }, + active with { Mounted = true }, active with { Occupied = true }, active with { CoreReady = false }]; + foreach (var s in inactive) Suite.True(ExecutionPolicy.PauseReason(s) != null); +}); +suite.Run("Dispatcher never overrides a queued action, animation lock, active cast, pending request or Guard", () => +{ + Suite.True(ExecutionPolicy.CanDispatch(false, 0, false, false, false)); + Suite.True(!ExecutionPolicy.CanDispatch(true, 0, false, false, false)); + Suite.True(!ExecutionPolicy.CanDispatch(false, .01f, false, false, false)); + Suite.True(!ExecutionPolicy.CanDispatch(false, 0, true, false, false)); + Suite.True(!ExecutionPolicy.CanDispatch(false, 0, false, true, false)); + Suite.True(!ExecutionPolicy.CanDispatch(false, 0, false, false, true)); +}); +suite.Run("Weaving reserves shared GCD boundary without blocking off-GCD windows", () => +{ + Suite.True(ExecutionPolicy.CanWeave(0)); Suite.True(ExecutionPolicy.CanWeave(.65f)); + Suite.True(ExecutionPolicy.CanWeave(2.4f)); Suite.True(!ExecutionPolicy.CanWeave(.64f)); +}); +suite.Run("Only matching action, target and native sequence establishes cast ownership", () => +{ + Suite.True(ExecutionPolicy.OwnsCast(29391, 10, 5, 29391, 10, 5)); + Suite.True(!ExecutionPolicy.OwnsCast(29391, 10, 5, 29391, 10, 6)); + Suite.True(!ExecutionPolicy.OwnsCast(29391, 10, 5, 29391, 11, 5)); + Suite.True(!ExecutionPolicy.OwnsCast(29391, 10, 5, 29055, 10, 5)); +}); +suite.Run("Enemy Guard cancellation follows the same ignore switch as target selection", () => +{ + Suite.True(ExecutionPolicy.ShouldCancelCast(Skill.PowerfulShot, true, false, true, false, null, true, false)); + Suite.True(!ExecutionPolicy.ShouldCancelCast(Skill.PowerfulShot, true, false, true, true, null, true, false)); + Suite.True(ExecutionPolicy.ShouldCancelCast(Skill.PowerfulShot, true, true, false, true, null, true, false)); +}); +suite.Run("Manual casts are untouched; plugin offensive casts yield to survival", () => +{ + var heal = new ActionDecision(Skill.Recuperate, 1, "heal", true); + Suite.True(!ExecutionPolicy.ShouldCancelCast(Skill.PowerfulShot, false, true, true, false, heal, true, true)); + Suite.True(ExecutionPolicy.ShouldCancelCast(Skill.PowerfulShot, true, false, false, false, heal, true, false)); +}); +suite.Run("Elixir completes safely when a routine heal comes off cooldown", () => +{ + var heal = new ActionDecision(Skill.Recuperate, 1, "heal", true); + Suite.True(!ExecutionPolicy.ShouldCancelCast(Skill.Elixir, true, false, false, false, heal, false, false)); + Suite.True(ExecutionPolicy.ShouldCancelCast(Skill.Elixir, true, false, false, false, heal, true, false)); + var purify = new ActionDecision(Skill.Purify, 1, "cleanse", true); + Suite.True(ExecutionPolicy.ShouldCancelCast(Skill.Elixir, true, false, false, false, purify, false, true)); +}); +suite.Run("Long elixir cast is not classified as request timeout", () => +{ + var g = new RequestGate(); g.Submitted(new(Skill.Elixir, 1, "drink"), 0, 1, 2); + Suite.True(g.Update(3, 2, 1, false, false, ownedCastInProgress: true)); + Suite.True(!g.Update(4.5, 2, 2, false, false)); + Suite.True(!g.IsBlocked(Skill.Elixir, 1, 4.5)); +}); + +suite.Run("Guard is never classified as generic invulnerability", () => +{ + Suite.True(!Statuses.IsInvulnerability(Statuses.Guard, 15024)); + Suite.True(Statuses.IsInvulnerability(1302, 0)); + Suite.True(Statuses.IsInvulnerability(99999, 15024)); + Suite.True(!Statuses.IsInvulnerability(Statuses.March, 0)); +}); +suite.Run("Target planning updates during acknowledgement wait without replacing accepted target", () => +{ + var gate = new RequestGate(); + gate.Submitted(new(Skill.PowerfulShot, 10, "already sent"), 0, 1, 2); + var s = Snap(null, Enemy(10, 40000), Enemy(11, 5000)) with { CurrentTarget = 10 }; + var f = new Fake(Skill.PowerfulShot); + Suite.True(gate.Update(.05, 2, 1, false, false)); + Suite.Equal(11ul, TargetSelector.PlanTarget(s, new(), f, f)!.Id); + Suite.Equal(10ul, gate.Pending!.TargetId); + Suite.True(!ExecutionPolicy.CanDispatch(false, 0, false, gate.Pending != null, false)); +}); +suite.Run("Target planning follows current HP changes without waiting for target death", () => +{ + var f = new Fake(Skill.PowerfulShot); + var s = Snap(null, Enemy(10, 5000), Enemy(11, 10000)); + Suite.Equal(10ul, TargetSelector.PlanTarget(s, new(), f, f)!.Id); + s = s with { Enemies = [Enemy(10, 20000), Enemy(11, 10000)], CurrentTarget = 10 }; + Suite.Equal(11ul, TargetSelector.PlanTarget(s, new(), f, f)!.Id); +}); +suite.Run("Guard-only target remains available to Eagle outside normal job range", () => +{ + var s = Snap(Self(60000, Statuses.RoleEagle), Enemy(22, 30000, 38, Statuses.Guard)); + var f = new Fake(Skill.EagleEyeShot); + Suite.Equal(22ul, TargetSelector.PlanTarget(s, new(), f, f)!.Id); + IsSkill(Decide(s, f), Skill.EagleEyeShot); +}); +suite.Run("Guard finisher planning preempts unguarded ordinary target", () => +{ + var s = Snap(Self(60000, Statuses.RoleEagle), Enemy(10, 7000) with { ShieldHp = 10000 }, Enemy(11, 8000, 38, Statuses.Guard)); + var f = new Fake(Skill.EagleEyeShot, Skill.PowerfulShot); + Suite.Equal(11ul, TargetSelector.PlanTarget(s with { CurrentTarget = 10 }, new(), f, f)!.Id); +}); +suite.Run("Eagle on cooldown does not lure ordinary attacks onto Guard", () => +{ + var s = Snap(Self(60000, Statuses.RoleEagle), Enemy(10, 5000, 20, Statuses.Guard), Enemy(11, 40000)); + var f = new Fake(Skill.PowerfulShot); + Suite.Equal(11ul, TargetSelector.PlanTarget(s, new(), f, f)!.Id); + Suite.True(TargetSelector.PlanTarget(s with { Enemies = [s.Enemies[0]] }, new(), f, f) == null); +}); +suite.Run("Target planning respects own Guard, auto-attack switch and manual Eagle disable", () => +{ + var s = Snap(Self(60000, Statuses.RoleEagle), Enemy(states: [Statuses.Guard])); + var f = new Fake(Skill.EagleEyeShot); + Suite.True(TargetSelector.PlanTarget(s, new() { AutoAttack = false }, f, f) == null); + Suite.True(TargetSelector.PlanTarget(s with { GuardPending = true }, new(), f, f) == null); + Suite.True(TargetSelector.PlanTarget(s with { Player = Self(60000, Statuses.Guard, Statuses.RoleEagle) }, new(), f, f) == null); + Suite.True(TargetSelector.PlanTarget(s, new() { DisabledSkills = [Skill.EagleEyeShot] }, f, f) == null); +}); + +Actor Ice(ulong id = 50, uint hp = 1000) => Enemy(id, hp, 10) with { IsObjective = true, Name = "Ice", MaxHp = 1000000 }; +Actor TimedSelf(params (uint Status, float Seconds)[] procs) => Self(60000, procs.Select(p => p.Status).ToArray()) + with { StatusRemaining = procs.ToDictionary(p => p.Status, p => p.Seconds) }; + +suite.Run("Objectives enabled by default and ordinary rotation attacks ice", () => +{ + Suite.True(new Settings().AttackObjectives); + var d = Decide(Snap(null, Ice()), new(Skill.PowerfulShot)); + IsSkill(d, Skill.PowerfulShot); Suite.Equal(50ul, d!.TargetId); + IsSkill(Decide(Snap(Self(60000, Statuses.BlastReady), Ice()), new(Skill.BlastArrow)), Skill.BlastArrow); +}); +suite.Run("Players outrank lower HP or marked ice under every sorting mode", () => +{ + foreach (var order in Enum.GetValues()) + { + var s = Snap(null, Ice() with { AttackMarker = 1 }, Enemy(10, 50000, 20)); + Suite.Equal(10ul, Decide(s, new(Skill.PowerfulShot), new() { TargetOrder = order })!.TargetId); + var f = new Fake(Skill.PowerfulShot); + Suite.Equal(10ul, TargetSelector.PlanTarget(s, new() { TargetOrder = order }, f, f)!.Id); + } +}); +suite.Run("Global player priority prevents a higher priority ice skill preempting a player skill", () => +{ + var f = new Fake(Skill.ApexArrow, Skill.SilentNocturne) { Blocked = [(Skill.ApexArrow, 10)] }; + var d = Decide(Snap(null, Ice(), Enemy()), f); + IsSkill(d, Skill.SilentNocturne); Suite.Equal(10ul, d!.TargetId); +}); +suite.Run("Invalid players permit objective fallback", () => +{ + foreach (var player in new[] { Enemy() with { Dead = true }, Enemy() with { Targetable = false }, + Enemy(distance: 80), Enemy(states: [Statuses.Guard]), Enemy() with { Invulnerable = true }, Enemy() }) + { + var f = new Fake(Skill.PowerfulShot) { Occluded = player.Status.Count == 0 && player.Alive && player.Targetable && !player.Invulnerable && player.Distance == 20 ? [10] : [] }; + Suite.Equal(50ul, Decide(Snap(null, player, Ice()), f)!.TargetId); + } +}); +suite.Run("Eagle player priority follows readiness and equipped role", () => +{ + var s = Snap(Self(60000, Statuses.RoleEagle), Ice(), Enemy(10, 30000, 38, Statuses.Guard)); + var d = Decide(s, new(Skill.EagleEyeShot, Skill.PowerfulShot)); + IsSkill(d, Skill.EagleEyeShot); Suite.Equal(10ul, d!.TargetId); + Suite.Equal(50ul, Decide(s, new(Skill.PowerfulShot))!.TargetId); + Suite.Equal(50ul, Decide(s with { Player = Self() }, new(Skill.EagleEyeShot, Skill.PowerfulShot))!.TargetId); + Suite.Equal(50ul, Decide(s, new(Skill.EagleEyeShot, Skill.PowerfulShot), new() { EagleStrategy = EagleStrategy.FinisherOnly })!.TargetId); +}); +suite.Run("Eagle never spends on objectives even in finisher range", () => +{ + foreach (var strategy in Enum.GetValues()) + { + var s = Snap(Self(60000, Statuses.RoleEagle), Ice()); + var f = new Fake(Skill.EagleEyeShot); + var c = new Settings { EagleStrategy = strategy, DisabledSkills = Actions.All.Where(a => a != Skill.EagleEyeShot).ToHashSet() }; + None(Decide(s, f, c)); + Suite.True(TargetSelector.EagleFinisher(s, c, f) == null); + Suite.True(TargetSelector.PlanTarget(s, c, f, f) == null); + } +}); +suite.Run("Objective switch excludes ice from offense and display planning", () => +{ + var s = Snap(null, Ice()); var c = new Settings { AttackObjectives = false }; var f = new Fake(Skill.PowerfulShot); + None(Decide(s, f, c)); Suite.True(TargetSelector.PlanTarget(s, c, f, f) == null); + Suite.Equal(10ul, Decide(s with { Enemies = [Ice(), Enemy()] }, f, c)!.TargetId); + IsSkill(Decide(s with { Player = Self(30000) }, new(Skill.Recuperate), c), Skill.Recuperate); +}); +suite.Run("Dead inaccessible or immune objectives are skipped", () => +{ + foreach (var ice in new[] { Ice() with { Dead = true }, Ice() with { Targetable = false }, + Ice() with { Invulnerable = true }, Ice() with { Hp = 0 }, Ice() with { Distance = 80 } }) + None(Decide(Snap(null, ice), new(Skill.PowerfulShot))); + None(Decide(Snap(null, Ice()), new(Skill.PowerfulShot) { Occluded = [50] })); +}); +suite.Run("Ice does not prevent safe elixir but protected players still do", () => +{ + var s = Snap(Self(10000), Ice()); + IsSkill(Decide(s, new(Skill.Elixir, Skill.PowerfulShot)), Skill.Elixir); + Suite.True(!TargetSelector.HasNearbyThreat(s)); + Suite.True(TargetSelector.HasNearbyThreat(s with { Enemies = [Ice(), Enemy(states: [Statuses.Guard])] })); + None(Decide(s with { Enemies = [Ice(), Enemy(states: [Statuses.Guard])] }, new(Skill.Elixir))); +}); +suite.Run("Objective target planning returns to players as they enter range", () => +{ + var f = new Fake(Skill.PowerfulShot); var s = Snap(null, Ice(), Enemy(distance: 80)); + Suite.Equal(50ul, TargetSelector.PlanTarget(s, new(), f, f)!.Id); + Suite.Equal(10ul, TargetSelector.PlanTarget(s with { Enemies = [Ice(), Enemy()], CurrentTarget = 50 }, new(), f, f)!.Id); +}); +suite.Run("Display follows objective fallback when all player attacks are unavailable", () => +{ + var f = new Fake(Skill.PowerfulShot) { Blocked = [(Skill.PowerfulShot, 10)] }; + var s = Snap(null, Ice(), Enemy()); + Suite.Equal(50ul, Decide(s, f)!.TargetId); + Suite.Equal(50ul, TargetSelector.PlanTarget(s, new(), f, f)!.Id); +}); +suite.Run("Expiring Pitch Blast and Encore preempt ordinary damage and LB", () => +{ + foreach (var (skill, status) in new[] { (Skill.PitchPerfect, Statuses.Repertoire), + (Skill.BlastArrow, Statuses.BlastReady), (Skill.EncoreOfLight, Statuses.EncoreReady) }) + { + var s = Snap(TimedSelf((status, 3)), Enemy()) with { LimitFull = true }; + IsSkill(Decide(s, new(skill, Skill.HarmonicArrow, Skill.FinalFantasia)), skill); + } +}); +suite.Run("Nonexpiring or unknown proc duration preserves normal RSR ordering", () => +{ + foreach (var seconds in new[] { 3.01f, 10f, 0f, -1f, float.NaN }) + IsSkill(Decide(Snap(TimedSelf((Statuses.Repertoire, seconds)), Enemy()), new(Skill.PitchPerfect, Skill.HarmonicArrow)), Skill.HarmonicArrow); + IsSkill(Decide(Snap(Self(60000, Statuses.Repertoire), Enemy()), new(Skill.PitchPerfect, Skill.HarmonicArrow)), Skill.HarmonicArrow); +}); +suite.Run("Earliest expiration wins and unusable expiring skills fall through", () => +{ + var s = Snap(TimedSelf((Statuses.Repertoire, 2), (Statuses.BlastReady, 1), (Statuses.EncoreReady, .5f)), Enemy()); + IsSkill(Decide(s, new(Skill.PitchPerfect, Skill.BlastArrow, Skill.EncoreOfLight)), Skill.EncoreOfLight); + IsSkill(Decide(s, new(Skill.PitchPerfect, Skill.BlastArrow)), Skill.BlastArrow); + IsSkill(Decide(s, new(Skill.HarmonicArrow, Skill.PitchPerfect), new() { DisabledSkills = [Skill.PitchPerfect] }), Skill.HarmonicArrow); +}); +suite.Run("Survival and player Eagle finishers outrank expiration rescue", () => +{ + var s = Snap(TimedSelf((Statuses.RoleEagle, 100), (Statuses.Repertoire, 1)), Enemy(hp: 1000)); + IsSkill(Decide(s, new(Skill.PitchPerfect, Skill.EagleEyeShot)), Skill.EagleEyeShot); + IsSkill(Decide(s with { Player = s.Player with { Hp = 30000 } }, new(Skill.PitchPerfect, Skill.Recuperate)), Skill.Recuperate); + None(Decide(s, new(Skill.PitchPerfect), new() { AutoAttack = false })); +}); +suite.Run("Expiration rescue also damages objectives without defeating player priority", () => +{ + var s = Snap(TimedSelf((Statuses.BlastReady, 1)), Ice()); + Suite.Equal(50ul, Decide(s, new(Skill.BlastArrow))!.TargetId); + Suite.Equal(10ul, Decide(s with { Enemies = [Ice(), Enemy()] }, new(Skill.BlastArrow))!.TargetId); +}); +suite.Run("Shared recast identification includes additional groups", () => +{ + Suite.True(ExecutionPolicy.SharesGcd(57, -1, 57)); + Suite.True(ExecutionPolicy.SharesGcd(10, 57, 57)); + Suite.True(!ExecutionPolicy.SharesGcd(10, -1, 57)); + Suite.True(!ExecutionPolicy.SharesGcd(-1, -1, -1)); +}); +suite.Run("Inactive or completed recasts cannot create a phantom weave lock", () => +{ + Suite.Equal(0f, ExecutionPolicy.RecastRemaining(false, 2.4f, 2)); + Suite.True(ExecutionPolicy.CanWeave(ExecutionPolicy.RecastRemaining(false, 2.4f, 2))); + Suite.True(!ExecutionPolicy.CanWeave(ExecutionPolicy.RecastRemaining(true, 2.4f, 2))); + Suite.Equal(0f, ExecutionPolicy.RecastRemaining(true, 2.4f, 3)); +}); + +suite.Run("Encore fires before ready filler and proc generators without waiting for expiry", () => +{ + var s = Snap(TimedSelf((Statuses.EncoreReady, 29)), Enemy(distance: 8)); + var d = Decide(s, new(Skill.PowerfulShot, Skill.ApexArrow, Skill.HarmonicArrow, Skill.RepellingShot, Skill.SilentNocturne, Skill.EncoreOfLight)); + IsSkill(d, Skill.EncoreOfLight); + Suite.True(d!.Reason.Contains("极限技后续")); +}); +suite.Run("Encore cannot be starved by short weave windows under stacked haste", () => +{ + foreach (var remaining in new[] { .01f, .3f, .64f }) + { + var f = new Fake(Skill.PowerfulShot, Skill.EncoreOfLight) { SharedGcdRemaining = remaining }; + IsSkill(Decide(Snap(TimedSelf((Statuses.EncoreReady, 25)), Enemy()), f), Skill.EncoreOfLight); + Suite.True(!ExecutionPolicy.CanWeave(remaining, Skill.SilentNocturne)); + } +}); +suite.Run("Encore still yields to survival and Eagle finishers but outranks ordinary Eagle", () => +{ + var s = Snap(Self(60000, Statuses.EncoreReady, Statuses.RoleEagle), Enemy()); + IsSkill(Decide(s, new(Skill.EncoreOfLight, Skill.EagleEyeShot)), Skill.EncoreOfLight); + IsSkill(Decide(s with { Enemies = [Enemy(hp: 9000)] }, new(Skill.EncoreOfLight, Skill.EagleEyeShot)), Skill.EagleEyeShot); + IsSkill(Decide(s with { Player = s.Player with { Hp = 30000 } }, new(Skill.EncoreOfLight, Skill.Recuperate)), Skill.Recuperate); +}); +suite.Run("Encore cannot bypass its status toggle target or native availability", () => +{ + var s = Snap(Self(60000, Statuses.EncoreReady), Enemy()); + IsSkill(Decide(s with { Player = Self() }, new(Skill.EncoreOfLight, Skill.PowerfulShot)), Skill.PowerfulShot); + IsSkill(Decide(s, new(Skill.EncoreOfLight, Skill.PowerfulShot), new() { DisabledSkills = [Skill.EncoreOfLight] }), Skill.PowerfulShot); + IsSkill(Decide(s, new(Skill.PowerfulShot)), Skill.PowerfulShot); + None(Decide(s with { Enemies = [Enemy(states: [Statuses.Guard])] }, new(Skill.EncoreOfLight))); + None(Decide(s with { Enemies = [Enemy(distance: 30)] }, new(Skill.EncoreOfLight))); + None(Decide(s, new(Skill.EncoreOfLight) { Occluded = [10] })); + None(Decide(s with { GuardPending = true }, new(Skill.EncoreOfLight))); +}); +suite.Run("Encore uses players before ice and does not divert available player attacks", () => +{ + var s = Snap(Self(60000, Statuses.EncoreReady), Ice(), Enemy()); + Suite.Equal(10ul, Decide(s, new(Skill.EncoreOfLight))!.TargetId); + var d = Decide(s, new(Skill.EncoreOfLight, Skill.PowerfulShot) { Blocked = [(Skill.EncoreOfLight, 10)] }); + IsSkill(d, Skill.PowerfulShot); Suite.Equal(10ul, d!.TargetId); + Suite.Equal(50ul, Decide(s with { Enemies = [Ice()] }, new(Skill.EncoreOfLight))!.TargetId); +}); +suite.Run("Encore remains usable at zero player MP and requires the transformed LB ID", () => +{ + var s = Snap(Self(60000, Statuses.EncoreReady), Enemy()) with { Mp = 0 }; + IsSkill(Decide(s, new(Skill.EncoreOfLight) { Mp = 0 }), Skill.EncoreOfLight); + None(Decide(s, new(Skill.EncoreOfLight) { AdjustedIds = { [Skill.EncoreOfLight] = (uint)Skill.FinalFantasia } })); +}); +suite.Run("All known Harmonic variants survive native ID validation and reach rotation", () => +{ + foreach (var id in new uint[] { 41464, 41465, 41466, 41964 }) + { + var f = new Fake(Skill.HarmonicArrow, Skill.PowerfulShot) { AdjustedIds = { [Skill.HarmonicArrow] = id } }; + IsSkill(Decide(Snap(null, Enemy()), f), Skill.HarmonicArrow); + f.Charges[Skill.HarmonicArrow] = 0; + IsSkill(Decide(Snap(null, Enemy()), f), Skill.PowerfulShot); + } +}); +suite.Run("Harmonic variant acceptance does not permit unknown or unrelated transformations", () => +{ + foreach (var id in new uint[] { 0, 41965, (uint)Skill.EncoreOfLight }) + IsSkill(Decide(Snap(null, Enemy()), new(Skill.HarmonicArrow, Skill.PowerfulShot) + { AdjustedIds = { [Skill.HarmonicArrow] = id } }), Skill.PowerfulShot); + Suite.True(!Actions.Matches(Skill.PowerfulShot, (uint)Skill.PitchPerfect)); + Suite.True(!Actions.Matches(Skill.ApexArrow, (uint)Skill.BlastArrow)); + Suite.True(!Actions.Matches(Skill.EncoreOfLight, 41964)); +}); + +suite.Run("Battlefield mode identification supports Frontline and Rival Wings only", () => +{ + Suite.Equal(BattlefieldMode.Frontline, Battlefield.Identify(18, true)); + Suite.Equal(BattlefieldMode.RivalWings, Battlefield.Identify(39, true)); + foreach (var use in new uint[] { 0, 3, 12, 28, 37, 99 }) + Suite.Equal(BattlefieldMode.None, Battlefield.Identify(use, true)); + Suite.Equal(BattlefieldMode.None, Battlefield.Identify(39, false)); + Suite.Equal(BattlefieldMode.None, Battlefield.Identify(18, false)); +}); +suite.Run("Rival Wings runs on foot and retains all ordinary pause gates", () => +{ + var active = new RuntimeConditions(true, true, false, BattlefieldMode.RivalWings, true, false, true, true, false, false, true); + Suite.True(ExecutionPolicy.PauseReason(active) == null); + foreach (var stopped in new[] { active with { Enabled = false }, active with { LoggedIn = false }, + active with { ChangingArea = true }, active with { DutyStarted = false }, active with { DutyCompleted = true }, + active with { Bard = false }, active with { Alive = false }, active with { Mounted = true }, + active with { Occupied = true }, active with { CoreReady = false }, active with { Piloting = true } }) + Suite.True(ExecutionPolicy.PauseReason(stopped) != null); + Suite.True(ExecutionPolicy.PauseReason(active with { Piloting = true })!.Contains("机甲")); +}); +suite.Run("Mech detection covers condition status and content armor with mode isolation", () => +{ + Suite.True(Battlefield.IsPiloting(BattlefieldMode.RivalWings, true, false, 0)); + Suite.True(Battlefield.IsPiloting(BattlefieldMode.RivalWings, false, true, 0)); + Suite.True(Battlefield.IsPiloting(BattlefieldMode.RivalWings, false, false, 1)); + Suite.True(!Battlefield.IsPiloting(BattlefieldMode.RivalWings, false, false, 0)); + Suite.True(!Battlefield.IsPiloting(BattlefieldMode.Frontline, true, true, 1)); + Suite.True(!Battlefield.IsPiloting(BattlefieldMode.None, true, true, 1)); +}); +suite.Run("Battle NPC candidates exclude pets companions owned summons and noncombat kinds", () => +{ + foreach (byte kind in new byte[] { 0, 1, 5 }) + { + Suite.True(Battlefield.IsBattleNpcCandidate(kind, 0)); + Suite.True(Battlefield.IsBattleNpcCandidate(kind, 0xE0000000)); + Suite.True(!Battlefield.IsBattleNpcCandidate(kind, 12345)); + } + foreach (byte kind in new byte[] { 2, 3, 4, 6, 7, 9 }) + Suite.True(!Battlefield.IsBattleNpcCandidate(kind, 0)); +}); +suite.Run("Rival Wings hostile NPCs block elixir even behind walls or while untargetable", () => +{ + foreach (var npc in new[] { Ice() with { ThreateningObjective = true }, + Ice() with { ThreateningObjective = true, Targetable = false }, + Ice() with { ThreateningObjective = true, Invulnerable = true } }) + { + var s = Snap(Self(10000), npc); + Suite.True(TargetSelector.HasNearbyThreat(s)); + None(Decide(s, new(Skill.Elixir) { Occluded = [npc.Id] }, new() { AttackObjectives = false })); + } +}); +suite.Run("Distant dead and friendly NPCs do not block elixir and ice behavior is unchanged", () => +{ + foreach (var npc in new[] { Ice(), Ice() with { ThreateningObjective = true, Distance = 31 }, + Ice() with { ThreateningObjective = true, Dead = true }, Ice() with { ThreateningObjective = true, Enemy = false } }) + IsSkill(Decide(Snap(Self(10000), npc), new(Skill.Elixir)), Skill.Elixir); +}); +suite.Run("Players outrank enemy mech and NPC targets while Eagle avoids mechs", () => +{ + var mech = Enemy(50, 1000, 10, Statuses.Warmachina) with { IsObjective = true, ThreateningObjective = true }; + var s = Snap(Self(60000, Statuses.RoleEagle), mech, Enemy(10, 50000)); + Suite.Equal(10ul, Decide(s, new(Skill.PowerfulShot, Skill.EagleEyeShot))!.TargetId); + var onlyMech = s with { Enemies = [mech] }; + IsSkill(Decide(onlyMech, new(Skill.PowerfulShot, Skill.EagleEyeShot)), Skill.PowerfulShot); + None(Decide(onlyMech, new(Skill.EagleEyeShot))); + None(Decide(onlyMech, new(Skill.PowerfulShot), new() { AttackObjectives = false })); +}); +suite.Run("Unattackable structures and friendly battle NPCs cannot receive attacks", () => +{ + foreach (var npc in new[] { Ice() with { Enemy = false }, Ice() with { Invulnerable = true }, + Ice() with { Targetable = false } }) + None(Decide(Snap(null, npc with { ThreateningObjective = true }), new(Skill.PowerfulShot, Skill.HarmonicArrow))); +}); +suite.Run("Rival Wings NPC threats let emergency healing interrupt unsafe elixir", () => +{ + var heal = new ActionDecision(Skill.Recuperate, 1, "heal", true); + var s = Snap(Self(10000), Ice() with { ThreateningObjective = true }); + Suite.True(ExecutionPolicy.ShouldCancelCast(Skill.Elixir, true, false, false, false, + heal, TargetSelector.HasNearbyThreat(s), false)); + Suite.True(!ExecutionPolicy.ShouldCancelCast(Skill.Elixir, true, false, false, false, + heal, TargetSelector.HasNearbyThreat(s with { Enemies = [Ice()] }), false)); +}); + +OptimizationTests.Run(suite); +PressureTests.Run(suite); +suite.Finish(); + +sealed class Fake(params Skill[] ready) : IActionAvailability, ITargetProbe +{ + public Dictionary Charges { get; } = []; + public Dictionary AdjustedIds { get; } = []; + public float? SharedGcdRemaining { get; init; } + public HashSet Occluded { get; init; } = []; + public HashSet<(Skill, ulong)> Blocked { get; init; } = []; + public int Mp { get; init; } = 10000; + public bool CanTarget(Skill skill, Actor a) => a.Alive && a.Targetable && !Occluded.Contains(a.Id) + && (!Actions.IsHostile(skill) || a.Distance <= (skill == Skill.RepellingShot ? 10 : skill == Skill.SilentNocturne ? 20 : skill == Skill.EagleEyeShot ? 40 : 25)); + public bool CanUse(Skill skill, Actor a) => ready.Contains(skill) && !Blocked.Contains((skill, a.Id)) && CanTarget(skill, a) + && Actions.Matches(skill, AdjustedIds.GetValueOrDefault(skill, (uint)skill)) + && (SharedGcdRemaining is not { } remain || skill is not (Skill.EncoreOfLight or Skill.SilentNocturne or Skill.RepellingShot) + || ExecutionPolicy.CanWeave(remain, skill)) + && (!Charges.TryGetValue(skill, out var count) || count > 0) + && (skill is not (Skill.Recuperate or Skill.Purify) || Mp >= 2000); +} +sealed class Suite +{ + private int passed, failed; + public void Run(string name, Action test) + { + try { test(); passed++; Console.WriteLine($"PASS {name}"); } + catch (Exception e) { failed++; Console.WriteLine($"FAIL {name}: {e.Message}"); } + } + public static void True(bool b, string message = "Assertion failed") { if (!b) throw new Exception(message); } + public static void Equal(T expected, T actual) { if (!EqualityComparer.Default.Equals(expected, actual)) throw new Exception($"Expected {expected}, got {actual}"); } + public void Finish() { Console.WriteLine($"{passed} passed, {failed} failed"); Environment.ExitCode = failed == 0 ? 0 : 1; } +} diff --git a/VERIFICATION.md b/VERIFICATION.md new file mode 100644 index 0000000..a6cbd58 --- /dev/null +++ b/VERIFICATION.md @@ -0,0 +1,38 @@ +# 验证记录 + +日期:2026-10-07;版本:0.2.1。 + +## 已完成 + +- 使用用户指定的 `C:\Users\AinaSnow\AppData\Roaming\XIVLauncher\addon\Hooks\dev` 成品 DLL 编译。 +- Release 构建:0 警告、0 错误;不引用 Dalamud、FFXIVClientStructs、RSR 的源码项目。 +- 142 项离线场景测试通过。测试工程直接链接生产 `Core` 源文件,不包含游戏内存访问。 +- 包含技能状态/变换前置、充能、循环回退、职能技能及极限技链、低血量保护、六种净化、复活保护、礼赞延迟、补给威胁、Guard 过滤、目标排序与鹰眼收割。 +- 包含动作确认序列/回绕/超时、长读条、动画锁与游戏队列限制、手动读条保护、对局状态和开关门禁。 +- 0.1.1 新增等待动作确认时更新目标、HP 变化重新选敌、Guard 与通用无敌分离、Guard-only 远距离鹰眼、鹰眼冷却时的选敌回退等场景。 +- 0.1.2 新增冰块输出、全部排序下玩家优先、跨技能的玩家优先、无效玩家回退、鹰眼不打冰块、目标开关、补给威胁区分、画面选敌与实际回退一致、状态过期优先级和冷却组计时等场景。 +- 0.1.3 新增安可不等过期、短穿插窗口、生存 / 鹰眼优先级、目标与技能门禁、玩家优先、零 MP 和技能变换、和弦箭各合法变体及未知变体回退等测试。 +- 0.1.4 新增烈羽争锋模式、机甲多来源检测、模式隔离、机甲暂停、NPC 类型筛选、敌方 NPC 补给威胁、普通玩家优先及鹰眼避开机甲、建筑可攻击性和补给取消联动测试。 +- 0.2.0 新增 27 项测试:旧配置默认值 / 新设置序列化、稳定选敌和失效回退、插件自身操作不触发接管、外部动作序列回绕、接管恢复、穿插样本学习 / 排除 / 窗口淘汰、目标分类开关和威胁分离、爆发超时回退和手动窗口、圆形 / 直线范围几何、集火 / 快速掉血 / 复活和 Guard 保护、手动后跳及完整诊断的跨帧一致性。 + +## 未完成的游戏内验收 + +0.2.1 新增 12 项测试覆盖主动爆发的设置兼容、鹰眼 / 沉默提前、后续技能优先、充能不保留、短穿插窗口、读条和后跳不豁免、严格收割、生存与手动接管、玩家优先及技能用尽后的普攻回退。尚需实机验证主动爆发的释放节奏和对普攻间隔的影响,未声称实际伤害提升。 + +本次没有向运行中的游戏加载插件或发送技能,以下仍需在游戏内确认: + +- 0.2.0 设置窗口和冻结 / 复制诊断按钮、命令、人工操作与其他插件动作的接管检测;实际延迟下的瞬发确认 / 动画锁预算;移动 / 地形下的范围估算;当前客户端新增 NPC ID 的分类;敌人目标指向和 HP 掉血信号的实战效果。离线测试不能模拟这些客户端数据来源。 + +- Dalamud 加载/卸载、设置与浮窗布局、命令与配置保存。 +- 纷争前线开局/结束、进场/复活/职业切换、双方敌人阵营识别。 +- 烈羽争锋实际开局 / 结束、猎隼 / 渡鸦敌我识别、兵线 / 建筑 / 机甲的实际对象分类,以及搭乘状态 1420 / ContentValue(1) 上机甲和下机甲更新。离线测试不能代替这些客户端数据验证。 +- 碎冰战实际冰块及其他战场目标的对象类型、原生可攻击性,以及玩家出现/离开后的目标切换。离线测试覆盖目标分类后的决策,不能验证游戏中的 NPC 分类与内存读取。 +- 全套诗人技能的实际动作可用性、充能读取、技能变换、移动强力射击和穿插时间。 +- 即将过期状态的客户端剩余时间读取,以及独立冷却武器技能的实际共享冷却组。 +- 净化在控制状态下实际释放,防御不被自动取消,自疗与补给恢复结果。 +- 按技能实际射程、障碍物及目标状态的原生检查结果。 +- 极限技和后续技能、鹰眼的实际伤害、收割阈值及护盾换算误差。 +- 0.1.3 安可实际目标拒绝原因与释放结果、和弦箭变体的实际动作请求、复制诊断按钮。用户截图只能证明旧版最近一次安可目标检查失败及和弦箭 41964 被拒绝,不能证明安可的具体失败子条件。 +- 实际网络延迟下的请求确认与读条取消行为。 + +离线测试中的原生可用性由假实现提供;编译通过与纯逻辑测试不能代替上述实机结果。缺失技能及最近原生检查错误码可在“运行诊断”查看;意外异常会关闭总开关并写入 Dalamud 日志。 diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..d710b31 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,22 @@ +param( + [string]$HooksDevPath = (Join-Path $env:APPDATA 'XIVLauncher\addon\Hooks\dev') +) +$ErrorActionPreference = 'Stop' +Push-Location $PSScriptRoot +try { + dotnet restore BardFrontline.csproj --configfile NuGet.Config --nologo + if ($LASTEXITCODE -ne 0) { throw 'Plugin restore failed.' } + dotnet build BardFrontline.csproj -c Release "-p:HooksDevPath=$HooksDevPath" --no-restore --nologo + if ($LASTEXITCODE -ne 0) { throw 'Plugin build failed.' } + dotnet restore Tests/BardFrontline.Tests.csproj --configfile NuGet.Config --nologo + if ($LASTEXITCODE -ne 0) { throw 'Test restore failed.' } + dotnet run --project Tests/BardFrontline.Tests.csproj -c Release --no-restore + if ($LASTEXITCODE -ne 0) { throw 'Tests failed.' } + $bardDist = Join-Path $PSScriptRoot 'dist' + New-Item -ItemType Directory -Path $bardDist -Force | Out-Null + $bardPackage = @('bin/Release/BardFrontline.dll', 'bin/Release/BardFrontline.json') + $bardZip = Join-Path $bardDist 'BardFrontline-0.2.1.zip' + Compress-Archive -LiteralPath $bardPackage -DestinationPath $bardZip -Force + Get-Item -LiteralPath $bardZip +} +finally { Pop-Location }