|
|
|
@@ -0,0 +1,781 @@
|
|
|
|
|
using BardFrontline.Core;
|
|
|
|
|
|
|
|
|
|
var suite = new Suite();
|
|
|
|
|
var engine = new Rotation();
|
|
|
|
|
Actor Self(uint hp = 60000, params uint[] states) => new(1, "Self", hp, 60000, 0, false, true, false, 0, [.. states]);
|
|
|
|
|
Actor Enemy(ulong id = 10, uint hp = 30000, float distance = 20, params uint[] states) =>
|
|
|
|
|
new(id, "Enemy", hp, 60000, distance, true, true, false, 0, [.. states]);
|
|
|
|
|
CombatSnapshot Snap(Actor? player = null, params Actor[] enemies) =>
|
|
|
|
|
new(player ?? Self(), 10000, 10000, true, false, 10, false, enemies, [], 0);
|
|
|
|
|
ActionDecision? Decide(CombatSnapshot s, Fake f, Settings? c = null, bool emergency = false) => engine.Decide(s, c ?? new(), f, emergency);
|
|
|
|
|
void IsSkill(ActionDecision? d, Skill skill) => Suite.Equal(skill, d?.Skill);
|
|
|
|
|
void None(ActionDecision? d) => Suite.True(d == null, $"Expected no action, got {d}");
|
|
|
|
|
|
|
|
|
|
suite.Run("Default configuration and invalid persisted values", () =>
|
|
|
|
|
{
|
|
|
|
|
var c = new Settings();
|
|
|
|
|
Suite.True(c.AutoAttack && !c.IgnoreEnemyGuard && !c.HelpParty);
|
|
|
|
|
Suite.True(Actions.All.All(c.Enabled));
|
|
|
|
|
c.GuardHpPercent = float.NaN; c.ElixirPercent = float.PositiveInfinity;
|
|
|
|
|
c.RecuperateMissingHp = -10; c.TargetOrder = (TargetOrder)99; c.Normalize();
|
|
|
|
|
Suite.Equal(15f, c.GuardHpPercent); Suite.Equal(1, c.RecuperateMissingHp);
|
|
|
|
|
Suite.Equal(TargetOrder.LowestHp, c.TargetOrder);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Missing March prioritizes Apex over charged Harmonic", () =>
|
|
|
|
|
IsSkill(Decide(Snap(null, Enemy()), new(Skill.ApexArrow, Skill.HarmonicArrow, Skill.PowerfulShot)), Skill.ApexArrow));
|
|
|
|
|
suite.Run("March present prioritizes Harmonic", () =>
|
|
|
|
|
IsSkill(Decide(Snap(Self(60000, Statuses.March), Enemy()), new(Skill.ApexArrow, Skill.HarmonicArrow)), Skill.HarmonicArrow));
|
|
|
|
|
suite.Run("Harmonic is usable at each charge count, zero falls through", () =>
|
|
|
|
|
{
|
|
|
|
|
foreach (var count in new[] { 1, 2, 3, 4 })
|
|
|
|
|
{
|
|
|
|
|
var fake = new Fake(Skill.HarmonicArrow, Skill.PowerfulShot);
|
|
|
|
|
fake.Charges[Skill.HarmonicArrow] = count;
|
|
|
|
|
IsSkill(Decide(Snap(null, Enemy()), fake), Skill.HarmonicArrow);
|
|
|
|
|
}
|
|
|
|
|
var empty = new Fake(Skill.HarmonicArrow, Skill.PowerfulShot);
|
|
|
|
|
empty.Charges[Skill.HarmonicArrow] = 0;
|
|
|
|
|
IsSkill(Decide(Snap(null, Enemy()), empty), Skill.PowerfulShot);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Repertoire uses Pitch and prevents wasteful generators", () =>
|
|
|
|
|
{
|
|
|
|
|
IsSkill(Decide(Snap(Self(60000, Statuses.Repertoire), Enemy()), new(Skill.PitchPerfect, Skill.PowerfulShot)), Skill.PitchPerfect);
|
|
|
|
|
None(Decide(Snap(Self(60000, Statuses.Repertoire), Enemy()), new(Skill.RepellingShot, Skill.SilentNocturne, Skill.PowerfulShot)));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("No Repertoire does not attempt Pitch", () =>
|
|
|
|
|
IsSkill(Decide(Snap(null, Enemy()), new(Skill.PitchPerfect, Skill.PowerfulShot)), Skill.PowerfulShot));
|
|
|
|
|
suite.Run("Blast ready consumes Blast and blocks Apex", () =>
|
|
|
|
|
IsSkill(Decide(Snap(Self(60000, Statuses.BlastReady), Enemy()), new(Skill.ApexArrow, Skill.BlastArrow)), Skill.BlastArrow));
|
|
|
|
|
suite.Run("No Blast ready falls through to Apex", () =>
|
|
|
|
|
IsSkill(Decide(Snap(Self(60000, Statuses.March), Enemy()), new(Skill.BlastArrow, Skill.ApexArrow)), Skill.ApexArrow));
|
|
|
|
|
suite.Run("Offensive abilities preserve RSR order", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(Self(60000, Statuses.RoleEagle), Enemy(distance: 8));
|
|
|
|
|
IsSkill(Decide(s, new(Skill.RepellingShot, Skill.SilentNocturne, Skill.EagleEyeShot, Skill.EncoreOfLight)), Skill.RepellingShot);
|
|
|
|
|
IsSkill(Decide(s, new(Skill.SilentNocturne, Skill.EagleEyeShot, Skill.EncoreOfLight)), Skill.SilentNocturne);
|
|
|
|
|
IsSkill(Decide(s, new(Skill.EagleEyeShot, Skill.EncoreOfLight)), Skill.EagleEyeShot);
|
|
|
|
|
IsSkill(Decide(s with { Player = Self(60000, Statuses.EncoreReady) }, new(Skill.EncoreOfLight)), Skill.EncoreOfLight);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Repelling out of range falls through to Nocturne", () =>
|
|
|
|
|
IsSkill(Decide(Snap(null, Enemy(distance: 18)), new(Skill.RepellingShot, Skill.SilentNocturne)), Skill.SilentNocturne));
|
|
|
|
|
suite.Run("Ready weapons prevent offensive ability starvation of GCD", () =>
|
|
|
|
|
IsSkill(Decide(Snap(null, Enemy(distance: 8)), new(Skill.PowerfulShot, Skill.RepellingShot)), Skill.PowerfulShot));
|
|
|
|
|
suite.Run("Unavailable high priority skills fall through", () =>
|
|
|
|
|
IsSkill(Decide(Snap(null, Enemy()), new(Skill.PowerfulShot)), Skill.PowerfulShot));
|
|
|
|
|
suite.Run("Disabled offensive skills are never selected", () =>
|
|
|
|
|
{
|
|
|
|
|
var c = new Settings(); c.DisabledSkills.Add(Skill.HarmonicArrow);
|
|
|
|
|
IsSkill(Decide(Snap(null, Enemy()), new(Skill.HarmonicArrow, Skill.PowerfulShot), c), Skill.PowerfulShot);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Limit Break requires full gauge, combat and target", () =>
|
|
|
|
|
{
|
|
|
|
|
var f = new Fake(Skill.FinalFantasia);
|
|
|
|
|
var s = Snap(null, Enemy()) with { LimitFull = true };
|
|
|
|
|
IsSkill(Decide(s, f), Skill.FinalFantasia);
|
|
|
|
|
None(Decide(s with { LimitFull = false }, f));
|
|
|
|
|
None(Decide(s with { InCombat = false }, f));
|
|
|
|
|
None(Decide(s with { Enemies = [] }, f));
|
|
|
|
|
None(Decide(s with { Player = Self(60000, Statuses.Fantasia) }, f));
|
|
|
|
|
None(Decide(s with { Player = Self(60000, Statuses.EncoreReady) }, f));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Encore requires its status and uses hostile target", () =>
|
|
|
|
|
{
|
|
|
|
|
var f = new Fake(Skill.EncoreOfLight);
|
|
|
|
|
None(Decide(Snap(null, Enemy()), f));
|
|
|
|
|
var d = Decide(Snap(Self(60000, Statuses.EncoreReady), Enemy(33)), f);
|
|
|
|
|
IsSkill(d, Skill.EncoreOfLight); Suite.Equal(33ul, d!.TargetId);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Only equipped role actions are attempted", () =>
|
|
|
|
|
{
|
|
|
|
|
var f = new Fake(Skill.Bravery, Skill.Dervish, Skill.EagleEyeShot);
|
|
|
|
|
None(Decide(Snap(null, Enemy()), f));
|
|
|
|
|
var bravery = Decide(Snap(Self(60000, Statuses.RoleBravery), Enemy()), f);
|
|
|
|
|
IsSkill(bravery, Skill.Bravery); Suite.Equal(1ul, bravery!.TargetId);
|
|
|
|
|
IsSkill(Decide(Snap(Self(60000, Statuses.RoleDervish), Enemy()), f), Skill.Dervish);
|
|
|
|
|
IsSkill(Decide(Snap(Self(60000, Statuses.RoleEagle), Enemy()), f), Skill.EagleEyeShot);
|
|
|
|
|
None(Decide(Snap(Self(60000, Statuses.RoleBravery, Statuses.Bravery), Enemy()), f));
|
|
|
|
|
None(Decide(Snap(Self(60000, Statuses.RoleDervish, Statuses.Dervish), Enemy()), f));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Own Guard stops all actions including healing and purify", () =>
|
|
|
|
|
None(Decide(Snap(Self(1000, Statuses.Guard, 1343), Enemy()), new(Actions.All))));
|
|
|
|
|
suite.Run("Pending Guard stops actions before buff arrives", () =>
|
|
|
|
|
None(Decide(Snap(Self(1000, 1343), Enemy()) with { GuardPending = true }, new(Actions.All))));
|
|
|
|
|
suite.Run("Self cleanse priority is Paean then Purify then Guard", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(Self(1000, 1343), Enemy());
|
|
|
|
|
IsSkill(Decide(s, new(Skill.WardensPaean, Skill.Purify, Skill.Guard, Skill.Recuperate)), Skill.WardensPaean);
|
|
|
|
|
IsSkill(Decide(s, new(Skill.Purify, Skill.Guard, Skill.Recuperate)), Skill.Purify);
|
|
|
|
|
IsSkill(Decide(s, new(Skill.Guard, Skill.Recuperate)), Skill.Guard);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("All six cleanse statuses work, disabled Purify trigger is respected", () =>
|
|
|
|
|
{
|
|
|
|
|
foreach (var id in Statuses.Controls)
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(Self(60000, id));
|
|
|
|
|
IsSkill(Decide(s, new(Skill.Purify)), Skill.Purify);
|
|
|
|
|
var c = new Settings(); c.DisabledPurifyStatuses.Add(id);
|
|
|
|
|
None(Decide(s, new(Skill.Purify), c));
|
|
|
|
|
IsSkill(Decide(s, new(Skill.WardensPaean), c), Skill.WardensPaean);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Paean acknowledgement delay does not immediately spend Purify", () =>
|
|
|
|
|
None(Decide(Snap(Self(60000, 1343)) with { CleansePending = true }, new(Skill.Purify))));
|
|
|
|
|
suite.Run("Successful cleanse stops retries after debuff disappears", () =>
|
|
|
|
|
None(Decide(Snap(), new(Skill.WardensPaean, Skill.Purify))));
|
|
|
|
|
suite.Run("Guard threshold and fallback when unavailable", () =>
|
|
|
|
|
{
|
|
|
|
|
IsSkill(Decide(Snap(Self(9000)), new(Skill.Guard, Skill.Recuperate)), Skill.Guard);
|
|
|
|
|
IsSkill(Decide(Snap(Self(9001)), new(Skill.Guard, Skill.Recuperate)), Skill.Recuperate);
|
|
|
|
|
IsSkill(Decide(Snap(Self(1000)), new(Skill.Recuperate)), Skill.Recuperate);
|
|
|
|
|
None(Decide(Snap(Self(1000)) with { InCombat = false }, new(Skill.Guard)));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Recuperate works without an enemy, respects deficit and available MP", () =>
|
|
|
|
|
{
|
|
|
|
|
IsSkill(Decide(Snap(Self(45000)), new(Skill.Recuperate)), Skill.Recuperate);
|
|
|
|
|
None(Decide(Snap(Self(45001)), new(Skill.Recuperate)));
|
|
|
|
|
var f = new Fake(Skill.Recuperate) { Mp = 1999 };
|
|
|
|
|
None(Decide(Snap(Self(30000)) with { Mp = 1999 }, f));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Five second revive protection includes boundary", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(Self(1000)) with { AliveSeconds = 4.999 };
|
|
|
|
|
None(Decide(s, new(Skill.Guard, Skill.Recuperate, Skill.Elixir)));
|
|
|
|
|
IsSkill(Decide(s with { AliveSeconds = 5 }, new(Skill.Guard)), Skill.Guard);
|
|
|
|
|
IsSkill(Decide(s with { Player = Self(1000, 1343) }, new(Skill.Purify)), Skill.Purify);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Party Paean is opt-in and lowest HP ratio first", () =>
|
|
|
|
|
{
|
|
|
|
|
var ally = Enemy(20, 10000, 10, 1343) with { Enemy = false };
|
|
|
|
|
var other = Enemy(21, 20000, 5, 1345) with { Enemy = false };
|
|
|
|
|
var s = Snap() with { Party = [other, ally] };
|
|
|
|
|
None(Decide(s, new(Skill.WardensPaean)));
|
|
|
|
|
var d = Decide(s, new(Skill.WardensPaean), new() { HelpParty = true });
|
|
|
|
|
Suite.Equal(20ul, d!.TargetId);
|
|
|
|
|
IsSkill(Decide(s with { Player = Self(10000) }, new(Skill.WardensPaean, Skill.Recuperate), new() { HelpParty = true }), Skill.Recuperate);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Elixir stationary resource thresholds and no enemy requirement", () =>
|
|
|
|
|
{
|
|
|
|
|
var f = new Fake(Skill.Elixir);
|
|
|
|
|
IsSkill(Decide(Snap(Self(10000)), f), Skill.Elixir);
|
|
|
|
|
IsSkill(Decide(Snap() with { Mp = 1000 }, f), Skill.Elixir);
|
|
|
|
|
None(Decide(Snap(), f));
|
|
|
|
|
None(Decide(Snap(Self(10000)) with { Moving = true }, f));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Guard, invincible, untargetable and occluded enemies remain elixir threats", () =>
|
|
|
|
|
{
|
|
|
|
|
foreach (var enemy in new[] { Enemy(distance: 30), Enemy(distance: 30, states: [Statuses.Guard]),
|
|
|
|
|
Enemy(distance: 30, states: [1302]), Enemy(distance: 30) with { Targetable = false } })
|
|
|
|
|
None(Decide(Snap(Self(10000), enemy), new(Skill.Elixir) { Occluded = [10] }));
|
|
|
|
|
IsSkill(Decide(Snap(Self(10000), Enemy(distance: 31)), new(Skill.Elixir)), Skill.Elixir);
|
|
|
|
|
IsSkill(Decide(Snap(Self(10000), Enemy() with { Dead = true }), new(Skill.Elixir)), Skill.Elixir);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Sprint requires movement, no attack target, and no existing buff", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap() with { Moving = true };
|
|
|
|
|
IsSkill(Decide(s, new(Skill.Sprint)), Skill.Sprint);
|
|
|
|
|
None(Decide(s with { Moving = false }, new(Skill.Sprint)));
|
|
|
|
|
None(Decide(s with { Enemies = [Enemy()] }, new(Skill.Sprint)));
|
|
|
|
|
None(Decide(s with { Player = Self(60000, Statuses.Sprint) }, new(Skill.Sprint)));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Automatic attack off preserves common survival skills", () =>
|
|
|
|
|
{
|
|
|
|
|
var c = new Settings { AutoAttack = false };
|
|
|
|
|
None(Decide(Snap(null, Enemy()), new(Skill.PowerfulShot), c));
|
|
|
|
|
IsSkill(Decide(Snap(Self(30000), Enemy()), new(Skill.PowerfulShot, Skill.Recuperate), c), Skill.Recuperate);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Emergency-only cast probe never suggests offense or routine elixir", () =>
|
|
|
|
|
{
|
|
|
|
|
None(Decide(Snap(null, Enemy()), new(Actions.All), emergency: true));
|
|
|
|
|
None(Decide(Snap() with { Mp = 0 }, new(Skill.Elixir), emergency: true));
|
|
|
|
|
Suite.True(Decide(Snap(Self(30000)), new(Skill.Recuperate), emergency: true)!.Emergency);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Lowest HP across both hostile groups, not HP ratio or shield", () =>
|
|
|
|
|
{
|
|
|
|
|
var a = Enemy(11, 10000) with { MaxHp = 20000 };
|
|
|
|
|
var b = Enemy(12, 20000) with { MaxHp = 100000 };
|
|
|
|
|
var s = Snap(null, b, a);
|
|
|
|
|
Suite.Equal(11ul, Decide(s, new(Skill.PowerfulShot))!.TargetId);
|
|
|
|
|
Suite.Equal(12ul, Decide(s, new(Skill.PowerfulShot), new() { TargetOrder = TargetOrder.LowestHpRatio })!.TargetId);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("HP ties use distance then current target then stable ID", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(null, Enemy(12, distance: 10), Enemy(11, distance: 10), Enemy(10, distance: 15));
|
|
|
|
|
Suite.Equal(11ul, Decide(s, new(Skill.PowerfulShot))!.TargetId);
|
|
|
|
|
Suite.Equal(12ul, Decide(s with { CurrentTarget = 12 }, new(Skill.PowerfulShot))!.TargetId);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Nearest and mark ordering are independently selectable", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(null, Enemy(11, 100, 20), Enemy(12, 50000, 10) with { AttackMarker = 2 },
|
|
|
|
|
Enemy(13, 60000, 15) with { AttackMarker = 1 });
|
|
|
|
|
Suite.Equal(12ul, Decide(s, new(Skill.PowerfulShot), new() { TargetOrder = TargetOrder.Nearest })!.TargetId);
|
|
|
|
|
Suite.Equal(13ul, Decide(s, new(Skill.PowerfulShot), new() { TargetOrder = TargetOrder.AttackMarker })!.TargetId);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Invalid candidates do not block valid next candidate", () =>
|
|
|
|
|
{
|
|
|
|
|
var good = Enemy(99, 40000);
|
|
|
|
|
var s = Snap(null, Enemy(10, 1) with { Dead = true }, Enemy(11, 2) with { Targetable = false },
|
|
|
|
|
Enemy(12, 3) with { Enemy = false }, Enemy(13, 4, 80), Enemy(14, 5), good);
|
|
|
|
|
Suite.Equal(99ul, Decide(s, new(Skill.PowerfulShot) { Occluded = [14] })!.TargetId);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Guard off excludes normal attacks, on allows them", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(null, Enemy(states: [Statuses.Guard]));
|
|
|
|
|
None(Decide(s, new(Skill.PowerfulShot)));
|
|
|
|
|
IsSkill(Decide(s, new(Skill.PowerfulShot), new() { IgnoreEnemyGuard = true }), Skill.PowerfulShot);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Eagle Eye bypasses Guard but not invincibility", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(Self(60000, Statuses.RoleEagle), Enemy(states: [Statuses.Guard]));
|
|
|
|
|
IsSkill(Decide(s, new(Skill.EagleEyeShot)), Skill.EagleEyeShot);
|
|
|
|
|
None(Decide(s with { Enemies = [Enemy(states: [1302, Statuses.Guard])] }, new(Skill.EagleEyeShot), new() { IgnoreEnemyGuard = true }));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("All independent skill switches prevent corresponding request", () =>
|
|
|
|
|
{
|
|
|
|
|
var c = new Settings { DisabledSkills = Actions.All.ToHashSet(), HelpParty = true };
|
|
|
|
|
None(Decide(Snap(Self(1000, 1343, Statuses.RoleEagle, Statuses.EncoreReady), Enemy()), new(Actions.All), c));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Dead player cannot receive any decision", () => None(Decide(Snap(Self(0), Enemy()), new(Actions.All))));
|
|
|
|
|
suite.Run("Gate waits for new sequence acknowledgement, not stale ack", () =>
|
|
|
|
|
{
|
|
|
|
|
var g = new RequestGate(); g.Submitted(new(Skill.PowerfulShot, 10, "test"), 0, 10, 11);
|
|
|
|
|
Suite.True(g.Update(.5, 11, 10, false, false));
|
|
|
|
|
Suite.True(!g.Update(.6, 11, 11, false, false));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Gate handles delayed sequence increment", () =>
|
|
|
|
|
{
|
|
|
|
|
var g = new RequestGate(); g.Submitted(new(Skill.PowerfulShot, 10, "test"), 0, 10, 10);
|
|
|
|
|
Suite.True(g.Update(.5, 10, 10, false, false));
|
|
|
|
|
Suite.True(!g.Update(.6, 11, 11, false, false));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Gate sequence rollover", () =>
|
|
|
|
|
{
|
|
|
|
|
var g = new RequestGate(); g.Submitted(new(Skill.PowerfulShot, 10, "test"), 0, 65535, 0);
|
|
|
|
|
Suite.True(g.Update(.2, 0, 65535, false, false));
|
|
|
|
|
Suite.True(!g.Update(.3, 0, 0, false, false));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Guard and cleanse guard against delayed status replication", () =>
|
|
|
|
|
{
|
|
|
|
|
foreach (var skill in new[] { Skill.Guard, Skill.Purify, Skill.WardensPaean })
|
|
|
|
|
{
|
|
|
|
|
var g = new RequestGate(); g.Submitted(new(skill, 1, "test"), 0, 1, 2);
|
|
|
|
|
Suite.True(g.Update(.1, 2, 2, false, false));
|
|
|
|
|
Suite.True(!g.Update(.5, 2, 2, false, false));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Actual Guard or changed debuff confirms pending status action", () =>
|
|
|
|
|
{
|
|
|
|
|
var g = new RequestGate(); g.Submitted(new(Skill.Guard, 1, "test"), 0, 1, 2);
|
|
|
|
|
Suite.True(!g.Update(.1, 2, 1, true, false));
|
|
|
|
|
g.Submitted(new(Skill.WardensPaean, 1, "test"), 0, 1, 2);
|
|
|
|
|
Suite.True(!g.Update(.1, 2, 1, false, true));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Timeout causes bounded per-skill per-target backoff", () =>
|
|
|
|
|
{
|
|
|
|
|
var g = new RequestGate(); g.Submitted(new(Skill.PowerfulShot, 10, "test"), 0, 1, 2);
|
|
|
|
|
Suite.True(!g.Update(2, 2, 1, false, false));
|
|
|
|
|
Suite.True(g.IsBlocked(Skill.PowerfulShot, 10, 2.1));
|
|
|
|
|
Suite.True(!g.IsBlocked(Skill.PowerfulShot, 11, 2.1));
|
|
|
|
|
Suite.True(!g.IsBlocked(Skill.HarmonicArrow, 10, 2.1));
|
|
|
|
|
Suite.True(!g.IsBlocked(Skill.PowerfulShot, 10, 2.5));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Reset removes pending actions and stale backoff", () =>
|
|
|
|
|
{
|
|
|
|
|
var g = new RequestGate(); var d = new ActionDecision(Skill.Guard, 1, "test");
|
|
|
|
|
g.Fail(d, 1, "failure"); g.Submitted(d, 1, 1, 2); g.Reset();
|
|
|
|
|
Suite.True(g.Pending == null && !g.IsBlocked(Skill.Guard, 1, 1.1));
|
|
|
|
|
Suite.True(!g.Update(1.1, 2, 1, false, false));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
suite.Run("Finisher Eagle preempts normal GCD, buffs and LB", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(Self(60000, Statuses.RoleEagle), Enemy(hp: 9000)) with { LimitFull = true };
|
|
|
|
|
var d = Decide(s, new(Skill.EagleEyeShot, Skill.ApexArrow, Skill.FinalFantasia));
|
|
|
|
|
IsSkill(d, Skill.EagleEyeShot); Suite.True(d!.Reason.Contains("收割"));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Finisher changes target to lowest effective HP in Eagle range", () =>
|
|
|
|
|
{
|
|
|
|
|
var shielded = Enemy(10, 1000, 10) with { ShieldHp = 20000 };
|
|
|
|
|
var distant = Enemy(11, 8000, 38, Statuses.Guard);
|
|
|
|
|
var near = Enemy(12, 9000, 10);
|
|
|
|
|
var s = Snap(Self(60000, Statuses.RoleEagle), shielded, near, distant) with { CurrentTarget = 10 };
|
|
|
|
|
Suite.Equal(11ul, Decide(s, new(Skill.EagleEyeShot, Skill.PowerfulShot))!.TargetId);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Finisher eligibility includes shield, exact threshold, and invulnerability", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(Self(60000, Statuses.RoleEagle), Enemy(hp: 8000) with { ShieldHp = 2000 });
|
|
|
|
|
var c = new Settings { EagleStrategy = EagleStrategy.FinisherOnly };
|
|
|
|
|
IsSkill(Decide(s, new(Skill.EagleEyeShot), c), Skill.EagleEyeShot);
|
|
|
|
|
None(Decide(s with { Enemies = [Enemy(hp: 8000) with { ShieldHp = 2001 }] }, new(Skill.EagleEyeShot), c));
|
|
|
|
|
None(Decide(s with { Enemies = [Enemy(hp: 8000) with { Invulnerable = true }] }, new(Skill.EagleEyeShot), c));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("First strategy uses Eagle normally without finisher; reserve strategy holds it", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(Self(60000, Statuses.RoleEagle), Enemy());
|
|
|
|
|
IsSkill(Decide(s, new(Skill.EagleEyeShot)), Skill.EagleEyeShot);
|
|
|
|
|
None(Decide(s, new(Skill.EagleEyeShot), new() { EagleStrategy = EagleStrategy.FinisherOnly }));
|
|
|
|
|
IsSkill(Decide(s, new(Skill.EagleEyeShot, Skill.PowerfulShot)), Skill.PowerfulShot);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Survival and shared native availability still take precedence over finishing", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(Self(1000, Statuses.RoleEagle, 1343), Enemy(hp: 1000));
|
|
|
|
|
IsSkill(Decide(s, new(Skill.EagleEyeShot, Skill.Purify)), Skill.Purify);
|
|
|
|
|
IsSkill(Decide(s, new(Skill.EagleEyeShot, Skill.Guard)), Skill.Guard);
|
|
|
|
|
IsSkill(Decide(s, new(Skill.EagleEyeShot, Skill.Recuperate)), Skill.Recuperate);
|
|
|
|
|
IsSkill(Decide(s with { Player = Self(60000, Statuses.RoleEagle) }, new(Skill.PowerfulShot)), Skill.PowerfulShot);
|
|
|
|
|
None(Decide(s, new(Skill.EagleEyeShot), emergency: true));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Finisher setting is adjustable and does not change ordinary HP sorting", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(Self(60000, Statuses.RoleEagle), Enemy(10, 11000), Enemy(11, 12000));
|
|
|
|
|
var c = new Settings { EagleFinisherHp = 11000, EagleStrategy = EagleStrategy.FinisherOnly };
|
|
|
|
|
IsSkill(Decide(s, new(Skill.EagleEyeShot, Skill.PowerfulShot), c), Skill.EagleEyeShot);
|
|
|
|
|
var shielded = Enemy(10, 5000) with { ShieldHp = 50000 };
|
|
|
|
|
Suite.Equal(10ul, Decide(s with { Enemies = [shielded, Enemy(11, 10000)] }, new(Skill.PowerfulShot))!.TargetId);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Eagle range counts as an attack target even while Eagle is on cooldown", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(Self(60000, Statuses.RoleEagle), Enemy(distance: 38)) with { Moving = true };
|
|
|
|
|
None(Decide(s, new(Skill.Sprint)));
|
|
|
|
|
IsSkill(Decide(s with { Player = Self() }, new(Skill.Sprint)), Skill.Sprint);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Enabled only in a started Frontline match as living unmounted Bard", () =>
|
|
|
|
|
{
|
|
|
|
|
var active = new RuntimeConditions(true, true, false, BattlefieldMode.Frontline, true, false, true, true, false, false, true);
|
|
|
|
|
Suite.True(ExecutionPolicy.PauseReason(active) == null);
|
|
|
|
|
RuntimeConditions[] inactive = [active with { Enabled = false }, active with { LoggedIn = false },
|
|
|
|
|
active with { ChangingArea = true }, active with { Mode = BattlefieldMode.None }, active with { DutyStarted = false },
|
|
|
|
|
active with { DutyCompleted = true }, active with { Bard = false }, active with { Alive = false },
|
|
|
|
|
active with { Mounted = true }, active with { Occupied = true }, active with { CoreReady = false }];
|
|
|
|
|
foreach (var s in inactive) Suite.True(ExecutionPolicy.PauseReason(s) != null);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Dispatcher never overrides a queued action, animation lock, active cast, pending request or Guard", () =>
|
|
|
|
|
{
|
|
|
|
|
Suite.True(ExecutionPolicy.CanDispatch(false, 0, false, false, false));
|
|
|
|
|
Suite.True(!ExecutionPolicy.CanDispatch(true, 0, false, false, false));
|
|
|
|
|
Suite.True(!ExecutionPolicy.CanDispatch(false, .01f, false, false, false));
|
|
|
|
|
Suite.True(!ExecutionPolicy.CanDispatch(false, 0, true, false, false));
|
|
|
|
|
Suite.True(!ExecutionPolicy.CanDispatch(false, 0, false, true, false));
|
|
|
|
|
Suite.True(!ExecutionPolicy.CanDispatch(false, 0, false, false, true));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Weaving reserves shared GCD boundary without blocking off-GCD windows", () =>
|
|
|
|
|
{
|
|
|
|
|
Suite.True(ExecutionPolicy.CanWeave(0)); Suite.True(ExecutionPolicy.CanWeave(.65f));
|
|
|
|
|
Suite.True(ExecutionPolicy.CanWeave(2.4f)); Suite.True(!ExecutionPolicy.CanWeave(.64f));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Only matching action, target and native sequence establishes cast ownership", () =>
|
|
|
|
|
{
|
|
|
|
|
Suite.True(ExecutionPolicy.OwnsCast(29391, 10, 5, 29391, 10, 5));
|
|
|
|
|
Suite.True(!ExecutionPolicy.OwnsCast(29391, 10, 5, 29391, 10, 6));
|
|
|
|
|
Suite.True(!ExecutionPolicy.OwnsCast(29391, 10, 5, 29391, 11, 5));
|
|
|
|
|
Suite.True(!ExecutionPolicy.OwnsCast(29391, 10, 5, 29055, 10, 5));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Enemy Guard cancellation follows the same ignore switch as target selection", () =>
|
|
|
|
|
{
|
|
|
|
|
Suite.True(ExecutionPolicy.ShouldCancelCast(Skill.PowerfulShot, true, false, true, false, null, true, false));
|
|
|
|
|
Suite.True(!ExecutionPolicy.ShouldCancelCast(Skill.PowerfulShot, true, false, true, true, null, true, false));
|
|
|
|
|
Suite.True(ExecutionPolicy.ShouldCancelCast(Skill.PowerfulShot, true, true, false, true, null, true, false));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Manual casts are untouched; plugin offensive casts yield to survival", () =>
|
|
|
|
|
{
|
|
|
|
|
var heal = new ActionDecision(Skill.Recuperate, 1, "heal", true);
|
|
|
|
|
Suite.True(!ExecutionPolicy.ShouldCancelCast(Skill.PowerfulShot, false, true, true, false, heal, true, true));
|
|
|
|
|
Suite.True(ExecutionPolicy.ShouldCancelCast(Skill.PowerfulShot, true, false, false, false, heal, true, false));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Elixir completes safely when a routine heal comes off cooldown", () =>
|
|
|
|
|
{
|
|
|
|
|
var heal = new ActionDecision(Skill.Recuperate, 1, "heal", true);
|
|
|
|
|
Suite.True(!ExecutionPolicy.ShouldCancelCast(Skill.Elixir, true, false, false, false, heal, false, false));
|
|
|
|
|
Suite.True(ExecutionPolicy.ShouldCancelCast(Skill.Elixir, true, false, false, false, heal, true, false));
|
|
|
|
|
var purify = new ActionDecision(Skill.Purify, 1, "cleanse", true);
|
|
|
|
|
Suite.True(ExecutionPolicy.ShouldCancelCast(Skill.Elixir, true, false, false, false, purify, false, true));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Long elixir cast is not classified as request timeout", () =>
|
|
|
|
|
{
|
|
|
|
|
var g = new RequestGate(); g.Submitted(new(Skill.Elixir, 1, "drink"), 0, 1, 2);
|
|
|
|
|
Suite.True(g.Update(3, 2, 1, false, false, ownedCastInProgress: true));
|
|
|
|
|
Suite.True(!g.Update(4.5, 2, 2, false, false));
|
|
|
|
|
Suite.True(!g.IsBlocked(Skill.Elixir, 1, 4.5));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
suite.Run("Guard is never classified as generic invulnerability", () =>
|
|
|
|
|
{
|
|
|
|
|
Suite.True(!Statuses.IsInvulnerability(Statuses.Guard, 15024));
|
|
|
|
|
Suite.True(Statuses.IsInvulnerability(1302, 0));
|
|
|
|
|
Suite.True(Statuses.IsInvulnerability(99999, 15024));
|
|
|
|
|
Suite.True(!Statuses.IsInvulnerability(Statuses.March, 0));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Target planning updates during acknowledgement wait without replacing accepted target", () =>
|
|
|
|
|
{
|
|
|
|
|
var gate = new RequestGate();
|
|
|
|
|
gate.Submitted(new(Skill.PowerfulShot, 10, "already sent"), 0, 1, 2);
|
|
|
|
|
var s = Snap(null, Enemy(10, 40000), Enemy(11, 5000)) with { CurrentTarget = 10 };
|
|
|
|
|
var f = new Fake(Skill.PowerfulShot);
|
|
|
|
|
Suite.True(gate.Update(.05, 2, 1, false, false));
|
|
|
|
|
Suite.Equal(11ul, TargetSelector.PlanTarget(s, new(), f, f)!.Id);
|
|
|
|
|
Suite.Equal(10ul, gate.Pending!.TargetId);
|
|
|
|
|
Suite.True(!ExecutionPolicy.CanDispatch(false, 0, false, gate.Pending != null, false));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Target planning follows current HP changes without waiting for target death", () =>
|
|
|
|
|
{
|
|
|
|
|
var f = new Fake(Skill.PowerfulShot);
|
|
|
|
|
var s = Snap(null, Enemy(10, 5000), Enemy(11, 10000));
|
|
|
|
|
Suite.Equal(10ul, TargetSelector.PlanTarget(s, new(), f, f)!.Id);
|
|
|
|
|
s = s with { Enemies = [Enemy(10, 20000), Enemy(11, 10000)], CurrentTarget = 10 };
|
|
|
|
|
Suite.Equal(11ul, TargetSelector.PlanTarget(s, new(), f, f)!.Id);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Guard-only target remains available to Eagle outside normal job range", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(Self(60000, Statuses.RoleEagle), Enemy(22, 30000, 38, Statuses.Guard));
|
|
|
|
|
var f = new Fake(Skill.EagleEyeShot);
|
|
|
|
|
Suite.Equal(22ul, TargetSelector.PlanTarget(s, new(), f, f)!.Id);
|
|
|
|
|
IsSkill(Decide(s, f), Skill.EagleEyeShot);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Guard finisher planning preempts unguarded ordinary target", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(Self(60000, Statuses.RoleEagle), Enemy(10, 7000) with { ShieldHp = 10000 }, Enemy(11, 8000, 38, Statuses.Guard));
|
|
|
|
|
var f = new Fake(Skill.EagleEyeShot, Skill.PowerfulShot);
|
|
|
|
|
Suite.Equal(11ul, TargetSelector.PlanTarget(s with { CurrentTarget = 10 }, new(), f, f)!.Id);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Eagle on cooldown does not lure ordinary attacks onto Guard", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(Self(60000, Statuses.RoleEagle), Enemy(10, 5000, 20, Statuses.Guard), Enemy(11, 40000));
|
|
|
|
|
var f = new Fake(Skill.PowerfulShot);
|
|
|
|
|
Suite.Equal(11ul, TargetSelector.PlanTarget(s, new(), f, f)!.Id);
|
|
|
|
|
Suite.True(TargetSelector.PlanTarget(s with { Enemies = [s.Enemies[0]] }, new(), f, f) == null);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Target planning respects own Guard, auto-attack switch and manual Eagle disable", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(Self(60000, Statuses.RoleEagle), Enemy(states: [Statuses.Guard]));
|
|
|
|
|
var f = new Fake(Skill.EagleEyeShot);
|
|
|
|
|
Suite.True(TargetSelector.PlanTarget(s, new() { AutoAttack = false }, f, f) == null);
|
|
|
|
|
Suite.True(TargetSelector.PlanTarget(s with { GuardPending = true }, new(), f, f) == null);
|
|
|
|
|
Suite.True(TargetSelector.PlanTarget(s with { Player = Self(60000, Statuses.Guard, Statuses.RoleEagle) }, new(), f, f) == null);
|
|
|
|
|
Suite.True(TargetSelector.PlanTarget(s, new() { DisabledSkills = [Skill.EagleEyeShot] }, f, f) == null);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
Actor Ice(ulong id = 50, uint hp = 1000) => Enemy(id, hp, 10) with { IsObjective = true, Name = "Ice", MaxHp = 1000000 };
|
|
|
|
|
Actor TimedSelf(params (uint Status, float Seconds)[] procs) => Self(60000, procs.Select(p => p.Status).ToArray())
|
|
|
|
|
with { StatusRemaining = procs.ToDictionary(p => p.Status, p => p.Seconds) };
|
|
|
|
|
|
|
|
|
|
suite.Run("Objectives enabled by default and ordinary rotation attacks ice", () =>
|
|
|
|
|
{
|
|
|
|
|
Suite.True(new Settings().AttackObjectives);
|
|
|
|
|
var d = Decide(Snap(null, Ice()), new(Skill.PowerfulShot));
|
|
|
|
|
IsSkill(d, Skill.PowerfulShot); Suite.Equal(50ul, d!.TargetId);
|
|
|
|
|
IsSkill(Decide(Snap(Self(60000, Statuses.BlastReady), Ice()), new(Skill.BlastArrow)), Skill.BlastArrow);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Players outrank lower HP or marked ice under every sorting mode", () =>
|
|
|
|
|
{
|
|
|
|
|
foreach (var order in Enum.GetValues<TargetOrder>())
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(null, Ice() with { AttackMarker = 1 }, Enemy(10, 50000, 20));
|
|
|
|
|
Suite.Equal(10ul, Decide(s, new(Skill.PowerfulShot), new() { TargetOrder = order })!.TargetId);
|
|
|
|
|
var f = new Fake(Skill.PowerfulShot);
|
|
|
|
|
Suite.Equal(10ul, TargetSelector.PlanTarget(s, new() { TargetOrder = order }, f, f)!.Id);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Global player priority prevents a higher priority ice skill preempting a player skill", () =>
|
|
|
|
|
{
|
|
|
|
|
var f = new Fake(Skill.ApexArrow, Skill.SilentNocturne) { Blocked = [(Skill.ApexArrow, 10)] };
|
|
|
|
|
var d = Decide(Snap(null, Ice(), Enemy()), f);
|
|
|
|
|
IsSkill(d, Skill.SilentNocturne); Suite.Equal(10ul, d!.TargetId);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Invalid players permit objective fallback", () =>
|
|
|
|
|
{
|
|
|
|
|
foreach (var player in new[] { Enemy() with { Dead = true }, Enemy() with { Targetable = false },
|
|
|
|
|
Enemy(distance: 80), Enemy(states: [Statuses.Guard]), Enemy() with { Invulnerable = true }, Enemy() })
|
|
|
|
|
{
|
|
|
|
|
var f = new Fake(Skill.PowerfulShot) { Occluded = player.Status.Count == 0 && player.Alive && player.Targetable && !player.Invulnerable && player.Distance == 20 ? [10] : [] };
|
|
|
|
|
Suite.Equal(50ul, Decide(Snap(null, player, Ice()), f)!.TargetId);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Eagle player priority follows readiness and equipped role", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(Self(60000, Statuses.RoleEagle), Ice(), Enemy(10, 30000, 38, Statuses.Guard));
|
|
|
|
|
var d = Decide(s, new(Skill.EagleEyeShot, Skill.PowerfulShot));
|
|
|
|
|
IsSkill(d, Skill.EagleEyeShot); Suite.Equal(10ul, d!.TargetId);
|
|
|
|
|
Suite.Equal(50ul, Decide(s, new(Skill.PowerfulShot))!.TargetId);
|
|
|
|
|
Suite.Equal(50ul, Decide(s with { Player = Self() }, new(Skill.EagleEyeShot, Skill.PowerfulShot))!.TargetId);
|
|
|
|
|
Suite.Equal(50ul, Decide(s, new(Skill.EagleEyeShot, Skill.PowerfulShot), new() { EagleStrategy = EagleStrategy.FinisherOnly })!.TargetId);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Eagle never spends on objectives even in finisher range", () =>
|
|
|
|
|
{
|
|
|
|
|
foreach (var strategy in Enum.GetValues<EagleStrategy>())
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(Self(60000, Statuses.RoleEagle), Ice());
|
|
|
|
|
var f = new Fake(Skill.EagleEyeShot);
|
|
|
|
|
var c = new Settings { EagleStrategy = strategy, DisabledSkills = Actions.All.Where(a => a != Skill.EagleEyeShot).ToHashSet() };
|
|
|
|
|
None(Decide(s, f, c));
|
|
|
|
|
Suite.True(TargetSelector.EagleFinisher(s, c, f) == null);
|
|
|
|
|
Suite.True(TargetSelector.PlanTarget(s, c, f, f) == null);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Objective switch excludes ice from offense and display planning", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(null, Ice()); var c = new Settings { AttackObjectives = false }; var f = new Fake(Skill.PowerfulShot);
|
|
|
|
|
None(Decide(s, f, c)); Suite.True(TargetSelector.PlanTarget(s, c, f, f) == null);
|
|
|
|
|
Suite.Equal(10ul, Decide(s with { Enemies = [Ice(), Enemy()] }, f, c)!.TargetId);
|
|
|
|
|
IsSkill(Decide(s with { Player = Self(30000) }, new(Skill.Recuperate), c), Skill.Recuperate);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Dead inaccessible or immune objectives are skipped", () =>
|
|
|
|
|
{
|
|
|
|
|
foreach (var ice in new[] { Ice() with { Dead = true }, Ice() with { Targetable = false },
|
|
|
|
|
Ice() with { Invulnerable = true }, Ice() with { Hp = 0 }, Ice() with { Distance = 80 } })
|
|
|
|
|
None(Decide(Snap(null, ice), new(Skill.PowerfulShot)));
|
|
|
|
|
None(Decide(Snap(null, Ice()), new(Skill.PowerfulShot) { Occluded = [50] }));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Ice does not prevent safe elixir but protected players still do", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(Self(10000), Ice());
|
|
|
|
|
IsSkill(Decide(s, new(Skill.Elixir, Skill.PowerfulShot)), Skill.Elixir);
|
|
|
|
|
Suite.True(!TargetSelector.HasNearbyThreat(s));
|
|
|
|
|
Suite.True(TargetSelector.HasNearbyThreat(s with { Enemies = [Ice(), Enemy(states: [Statuses.Guard])] }));
|
|
|
|
|
None(Decide(s with { Enemies = [Ice(), Enemy(states: [Statuses.Guard])] }, new(Skill.Elixir)));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Objective target planning returns to players as they enter range", () =>
|
|
|
|
|
{
|
|
|
|
|
var f = new Fake(Skill.PowerfulShot); var s = Snap(null, Ice(), Enemy(distance: 80));
|
|
|
|
|
Suite.Equal(50ul, TargetSelector.PlanTarget(s, new(), f, f)!.Id);
|
|
|
|
|
Suite.Equal(10ul, TargetSelector.PlanTarget(s with { Enemies = [Ice(), Enemy()], CurrentTarget = 50 }, new(), f, f)!.Id);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Display follows objective fallback when all player attacks are unavailable", () =>
|
|
|
|
|
{
|
|
|
|
|
var f = new Fake(Skill.PowerfulShot) { Blocked = [(Skill.PowerfulShot, 10)] };
|
|
|
|
|
var s = Snap(null, Ice(), Enemy());
|
|
|
|
|
Suite.Equal(50ul, Decide(s, f)!.TargetId);
|
|
|
|
|
Suite.Equal(50ul, TargetSelector.PlanTarget(s, new(), f, f)!.Id);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Expiring Pitch Blast and Encore preempt ordinary damage and LB", () =>
|
|
|
|
|
{
|
|
|
|
|
foreach (var (skill, status) in new[] { (Skill.PitchPerfect, Statuses.Repertoire),
|
|
|
|
|
(Skill.BlastArrow, Statuses.BlastReady), (Skill.EncoreOfLight, Statuses.EncoreReady) })
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(TimedSelf((status, 3)), Enemy()) with { LimitFull = true };
|
|
|
|
|
IsSkill(Decide(s, new(skill, Skill.HarmonicArrow, Skill.FinalFantasia)), skill);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Nonexpiring or unknown proc duration preserves normal RSR ordering", () =>
|
|
|
|
|
{
|
|
|
|
|
foreach (var seconds in new[] { 3.01f, 10f, 0f, -1f, float.NaN })
|
|
|
|
|
IsSkill(Decide(Snap(TimedSelf((Statuses.Repertoire, seconds)), Enemy()), new(Skill.PitchPerfect, Skill.HarmonicArrow)), Skill.HarmonicArrow);
|
|
|
|
|
IsSkill(Decide(Snap(Self(60000, Statuses.Repertoire), Enemy()), new(Skill.PitchPerfect, Skill.HarmonicArrow)), Skill.HarmonicArrow);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Earliest expiration wins and unusable expiring skills fall through", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(TimedSelf((Statuses.Repertoire, 2), (Statuses.BlastReady, 1), (Statuses.EncoreReady, .5f)), Enemy());
|
|
|
|
|
IsSkill(Decide(s, new(Skill.PitchPerfect, Skill.BlastArrow, Skill.EncoreOfLight)), Skill.EncoreOfLight);
|
|
|
|
|
IsSkill(Decide(s, new(Skill.PitchPerfect, Skill.BlastArrow)), Skill.BlastArrow);
|
|
|
|
|
IsSkill(Decide(s, new(Skill.HarmonicArrow, Skill.PitchPerfect), new() { DisabledSkills = [Skill.PitchPerfect] }), Skill.HarmonicArrow);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Survival and player Eagle finishers outrank expiration rescue", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(TimedSelf((Statuses.RoleEagle, 100), (Statuses.Repertoire, 1)), Enemy(hp: 1000));
|
|
|
|
|
IsSkill(Decide(s, new(Skill.PitchPerfect, Skill.EagleEyeShot)), Skill.EagleEyeShot);
|
|
|
|
|
IsSkill(Decide(s with { Player = s.Player with { Hp = 30000 } }, new(Skill.PitchPerfect, Skill.Recuperate)), Skill.Recuperate);
|
|
|
|
|
None(Decide(s, new(Skill.PitchPerfect), new() { AutoAttack = false }));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Expiration rescue also damages objectives without defeating player priority", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(TimedSelf((Statuses.BlastReady, 1)), Ice());
|
|
|
|
|
Suite.Equal(50ul, Decide(s, new(Skill.BlastArrow))!.TargetId);
|
|
|
|
|
Suite.Equal(10ul, Decide(s with { Enemies = [Ice(), Enemy()] }, new(Skill.BlastArrow))!.TargetId);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Shared recast identification includes additional groups", () =>
|
|
|
|
|
{
|
|
|
|
|
Suite.True(ExecutionPolicy.SharesGcd(57, -1, 57));
|
|
|
|
|
Suite.True(ExecutionPolicy.SharesGcd(10, 57, 57));
|
|
|
|
|
Suite.True(!ExecutionPolicy.SharesGcd(10, -1, 57));
|
|
|
|
|
Suite.True(!ExecutionPolicy.SharesGcd(-1, -1, -1));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Inactive or completed recasts cannot create a phantom weave lock", () =>
|
|
|
|
|
{
|
|
|
|
|
Suite.Equal(0f, ExecutionPolicy.RecastRemaining(false, 2.4f, 2));
|
|
|
|
|
Suite.True(ExecutionPolicy.CanWeave(ExecutionPolicy.RecastRemaining(false, 2.4f, 2)));
|
|
|
|
|
Suite.True(!ExecutionPolicy.CanWeave(ExecutionPolicy.RecastRemaining(true, 2.4f, 2)));
|
|
|
|
|
Suite.Equal(0f, ExecutionPolicy.RecastRemaining(true, 2.4f, 3));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
suite.Run("Encore fires before ready filler and proc generators without waiting for expiry", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(TimedSelf((Statuses.EncoreReady, 29)), Enemy(distance: 8));
|
|
|
|
|
var d = Decide(s, new(Skill.PowerfulShot, Skill.ApexArrow, Skill.HarmonicArrow, Skill.RepellingShot, Skill.SilentNocturne, Skill.EncoreOfLight));
|
|
|
|
|
IsSkill(d, Skill.EncoreOfLight);
|
|
|
|
|
Suite.True(d!.Reason.Contains("极限技后续"));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Encore cannot be starved by short weave windows under stacked haste", () =>
|
|
|
|
|
{
|
|
|
|
|
foreach (var remaining in new[] { .01f, .3f, .64f })
|
|
|
|
|
{
|
|
|
|
|
var f = new Fake(Skill.PowerfulShot, Skill.EncoreOfLight) { SharedGcdRemaining = remaining };
|
|
|
|
|
IsSkill(Decide(Snap(TimedSelf((Statuses.EncoreReady, 25)), Enemy()), f), Skill.EncoreOfLight);
|
|
|
|
|
Suite.True(!ExecutionPolicy.CanWeave(remaining, Skill.SilentNocturne));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Encore still yields to survival and Eagle finishers but outranks ordinary Eagle", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(Self(60000, Statuses.EncoreReady, Statuses.RoleEagle), Enemy());
|
|
|
|
|
IsSkill(Decide(s, new(Skill.EncoreOfLight, Skill.EagleEyeShot)), Skill.EncoreOfLight);
|
|
|
|
|
IsSkill(Decide(s with { Enemies = [Enemy(hp: 9000)] }, new(Skill.EncoreOfLight, Skill.EagleEyeShot)), Skill.EagleEyeShot);
|
|
|
|
|
IsSkill(Decide(s with { Player = s.Player with { Hp = 30000 } }, new(Skill.EncoreOfLight, Skill.Recuperate)), Skill.Recuperate);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Encore cannot bypass its status toggle target or native availability", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(Self(60000, Statuses.EncoreReady), Enemy());
|
|
|
|
|
IsSkill(Decide(s with { Player = Self() }, new(Skill.EncoreOfLight, Skill.PowerfulShot)), Skill.PowerfulShot);
|
|
|
|
|
IsSkill(Decide(s, new(Skill.EncoreOfLight, Skill.PowerfulShot), new() { DisabledSkills = [Skill.EncoreOfLight] }), Skill.PowerfulShot);
|
|
|
|
|
IsSkill(Decide(s, new(Skill.PowerfulShot)), Skill.PowerfulShot);
|
|
|
|
|
None(Decide(s with { Enemies = [Enemy(states: [Statuses.Guard])] }, new(Skill.EncoreOfLight)));
|
|
|
|
|
None(Decide(s with { Enemies = [Enemy(distance: 30)] }, new(Skill.EncoreOfLight)));
|
|
|
|
|
None(Decide(s, new(Skill.EncoreOfLight) { Occluded = [10] }));
|
|
|
|
|
None(Decide(s with { GuardPending = true }, new(Skill.EncoreOfLight)));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Encore uses players before ice and does not divert available player attacks", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(Self(60000, Statuses.EncoreReady), Ice(), Enemy());
|
|
|
|
|
Suite.Equal(10ul, Decide(s, new(Skill.EncoreOfLight))!.TargetId);
|
|
|
|
|
var d = Decide(s, new(Skill.EncoreOfLight, Skill.PowerfulShot) { Blocked = [(Skill.EncoreOfLight, 10)] });
|
|
|
|
|
IsSkill(d, Skill.PowerfulShot); Suite.Equal(10ul, d!.TargetId);
|
|
|
|
|
Suite.Equal(50ul, Decide(s with { Enemies = [Ice()] }, new(Skill.EncoreOfLight))!.TargetId);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Encore remains usable at zero player MP and requires the transformed LB ID", () =>
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(Self(60000, Statuses.EncoreReady), Enemy()) with { Mp = 0 };
|
|
|
|
|
IsSkill(Decide(s, new(Skill.EncoreOfLight) { Mp = 0 }), Skill.EncoreOfLight);
|
|
|
|
|
None(Decide(s, new(Skill.EncoreOfLight) { AdjustedIds = { [Skill.EncoreOfLight] = (uint)Skill.FinalFantasia } }));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("All known Harmonic variants survive native ID validation and reach rotation", () =>
|
|
|
|
|
{
|
|
|
|
|
foreach (var id in new uint[] { 41464, 41465, 41466, 41964 })
|
|
|
|
|
{
|
|
|
|
|
var f = new Fake(Skill.HarmonicArrow, Skill.PowerfulShot) { AdjustedIds = { [Skill.HarmonicArrow] = id } };
|
|
|
|
|
IsSkill(Decide(Snap(null, Enemy()), f), Skill.HarmonicArrow);
|
|
|
|
|
f.Charges[Skill.HarmonicArrow] = 0;
|
|
|
|
|
IsSkill(Decide(Snap(null, Enemy()), f), Skill.PowerfulShot);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Harmonic variant acceptance does not permit unknown or unrelated transformations", () =>
|
|
|
|
|
{
|
|
|
|
|
foreach (var id in new uint[] { 0, 41965, (uint)Skill.EncoreOfLight })
|
|
|
|
|
IsSkill(Decide(Snap(null, Enemy()), new(Skill.HarmonicArrow, Skill.PowerfulShot)
|
|
|
|
|
{ AdjustedIds = { [Skill.HarmonicArrow] = id } }), Skill.PowerfulShot);
|
|
|
|
|
Suite.True(!Actions.Matches(Skill.PowerfulShot, (uint)Skill.PitchPerfect));
|
|
|
|
|
Suite.True(!Actions.Matches(Skill.ApexArrow, (uint)Skill.BlastArrow));
|
|
|
|
|
Suite.True(!Actions.Matches(Skill.EncoreOfLight, 41964));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
suite.Run("Battlefield mode identification supports Frontline and Rival Wings only", () =>
|
|
|
|
|
{
|
|
|
|
|
Suite.Equal(BattlefieldMode.Frontline, Battlefield.Identify(18, true));
|
|
|
|
|
Suite.Equal(BattlefieldMode.RivalWings, Battlefield.Identify(39, true));
|
|
|
|
|
foreach (var use in new uint[] { 0, 3, 12, 28, 37, 99 })
|
|
|
|
|
Suite.Equal(BattlefieldMode.None, Battlefield.Identify(use, true));
|
|
|
|
|
Suite.Equal(BattlefieldMode.None, Battlefield.Identify(39, false));
|
|
|
|
|
Suite.Equal(BattlefieldMode.None, Battlefield.Identify(18, false));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Rival Wings runs on foot and retains all ordinary pause gates", () =>
|
|
|
|
|
{
|
|
|
|
|
var active = new RuntimeConditions(true, true, false, BattlefieldMode.RivalWings, true, false, true, true, false, false, true);
|
|
|
|
|
Suite.True(ExecutionPolicy.PauseReason(active) == null);
|
|
|
|
|
foreach (var stopped in new[] { active with { Enabled = false }, active with { LoggedIn = false },
|
|
|
|
|
active with { ChangingArea = true }, active with { DutyStarted = false }, active with { DutyCompleted = true },
|
|
|
|
|
active with { Bard = false }, active with { Alive = false }, active with { Mounted = true },
|
|
|
|
|
active with { Occupied = true }, active with { CoreReady = false }, active with { Piloting = true } })
|
|
|
|
|
Suite.True(ExecutionPolicy.PauseReason(stopped) != null);
|
|
|
|
|
Suite.True(ExecutionPolicy.PauseReason(active with { Piloting = true })!.Contains("机甲"));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Mech detection covers condition status and content armor with mode isolation", () =>
|
|
|
|
|
{
|
|
|
|
|
Suite.True(Battlefield.IsPiloting(BattlefieldMode.RivalWings, true, false, 0));
|
|
|
|
|
Suite.True(Battlefield.IsPiloting(BattlefieldMode.RivalWings, false, true, 0));
|
|
|
|
|
Suite.True(Battlefield.IsPiloting(BattlefieldMode.RivalWings, false, false, 1));
|
|
|
|
|
Suite.True(!Battlefield.IsPiloting(BattlefieldMode.RivalWings, false, false, 0));
|
|
|
|
|
Suite.True(!Battlefield.IsPiloting(BattlefieldMode.Frontline, true, true, 1));
|
|
|
|
|
Suite.True(!Battlefield.IsPiloting(BattlefieldMode.None, true, true, 1));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Battle NPC candidates exclude pets companions owned summons and noncombat kinds", () =>
|
|
|
|
|
{
|
|
|
|
|
foreach (byte kind in new byte[] { 0, 1, 5 })
|
|
|
|
|
{
|
|
|
|
|
Suite.True(Battlefield.IsBattleNpcCandidate(kind, 0));
|
|
|
|
|
Suite.True(Battlefield.IsBattleNpcCandidate(kind, 0xE0000000));
|
|
|
|
|
Suite.True(!Battlefield.IsBattleNpcCandidate(kind, 12345));
|
|
|
|
|
}
|
|
|
|
|
foreach (byte kind in new byte[] { 2, 3, 4, 6, 7, 9 })
|
|
|
|
|
Suite.True(!Battlefield.IsBattleNpcCandidate(kind, 0));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Rival Wings hostile NPCs block elixir even behind walls or while untargetable", () =>
|
|
|
|
|
{
|
|
|
|
|
foreach (var npc in new[] { Ice() with { ThreateningObjective = true },
|
|
|
|
|
Ice() with { ThreateningObjective = true, Targetable = false },
|
|
|
|
|
Ice() with { ThreateningObjective = true, Invulnerable = true } })
|
|
|
|
|
{
|
|
|
|
|
var s = Snap(Self(10000), npc);
|
|
|
|
|
Suite.True(TargetSelector.HasNearbyThreat(s));
|
|
|
|
|
None(Decide(s, new(Skill.Elixir) { Occluded = [npc.Id] }, new() { AttackObjectives = false }));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Distant dead and friendly NPCs do not block elixir and ice behavior is unchanged", () =>
|
|
|
|
|
{
|
|
|
|
|
foreach (var npc in new[] { Ice(), Ice() with { ThreateningObjective = true, Distance = 31 },
|
|
|
|
|
Ice() with { ThreateningObjective = true, Dead = true }, Ice() with { ThreateningObjective = true, Enemy = false } })
|
|
|
|
|
IsSkill(Decide(Snap(Self(10000), npc), new(Skill.Elixir)), Skill.Elixir);
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Players outrank enemy mech and NPC targets while Eagle avoids mechs", () =>
|
|
|
|
|
{
|
|
|
|
|
var mech = Enemy(50, 1000, 10, Statuses.Warmachina) with { IsObjective = true, ThreateningObjective = true };
|
|
|
|
|
var s = Snap(Self(60000, Statuses.RoleEagle), mech, Enemy(10, 50000));
|
|
|
|
|
Suite.Equal(10ul, Decide(s, new(Skill.PowerfulShot, Skill.EagleEyeShot))!.TargetId);
|
|
|
|
|
var onlyMech = s with { Enemies = [mech] };
|
|
|
|
|
IsSkill(Decide(onlyMech, new(Skill.PowerfulShot, Skill.EagleEyeShot)), Skill.PowerfulShot);
|
|
|
|
|
None(Decide(onlyMech, new(Skill.EagleEyeShot)));
|
|
|
|
|
None(Decide(onlyMech, new(Skill.PowerfulShot), new() { AttackObjectives = false }));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Unattackable structures and friendly battle NPCs cannot receive attacks", () =>
|
|
|
|
|
{
|
|
|
|
|
foreach (var npc in new[] { Ice() with { Enemy = false }, Ice() with { Invulnerable = true },
|
|
|
|
|
Ice() with { Targetable = false } })
|
|
|
|
|
None(Decide(Snap(null, npc with { ThreateningObjective = true }), new(Skill.PowerfulShot, Skill.HarmonicArrow)));
|
|
|
|
|
});
|
|
|
|
|
suite.Run("Rival Wings NPC threats let emergency healing interrupt unsafe elixir", () =>
|
|
|
|
|
{
|
|
|
|
|
var heal = new ActionDecision(Skill.Recuperate, 1, "heal", true);
|
|
|
|
|
var s = Snap(Self(10000), Ice() with { ThreateningObjective = true });
|
|
|
|
|
Suite.True(ExecutionPolicy.ShouldCancelCast(Skill.Elixir, true, false, false, false,
|
|
|
|
|
heal, TargetSelector.HasNearbyThreat(s), false));
|
|
|
|
|
Suite.True(!ExecutionPolicy.ShouldCancelCast(Skill.Elixir, true, false, false, false,
|
|
|
|
|
heal, TargetSelector.HasNearbyThreat(s with { Enemies = [Ice()] }), false));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
OptimizationTests.Run(suite);
|
|
|
|
|
PressureTests.Run(suite);
|
|
|
|
|
suite.Finish();
|
|
|
|
|
|
|
|
|
|
sealed class Fake(params Skill[] ready) : IActionAvailability, ITargetProbe
|
|
|
|
|
{
|
|
|
|
|
public Dictionary<Skill, int> Charges { get; } = [];
|
|
|
|
|
public Dictionary<Skill, uint> AdjustedIds { get; } = [];
|
|
|
|
|
public float? SharedGcdRemaining { get; init; }
|
|
|
|
|
public HashSet<ulong> Occluded { get; init; } = [];
|
|
|
|
|
public HashSet<(Skill, ulong)> Blocked { get; init; } = [];
|
|
|
|
|
public int Mp { get; init; } = 10000;
|
|
|
|
|
public bool CanTarget(Skill skill, Actor a) => a.Alive && a.Targetable && !Occluded.Contains(a.Id)
|
|
|
|
|
&& (!Actions.IsHostile(skill) || a.Distance <= (skill == Skill.RepellingShot ? 10 : skill == Skill.SilentNocturne ? 20 : skill == Skill.EagleEyeShot ? 40 : 25));
|
|
|
|
|
public bool CanUse(Skill skill, Actor a) => ready.Contains(skill) && !Blocked.Contains((skill, a.Id)) && CanTarget(skill, a)
|
|
|
|
|
&& Actions.Matches(skill, AdjustedIds.GetValueOrDefault(skill, (uint)skill))
|
|
|
|
|
&& (SharedGcdRemaining is not { } remain || skill is not (Skill.EncoreOfLight or Skill.SilentNocturne or Skill.RepellingShot)
|
|
|
|
|
|| ExecutionPolicy.CanWeave(remain, skill))
|
|
|
|
|
&& (!Charges.TryGetValue(skill, out var count) || count > 0)
|
|
|
|
|
&& (skill is not (Skill.Recuperate or Skill.Purify) || Mp >= 2000);
|
|
|
|
|
}
|
|
|
|
|
sealed class Suite
|
|
|
|
|
{
|
|
|
|
|
private int passed, failed;
|
|
|
|
|
public void Run(string name, Action test)
|
|
|
|
|
{
|
|
|
|
|
try { test(); passed++; Console.WriteLine($"PASS {name}"); }
|
|
|
|
|
catch (Exception e) { failed++; Console.WriteLine($"FAIL {name}: {e.Message}"); }
|
|
|
|
|
}
|
|
|
|
|
public static void True(bool b, string message = "Assertion failed") { if (!b) throw new Exception(message); }
|
|
|
|
|
public static void Equal<T>(T expected, T actual) { if (!EqualityComparer<T>.Default.Equals(expected, actual)) throw new Exception($"Expected {expected}, got {actual}"); }
|
|
|
|
|
public void Finish() { Console.WriteLine($"{passed} passed, {failed} failed"); Environment.ExitCode = failed == 0 ? 0 : 1; }
|
|
|
|
|
}
|