feat(enemy): 选招器支持防重复折扣——WeightedRandomAntiRepeat

上一次选中的招按 EnemyStatsSO.attackAntiRepeatFactor 折扣权重(默认 0.3)。
折扣把唯一候选压到 0 时退化为 Priority 路径,不会出现敌人永不出手。
阶段切换清空防重复记忆。取代 BossBase.UseBossSkillWeighted 里的同名逻辑。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 12:43:01 +08:00
co-authored by Claude Opus 5
parent b0fd040904
commit eb3f4888a4
6 changed files with 132 additions and 12 deletions
@@ -104,5 +104,84 @@ namespace BaseGames.Tests.EditMode.Enemies
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));
}
}
}