using System.Numerics; namespace BardFrontline.Core; public enum TargetOrder { LowestHp, LowestHpRatio, Nearest, AttackMarker } public enum EagleStrategy { FinisherFirst, FinisherOnly } public enum BurstStrategy { Normal, AlignBuffs, ManualWindow, Pressure } public enum ObjectiveKind { Other, Ice, Mech, Infantry, Mercenary, Tower, Core } public sealed class Settings { public bool AutoAttack { get; set; } = true; public bool AttackObjectives { get; set; } = true; public bool IgnoreEnemyGuard { get; set; } public bool HelpParty { get; set; } public bool ShowOverlay { get; set; } = true; public bool StableTargets { get; set; } public float TargetHoldSeconds { get; set; } = .6f; public int TargetHpMargin { get; set; } = 2000; public bool DetectManualControl { get; set; } public float ManualControlSeconds { get; set; } = 3; public bool AdaptiveWeaving { get; set; } = true; public BurstStrategy Burst { get; set; } public int BurstCharges { get; set; } = 3; public float BurstMaxHoldSeconds { get; set; } = 4; public bool PreferAoe { get; set; } public int AoeMinTargets { get; set; } = 2; public bool FocusDefense { get; set; } public int FocusAttackers { get; set; } = 3; public float FocusGuardPercent { get; set; } = 40; public bool ManualRepelling { get; set; } public HashSet DisabledObjectives { get; set; } = []; public TargetOrder TargetOrder { get; set; } public float GuardHpPercent { get; set; } = 15; public int RecuperateMissingHp { get; set; } = 15000; public float ElixirPercent { get; set; } = 100f / 3; public EagleStrategy EagleStrategy { get; set; } = EagleStrategy.FinisherFirst; public int EagleFinisherHp { get; set; } = 10000; public HashSet DisabledSkills { get; set; } = []; public HashSet DisabledPurifyStatuses { get; set; } = []; public bool Enabled(Skill skill) => !DisabledSkills.Contains(skill); public void Normalize() { DisabledSkills ??= []; DisabledPurifyStatuses ??= []; DisabledObjectives ??= []; TargetHoldSeconds = Clamp(TargetHoldSeconds, 0, 3, .6f); TargetHpMargin = Math.Clamp(TargetHpMargin, 0, 20000); ManualControlSeconds = Clamp(ManualControlSeconds, .5f, 15, 3); BurstMaxHoldSeconds = Clamp(BurstMaxHoldSeconds, .5f, 10, 4); FocusGuardPercent = Clamp(FocusGuardPercent, 1, 80, 40); FocusAttackers = Math.Clamp(FocusAttackers, 2, 10); BurstCharges = Math.Clamp(BurstCharges, 1, 4); AoeMinTargets = Math.Clamp(AoeMinTargets, 2, 10); if (!Enum.IsDefined(Burst)) Burst = BurstStrategy.Normal; GuardHpPercent = float.IsFinite(GuardHpPercent) ? Math.Clamp(GuardHpPercent, 1, 100) : 15; ElixirPercent = float.IsFinite(ElixirPercent) ? Math.Clamp(ElixirPercent, 1, 100) : 100f / 3; RecuperateMissingHp = Math.Clamp(RecuperateMissingHp, 1, 100000); EagleFinisherHp = Math.Clamp(EagleFinisherHp, 1, 100000); if (!Enum.IsDefined(TargetOrder)) TargetOrder = TargetOrder.LowestHp; if (!Enum.IsDefined(EagleStrategy)) EagleStrategy = EagleStrategy.FinisherFirst; } private static float Clamp(float value, float min, float max, float fallback) => float.IsFinite(value) ? Math.Clamp(value, min, max) : fallback; } public sealed record Actor(ulong Id, string Name, uint Hp, uint MaxHp, float Distance, bool Enemy, bool Targetable, bool Dead, int AttackMarker, HashSet Status) { public bool IsObjective { get; init; } public bool ThreateningObjective { get; init; } public ObjectiveKind Kind { get; init; } public Vector3 Position { get; init; } public float HitboxRadius { get; init; } public ulong TargetId { get; init; } public ulong CastTargetId { get; init; } public bool Invulnerable { get; init; } public IReadOnlyDictionary StatusRemaining { get; init; } = new Dictionary(); public uint ShieldHp { get; init; } public ulong EffectiveHp => (ulong)Hp + ShieldHp; public double HpRatio => MaxHp == 0 ? 1 : (double)Hp / MaxHp; public bool Alive => !Dead && Hp > 0 && MaxHp > 0; public bool Has(uint status) => Status.Contains(status); public float Remaining(uint status) => Has(status) && StatusRemaining.TryGetValue(status, out var seconds) && float.IsFinite(seconds) && seconds > 0 ? seconds : float.PositiveInfinity; } public sealed record CombatSnapshot( Actor Player, uint Mp, uint MaxMp, bool InCombat, bool Moving, double AliveSeconds, bool LimitFull, IReadOnlyList Enemies, IReadOnlyList Party, ulong CurrentTarget, bool GuardPending = false, bool CleansePending = false) { public double TargetAge { get; init; } = double.PositiveInfinity; public bool ManualControl { get; init; } public bool BurstWindow { get; init; } public int HarmonicCharges { get; init; } public double HarmonicReadySeconds { get; init; } public double RecentHpLossRatio { get; init; } public IReadOnlyDictionary Shapes { get; init; } = new Dictionary(); } public sealed record ActionDecision(Skill Skill, ulong TargetId, string Reason, bool Emergency = false); // The only native boundary used by the decision engine. A fake implementation drives offline tests. public interface IActionAvailability { bool CanUse(Skill skill, Actor target); } public static class TargetSelector { public static bool Allowed(Actor a, Settings c, Skill skill) => (!a.IsObjective || c.AttackObjectives && !c.DisabledObjectives.Contains(a.Kind) && skill != Skill.EagleEyeShot) && Eligible(a, skill, c.IgnoreEnemyGuard); public static bool Relevant(Skill skill, CombatSnapshot s, Settings c) => Actions.IsHostile(skill) && c.Enabled(skill) && !s.ManualControl && Strategy.HoldReason(skill, s, c) == null && (skill != Skill.EagleEyeShot || s.Player.Has(Statuses.RoleEagle) && c.EagleStrategy == EagleStrategy.FinisherFirst) && (skill != Skill.EncoreOfLight || s.Player.Has(Statuses.EncoreReady)) && (skill != Skill.PitchPerfect || s.Player.Has(Statuses.Repertoire)) && (skill != Skill.PowerfulShot || !s.Player.Has(Statuses.Repertoire)) && (skill != Skill.BlastArrow || s.Player.Has(Statuses.BlastReady)) && (skill != Skill.ApexArrow || !s.Player.Has(Statuses.BlastReady)) && (skill is not (Skill.RepellingShot or Skill.SilentNocturne) || !s.Player.Has(Statuses.Repertoire)); // Restrict the entire attack rotation to players when any usable attack can reach one. // Per-skill fallback alone would let a high-priority skill on ice preempt an available player attack. public static IReadOnlyList CombatCandidates(CombatSnapshot s, Settings c, IActionAvailability availability) { var players = s.Enemies.Where(a => !a.IsObjective).ToArray(); return players.Any(a => Actions.All.Any(skill => Relevant(skill, s, c) && Allowed(a, c, skill) && availability.CanUse(skill, a))) ? players : s.Enemies; } public static bool Eligible(Actor a, Skill skill, bool ignoreGuard) => a.Enemy && a.Alive && a.Targetable && !a.Invulnerable && !a.Status.Overlaps(Statuses.Invincible) && !a.Has(1307) && !a.Has(1720) && (ignoreGuard || skill == Skill.EagleEyeShot || !a.Has(Statuses.Guard)); public static IEnumerable Ordered(IEnumerable actors, Settings c, ulong current) { var first = c.TargetOrder switch { TargetOrder.LowestHpRatio => actors.OrderBy(a => (double)a.HpRatio), TargetOrder.Nearest => actors.OrderBy(a => (double)a.Distance), TargetOrder.AttackMarker => actors.OrderBy(a => (double)(a.AttackMarker > 0 ? a.AttackMarker : 99)), _ => actors.OrderBy(a => (double)a.Hp), }; return (c.TargetOrder == TargetOrder.AttackMarker ? first.ThenBy(a => a.Hp) : first) .ThenBy(a => a.Distance).ThenBy(a => a.Id == current ? 0 : 1).ThenBy(a => a.Id); } public static Actor? Select(CombatSnapshot s, Settings c, Skill skill, IActionAvailability availability) { var ordered = Ordered(s.Enemies.Where(a => Allowed(a, c, skill)), c, s.CurrentTarget).OrderBy(a => a.IsObjective); if (!c.StableTargets && !(c.PreferAoe && s.Shapes.ContainsKey(skill))) return ordered.FirstOrDefault(a => availability.CanUse(skill, a)); var candidates = ordered.Where(a => availability.CanUse(skill, a)).ToArray(); var best = candidates.FirstOrDefault(); if (best == null) return null; if (c.PreferAoe && s.Shapes.TryGetValue(skill, out var shape)) { var ranked = candidates.Where(a => a.IsObjective == best.IsObjective) .Select(a => (Actor: a, Count: Strategy.AoeCount(s, c, skill, a, shape))).OrderByDescending(x => x.Count).First(); if (ranked.Count >= c.AoeMinTargets) return ranked.Actor; } return Stabilize(s, c, best, candidates); } public static Actor? Stabilize(CombatSnapshot s, Settings c, Actor? best, IEnumerable candidates) { if (best == null || !c.StableTargets || s.TargetAge >= c.TargetHoldSeconds) return best; var current = candidates.FirstOrDefault(a => a.Id == s.CurrentTarget); if (current == null || current.IsObjective != best.IsObjective) return best; var majorImprovement = c.TargetOrder switch { TargetOrder.LowestHp => (long)current.Hp - best.Hp >= c.TargetHpMargin, TargetOrder.LowestHpRatio => current.HpRatio - best.HpRatio >= .05, TargetOrder.Nearest => current.Distance - best.Distance >= 3, _ => best.AttackMarker > 0 && (current.AttackMarker == 0 || best.AttackMarker < current.AttackMarker), }; return majorImprovement ? best : current; } public static Actor? EagleFinisher(CombatSnapshot s, Settings c, IActionAvailability availability) => !c.AutoAttack || !c.Enabled(Skill.EagleEyeShot) || !s.Player.Has(Statuses.RoleEagle) ? null : s.Enemies.Where(a => !a.IsObjective && Eligible(a, Skill.EagleEyeShot, c.IgnoreEnemyGuard) && a.EffectiveHp <= (ulong)c.EagleFinisherHp) .OrderBy(a => a.EffectiveHp).ThenBy(a => a.Distance) .ThenBy(a => a.Id == s.CurrentTarget ? 0 : 1).ThenBy(a => a.Id) .FirstOrDefault(a => availability.CanUse(Skill.EagleEyeShot, a)); // Readiness checks resources/cooldowns but ignores execution locks. Selecting a target never sends an action // or retargets an action that the game has already accepted. public static Actor? PlanTarget(CombatSnapshot s, Settings c, IActionAvailability readiness, ITargetProbe probe) { if (!c.AutoAttack || s.ManualControl || !s.Player.Alive || s.Player.Has(Statuses.Guard) || s.GuardPending) return null; var finisher = EagleFinisher(s, c, readiness); if (finisher != null) return finisher; var ordered = Ordered(s.Enemies, c, s.CurrentTarget).OrderBy(a => a.IsObjective).ToArray(); // Prefer the same usable player/objective pool as dispatch, avoiding display flicker back to // a player whose only in-range attack is unavailable while actually firing at an objective. var ready = ordered.FirstOrDefault(a => Actions.All.Any(skill => Relevant(skill, s, c) && Allowed(a, c, skill) && readiness.CanUse(skill, a))); if (ready != null) return Stabilize(s, c, ready, ordered.Where(a => Actions.All.Any(skill => Relevant(skill, s, c) && Allowed(a, c, skill) && readiness.CanUse(skill, a)))); return ordered.FirstOrDefault(a => Actions.All.Any(skill => Relevant(skill, s, c) && Allowed(a, c, skill) && (skill != Skill.EagleEyeShot || readiness.CanUse(skill, a)) && probe.CanTarget(skill, a))); } // Threats deliberately do not use attack eligibility, Guard, line of sight, or invincibility filters. public static bool HasNearbyThreat(CombatSnapshot s) => s.Enemies.Any(a => (!a.IsObjective || a.ThreateningObjective) && a.Enemy && a.Alive && a.Distance <= 30); }