Files
zeling_v2/Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs
T
joywayerandClaude Opus 5 eb3f4888a4 feat(enemy): 选招器支持防重复折扣——WeightedRandomAntiRepeat
上一次选中的招按 EnemyStatsSO.attackAntiRepeatFactor 折扣权重(默认 0.3)。
折扣把唯一候选压到 0 时退化为 Priority 路径,不会出现敌人永不出手。
阶段切换清空防重复记忆。取代 BossBase.UseBossSkillWeighted 里的同名逻辑。

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

188 lines
7.1 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;
}
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));
}
}
}