Files
BardFrontline/Runtime/CombatRuntime.cs
T

529 lines
31 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}
}