补上池化路径的防重复记忆重置——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>
256 lines
11 KiB
C#
256 lines
11 KiB
C#
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/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<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));
|
||
}
|
||
}
|
||
}
|