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 }