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 shapes = []; private readonly Queue 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 actionRows = []; private readonly Dictionary diagnostics = []; private readonly Dictionary 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 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 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(); 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().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(); var party = new List(); foreach (var obj in objects.OfType()) { 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() .Where(p => IsEnemy(player, p)).Select(p => ReadActor(p, player, true)).ToArray(); var partyMembers = partyIds.Select(GetBattle).OfType() .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().TryGetRow(ours, out var row) && theirs < row.IsEnemyTo.Count) return row.IsEnemyTo[theirs]; return other.StatusFlags.HasFlag(StatusFlags.Hostile); } private static HashSet 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(), 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().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(); } } }