Initial import of BardFrontline
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
bin/
|
||||
obj/
|
||||
.refs/
|
||||
dist/
|
||||
Tests/bin/
|
||||
Tests/obj/
|
||||
|
||||
# Local credentials and IDE state.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.vs/
|
||||
*.user
|
||||
*.suo
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<Version>0.2.1</Version>
|
||||
<AssemblyVersion>0.2.1.0</AssemblyVersion>
|
||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||
<HooksDevPath Condition="'$(HooksDevPath)' == ''">$(APPDATA)\XIVLauncher\addon\Hooks\dev</HooksDevPath>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Remove="Tests/**/*.cs" />
|
||||
<Reference Include="Dalamud;Dalamud.Bindings.ImGui;FFXIVClientStructs;InteropGenerator.Runtime;Lumina;Lumina.Excel;Newtonsoft.Json">
|
||||
<HintPath>$(HooksDevPath)\%(Identity).dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<None Update="BardFrontline.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
<Target Name="ValidateRuntime" BeforeTargets="ResolveReferences">
|
||||
<Error Condition="!Exists('$(HooksDevPath)\Dalamud.dll')" Text="HooksDevPath must contain the compiled Dalamud runtime DLLs." />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<!-- No NuGet dependencies: build against the installed Hooks/dev DLLs. -->
|
||||
<packageSources><clear /></packageSources>
|
||||
</configuration>
|
||||
@@ -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<ObjectiveKind>())
|
||||
{
|
||||
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<bool> apply)
|
||||
{ if (ImGui.Checkbox(label, ref value)) { apply(value); Save(); } }
|
||||
private void Float(string label, float value, float min, float max, Action<float> 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<int> 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();
|
||||
}
|
||||
}
|
||||
@@ -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`,**游戏内验收尚未完成**。
|
||||
@@ -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`。稳定选敌、爆发保留、集火阈值和自适应预算是本插件的可配置策略,不宣称为游戏的精确伤害或命中预测。
|
||||
@@ -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<Skill, AoeShape> shapes = [];
|
||||
private readonly Queue<string> 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<Skill, SheetAction> actionRows = [];
|
||||
private readonly Dictionary<Skill, string> diagnostics = [];
|
||||
private readonly Dictionary<Skill, string> 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<uint> 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<Skill, string> 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<SheetAction>();
|
||||
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<TerritoryType>().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<ulong>(); var party = new List<ulong>();
|
||||
foreach (var obj in objects.OfType<IBattleChara>())
|
||||
{
|
||||
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<IBattleChara>()
|
||||
.Where(p => IsEnemy(player, p)).Select(p => ReadActor(p, player, true)).ToArray();
|
||||
var partyMembers = partyIds.Select(GetBattle).OfType<IPlayerCharacter>()
|
||||
.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<Battalion>().TryGetRow(ours, out var row) && theirs < row.IsEnemyTo.Count)
|
||||
return row.IsEnemyTo[theirs];
|
||||
return other.StatusFlags.HasFlag(StatusFlags.Hostile);
|
||||
}
|
||||
|
||||
private static HashSet<uint> 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<uint, float>(),
|
||||
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<SheetAction>().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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="../Core/*.cs" Link="Core/%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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<Settings>("{\"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<Settings>(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<ObjectiveKind>())
|
||||
{
|
||||
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, AoeShape> { [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, AoeShape> { [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("不自动冲刺"));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<BurstStrategy>())
|
||||
{
|
||||
var c = JsonSerializer.Deserialize<Settings>(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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<TargetOrder>())
|
||||
{
|
||||
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<EagleStrategy>())
|
||||
{
|
||||
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<Skill, int> Charges { get; } = [];
|
||||
public Dictionary<Skill, uint> AdjustedIds { get; } = [];
|
||||
public float? SharedGcdRemaining { get; init; }
|
||||
public HashSet<ulong> 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>(T expected, T actual) { if (!EqualityComparer<T>.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; }
|
||||
}
|
||||
@@ -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 日志。
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user