Files

215 lines
12 KiB
C#

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<ObjectiveKind> 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<Skill> DisabledSkills { get; set; } = [];
public HashSet<uint> 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<uint> 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<uint, float> StatusRemaining { get; init; } = new Dictionary<uint, float>();
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<Actor> Enemies, IReadOnlyList<Actor> 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<Skill, AoeShape> Shapes { get; init; } = new Dictionary<Skill, AoeShape>();
}
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<Actor> 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<Actor> Ordered(IEnumerable<Actor> 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<Actor> 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);
}