Initial import of BardFrontline

This commit is contained in:
2026-10-13 22:55:05 +08:00
commit f3ab7c58c3
24 changed files with 2917 additions and 0 deletions
+53
View File
@@ -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<uint> 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<Skill>();
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(),
};
}
+51
View File
@@ -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;
}
+53
View File
@@ -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<float> 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; }
}
+55
View File
@@ -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<SkillAudit> Skills);
public static class DecisionAudit
{
public static IReadOnlyList<SkillAudit> Evaluate(CombatSnapshot? s, Settings c, ActionDecision? selected,
string phase, Func<Skill, Actor, string?> nativeCheck, Func<Skill, Actor, bool>? targetProbe = null)
{
var result = new List<SkillAudit>();
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<Actor> candidates = Actions.IsHostile(skill) ? pool
: skill == Skill.WardensPaean ? new[] { state.Player }.Concat(c.HelpParty ? state.Party : [])
: [state.Player];
var failures = new HashSet<string>();
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<Skill, Actor, string?> check) : IActionAvailability
{ public bool CanUse(Skill skill, Actor target) => check(skill, target) == null; }
}
+50
View File
@@ -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;
}
}
+214
View File
@@ -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<ObjectiveKind> 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<Skill> DisabledSkills { get; set; } = [];
public HashSet<uint> 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<uint> 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<uint, float> StatusRemaining { get; init; } = new Dictionary<uint, float>();
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<Actor> Enemies, IReadOnlyList<Actor> 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<Skill, AoeShape> Shapes { get; init; } = new Dictionary<Skill, AoeShape>();
}
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<Actor> 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<Actor> Ordered(IEnumerable<Actor> 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<Actor> 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);
}
+44
View File
@@ -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 = ""; }
}
+112
View File
@@ -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); }
+89
View File
@@ -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; }
}