529 lines
31 KiB
C#
529 lines
31 KiB
C#
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();
|
||
}
|
||
}
|
||
}
|