using System.Collections.Generic; using NUnit.Framework; using BaseGames.Enemies.Abilities; namespace BaseGames.Tests.EditMode.Enemies { public class EnemyAttackSelectorTests { sealed class FakeCandidate : IAttackCandidate { public bool CanUseV = true, InRange = true, ReqLOS = false, ReqGround = false, Executed; public float WeightV = 1f; public int PriorityV = 0; public bool CanUse => CanUseV; public bool RequiresLineOfSight => ReqLOS; public bool RequiresGrounded => ReqGround; public float Weight => WeightV; public int Priority => PriorityV; public bool InAttackRange() => InRange; public bool Execute() { Executed = true; return true; } } static EnemyAttackSelector Sel(params IAttackCandidate[] cs) => new EnemyAttackSelector(new List(cs)); [Test] public void Empty_NoEligible() { var s = Sel(); Assert.IsFalse(s.HasEligible(true, true)); Assert.IsNull(s.Select(true, true, AttackSelectionMode.Priority)); } [Test] public void ExcludesOnCooldown() { var c = new FakeCandidate { CanUseV = false }; Assert.IsFalse(Sel(c).HasEligible(true, true)); } [Test] public void ExcludesOutOfRange() { var c = new FakeCandidate { InRange = false }; Assert.IsFalse(Sel(c).HasEligible(true, true)); } [Test] public void ExcludesWhenNeedsLOS_ButNoLOS() { var c = new FakeCandidate { ReqLOS = true }; Assert.IsFalse(Sel(c).HasEligible(false, true)); Assert.IsTrue (Sel(c).HasEligible(true, true)); } [Test] public void ExcludesWhenNeedsGrounded_ButAirborne() { var c = new FakeCandidate { ReqGround = true }; Assert.IsFalse(Sel(c).HasEligible(true, false)); Assert.IsTrue (Sel(c).HasEligible(true, true)); } [Test] public void Priority_PicksHighest() { var lo = new FakeCandidate { PriorityV = 1 }; var hi = new FakeCandidate { PriorityV = 5 }; var pick = Sel(lo, hi).Select(true, true, AttackSelectionMode.Priority); Assert.AreSame(hi, pick); } [Test] public void Priority_TieTakesFirstInList() { var a = new FakeCandidate { PriorityV = 3 }; var b = new FakeCandidate { PriorityV = 3 }; var pick = Sel(a, b).Select(true, true, AttackSelectionMode.Priority); Assert.AreSame(a, pick); } [Test] public void Priority_SkipsIneligible() { var blocked = new FakeCandidate { PriorityV = 9, InRange = false }; var ok = new FakeCandidate { PriorityV = 1 }; var pick = Sel(blocked, ok).Select(true, true, AttackSelectionMode.Priority); Assert.AreSame(ok, pick); } [Test] public void Weighted_SingleEligible_AlwaysThatOne() { var only = new FakeCandidate { WeightV = 2f }; for (int i = 0; i < 20; i++) Assert.AreSame(only, Sel(only).Select(true, true, AttackSelectionMode.WeightedRandom)); } [Test] public void Weighted_NeverPicksIneligible() { var bad = new FakeCandidate { WeightV = 100f, InRange = false }; var ok = new FakeCandidate { WeightV = 1f }; for (int i = 0; i < 50; i++) Assert.AreSame(ok, Sel(bad, ok).Select(true, true, AttackSelectionMode.WeightedRandom)); } // ── WeightedRandomAntiRepeat ───────────────────────────────────────── static EnemyAttackSelector SelAntiRepeat(float factor, params IAttackCandidate[] cs) => new EnemyAttackSelector(new List(cs), factor); [Test] public void AntiRepeat_ZeroFactor_NeverPicksSameTwiceWhenAlternativeExists() { var a = new FakeCandidate { WeightV = 1f }; var b = new FakeCandidate { WeightV = 1f }; var s = SelAntiRepeat(0f, a, b); var prev = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat); for (int i = 0; i < 100; i++) { var cur = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat); Assert.AreNotSame(prev, cur, "系数为 0 时不应连续选中同一招"); prev = cur; } } [Test] public void AntiRepeat_ZeroFactor_SingleCandidate_StillReturnsIt() { // 只有一招时折扣会把权重压到 0,WeightedPick 返回 -1; // 必须退化为 Priority 路径把它选出来,否则敌人永远不出手。 var only = new FakeCandidate { WeightV = 1f }; var s = SelAntiRepeat(0f, only); for (int i = 0; i < 10; i++) Assert.AreSame(only, s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat)); } [Test] public void AntiRepeat_DiscountsPreviousPick_ButDoesNotBanIt() { // 系数 0.5:a 被选过后权重折半,但仍有机会再被选中。 var a = new FakeCandidate { WeightV = 1f }; var b = new FakeCandidate { WeightV = 1f }; var s = SelAntiRepeat(0.5f, a, b); s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat); int repeats = 0; var prev = s.LastPicked; for (int i = 0; i < 400; i++) { var cur = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat); if (ReferenceEquals(cur, prev)) repeats++; prev = cur; } // 统计断言(无固定随机种子):系数 0.5 → 每次重复概率 0.5/1.5≈1/3, // 400 次期望≈133,标准差≈9.4;下面的边界约 ±5σ,正确实现下几乎不可能红。 const string msg = "系数 0.5 → 每次重复概率 0.5/1.5≈1/3,400 次期望≈133(±5σ 约 [90,180])"; Assert.Greater(repeats, 90, "折扣不应等于禁用。" + msg); Assert.Less (repeats, 180, "重复率明显高于预期,折扣可能未生效。" + msg); } [Test] public void AntiRepeat_IneligiblePrevious_DoesNotBlockOthers() { // 用系数 0.5 而非 0:若实现把折扣错误地施加在「资格归零」之前 // (c.Weight * factor 而非 w * factor),冷却中的上一招会拿到 0.5 的权重而可能被选中。 // 系数为 0 时那个 bug 也会算出 0,测试将永远通过、失去意义。 var a = new FakeCandidate { WeightV = 1f }; var b = new FakeCandidate { WeightV = 1f }; var s = SelAntiRepeat(0.5f, a, b); var first = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat); ((FakeCandidate)first).CanUseV = false; // 上一招进冷却 for (int i = 0; i < 20; i++) { var second = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat); Assert.IsNotNull(second); Assert.AreNotSame(first, second, "冷却中的招不得因防重复折扣路径被选中"); } } [Test] public void ResetRepeatMemory_RestoresUndiscountedWeights() { // 确定性用例:a 权重 1 / 优先级 0,b 权重 0 / 优先级 5。 // 选中 a 后若不清记忆,a 被折扣到 0 → 全零 → 走 Priority 退化路径选出 b。 // 清了记忆则 a 恢复权重 1,仍应选 a。因此该用例精确地在重置失效时变红。 var a = new FakeCandidate { WeightV = 1f, PriorityV = 0 }; var b = new FakeCandidate { WeightV = 0f, PriorityV = 5 }; var s = SelAntiRepeat(0f, a, b); Assert.AreSame(a, s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat)); s.ResetRepeatMemory(); Assert.AreSame(a, s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat), "清除防重复记忆后应恢复未折扣的权重分布"); } [Test] public void AntiRepeat_FactorOne_AppliesNoPenalty() { // 系数 1.0 是 [Range] 的上界,语义为「不惩罚」:重复率应回到约 50% var a = new FakeCandidate { WeightV = 1f }; var b = new FakeCandidate { WeightV = 1f }; var s = SelAntiRepeat(1f, a, b); s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat); int repeats = 0; var prev = s.LastPicked; for (int i = 0; i < 400; i++) { var cur = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat); if (ReferenceEquals(cur, prev)) repeats++; prev = cur; } Assert.Greater(repeats, 150, "系数 1.0 应无惩罚:重复概率≈50%,400 次期望≈200(σ≈10,150 约为 -5σ)"); } [Test] public void AntiRepeat_ThreeCandidates_NoImmediateRepeat_AndAllReachable() { // 三候选:既验证折扣循环的下标没写错,也验证除上一招外其余候选都仍可达 var a = new FakeCandidate { WeightV = 1f }; var b = new FakeCandidate { WeightV = 1f }; var c = new FakeCandidate { WeightV = 1f }; var s = SelAntiRepeat(0f, a, b, c); var seen = new HashSet(); var prev = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat); seen.Add(prev); for (int i = 0; i < 200; i++) { var cur = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat); Assert.AreNotSame(prev, cur, "系数为 0 时不应连续选中同一招"); seen.Add(cur); prev = cur; } Assert.AreEqual(3, seen.Count, "三个等权候选应都能被选到"); } [Test] public void PlainWeighted_DoesNotApplyAntiRepeat() { // WeightedRandom 模式下唯一候选恒被选中(回归:折扣不得泄漏到旧模式) var only = new FakeCandidate { WeightV = 1f }; var s = SelAntiRepeat(0f, only); for (int i = 0; i < 10; i++) Assert.AreSame(only, s.Select(true, true, AttackSelectionMode.WeightedRandom)); } } }