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>
This commit is contained in:
2026-07-30 13:00:28 +08:00
co-authored by Claude Opus 5
parent eb3f4888a4
commit 0a92d21ce1
5 changed files with 104 additions and 15 deletions
@@ -155,23 +155,91 @@ namespace BaseGames.Tests.EditMode.Enemies
if (ReferenceEquals(cur, prev)) repeats++;
prev = cur;
}
Assert.Greater(repeats, 0, "折扣不应等于禁用");
Assert.Less (repeats, 200, "折扣后重复率应低于无折扣时的约 50%");
// 统计断言(无固定随机种子):系数 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(0f, a, b);
var s = SelAntiRepeat(0.5f, 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);
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]
@@ -109,6 +109,9 @@ namespace BaseGames.Enemies
var mode = _enemy.StatsSO != null
? _enemy.StatsSO.attackSelectionMode
: Abilities.AttackSelectionMode.WeightedRandom;
// 折扣系数与 mode 同步每次刷新,使播放模式下调参立即生效(不必重进场景)
if (_enemy.StatsSO != null)
sel.AntiRepeatFactor = _enemy.StatsSO.attackAntiRepeatFactor;
bool grounded = _enemy.Movement != null && _enemy.Movement.IsGrounded;
var pick = sel.Select(_enemy.IsPlayerVisible(), grounded, mode);
return pick != null && pick.Execute();
@@ -9,23 +9,35 @@ namespace BaseGames.Enemies.Abilities
/// </summary>
public sealed class EnemyAttackSelector
{
/// <summary>防重复折扣系数的默认值。EnemyStatsSO 字段初值与 EnemyBase 的兜底分支共用此常量。</summary>
public const float DefaultAntiRepeatFactor = 0.3f;
private readonly List<IAttackCandidate> _candidates;
// 权重缓冲区(与 _candidates 等长,每次选招复用):避免每次选招 new List 造成 GC
private readonly List<float> _weightBuf;
// 防重复折扣系数:0 = 有替代时绝不连续重复,1 = 不惩罚
private readonly float _antiRepeatFactor;
private float _antiRepeatFactor;
private IAttackCandidate _lastPicked;
public EnemyAttackSelector(IEnumerable<IAttackCandidate> candidates, float antiRepeatFactor = 0.3f)
public EnemyAttackSelector(IEnumerable<IAttackCandidate> candidates,
float antiRepeatFactor = DefaultAntiRepeatFactor)
{
_candidates = new List<IAttackCandidate>(candidates);
_weightBuf = new List<float>(_candidates.Count);
_antiRepeatFactor = Mathf.Clamp01(antiRepeatFactor);
_candidates = new List<IAttackCandidate>(candidates);
_weightBuf = new List<float>(_candidates.Count);
AntiRepeatFactor = antiRepeatFactor; // 经属性赋值,夹紧逻辑只有一处
}
public int Count => _candidates.Count;
/// <summary>防重复折扣系数。与 attackSelectionMode 一样每次选招前由调用方刷新,
/// 使策划在播放模式下调参能立即生效。</summary>
public float AntiRepeatFactor
{
get => _antiRepeatFactor;
set => _antiRepeatFactor = Mathf.Clamp01(value);
}
/// <summary>上一次实际选中的招(防重复折扣的作用对象;供测试与调试面板读取)。</summary>
public IAttackCandidate LastPicked => _lastPicked;
+8 -3
View File
@@ -601,9 +601,11 @@ namespace BaseGames.Enemies
candidates.Add(ab);
}
}
// 折扣系数是策划配置项,未配 StatsSO 的敌人用 0.3 这个合法业务缺省(非掩盖漏配:
// 该模式本身是可选的,没配就等于用默认折扣)
float antiRepeat = _statsSO != null ? _statsSO.attackAntiRepeatFactor : 0.3f;
// 缺 StatsSO 已由本类其它处显式报错,这里的三目只是避免空引用;
// 真正的业务缺省是 SO 字段自身的初值
float antiRepeat = _statsSO != null
? _statsSO.attackAntiRepeatFactor
: BaseGames.Enemies.Abilities.EnemyAttackSelector.DefaultAntiRepeatFactor;
_attackSelector = new Abilities.EnemyAttackSelector(candidates, antiRepeat);
}
@@ -760,6 +762,9 @@ namespace BaseGames.Enemies
// 重置能力冷却
_abilities.InterruptAll(InterruptReason.Dead);
// 对象池复用不得继承上一条命的防重复记忆(否则新生即带着上次的招被折扣)
_attackSelector?.ResetRepeatMemory();
// 重置决策层(回到 Entry、清临时态)
if (_brain != null) _brain.enabled = true; // 复用时恢复决策组件(可能曾被 QuotaManager 裁剪禁用)
_brain?.ResetBrain();
+2 -1
View File
@@ -46,7 +46,8 @@ namespace BaseGames.Enemies
[Tooltip("WeightedRandomAntiRepeat 模式下,上一次选中的招的权重折扣系数。" +
"0 = 有替代招时绝不连续重复;1 = 不惩罚。仅该模式生效")]
[Range(0f, 1f)] public float attackAntiRepeatFactor = 0.3f;
[Range(0f, 1f)] public float attackAntiRepeatFactor
= BaseGames.Enemies.Abilities.EnemyAttackSelector.DefaultAntiRepeatFactor;
[Header("追击 & AI 阶段")]
[Tooltip("是否有警觉状态:勾选=未发现态进入视野先进警觉(朝向+警觉动画)再追击;不勾选=进入追逐感知直接追击")]