Initial import of BardFrontline
This commit is contained in:
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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 = ""; }
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user