Initial import of BardFrontline

This commit is contained in:
2026-10-13 22:55:05 +08:00
commit f3ab7c58c3
24 changed files with 2917 additions and 0 deletions
+528
View File
@@ -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();
}
}
}