Initial import of BardFrontline
This commit is contained in:
+214
@@ -0,0 +1,214 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user