Files
zeling_v2/Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs
T
joywayerandClaude Opus 5 0a92d21ce1 fix(enemy): 应用 Task 3 代码审查意见
补上池化路径的防重复记忆重置——ResetRepeatMemory 此前文档承诺了
「对象池复用时清除」却无任何调用点,而它要取代的 BossBase.LastUsedSkillId
恰好在 OnSpawn 里有重置。放 EnemyBase.OnSpawn 以覆盖全部池化敌人。

折扣系数改为每次选招前刷新,与 attackSelectionMode 的读取时机对齐——
此前策划在播放模式下调系数无效、调模式却立即生效,容易误判为功能没生效。
默认值收敛为 EnemyAttackSelector.DefaultAntiRepeatFactor 单一常量。

测试:IneligiblePrevious 用例改用系数 0.5——原先用 0 时,把折扣错写在
eligibility 归零之前也照样通过,是个永远不会失败的测试;统计用例边界由
7σ 收紧到 5σ 使其真正验证该系数;补 ResetRepeatMemory 的确定性用例、
系数 1.0 无惩罚、三候选三条。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:00:28 +08:00

256 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<IAttackCandidate>(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<IAttackCandidate>(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/3400 次期望≈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(σ≈10150 约为 -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<IAttackCandidate>();
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));
}
}
}