上一次选中的招按 EnemyStatsSO.attackAntiRepeatFactor 折扣权重(默认 0.3)。 折扣把唯一候选压到 0 时退化为 Priority 路径,不会出现敌人永不出手。 阶段切换清空防重复记忆。取代 BossBase.UseBossSkillWeighted 里的同名逻辑。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
188 lines
7.1 KiB
C#
188 lines
7.1 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;
|
||
}
|
||
Assert.Greater(repeats, 0, "折扣不应等于禁用");
|
||
Assert.Less (repeats, 200, "折扣后重复率应低于无折扣时的约 50%");
|
||
}
|
||
|
||
[Test]
|
||
public void AntiRepeat_IneligiblePrevious_DoesNotBlockOthers()
|
||
{
|
||
var a = new FakeCandidate { WeightV = 1f };
|
||
var b = new FakeCandidate { WeightV = 1f };
|
||
var s = SelAntiRepeat(0f, a, b);
|
||
|
||
var first = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat);
|
||
((FakeCandidate)first).CanUseV = false; // 上一招进冷却
|
||
|
||
var second = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat);
|
||
Assert.IsNotNull(second);
|
||
Assert.AreNotSame(first, second);
|
||
}
|
||
|
||
[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));
|
||
}
|
||
}
|
||
}
|