diff --git a/Assets/Tests/EditMode/Core/WeightedPickTests.cs b/Assets/Tests/EditMode/Core/WeightedPickTests.cs
new file mode 100644
index 00000000..d2f99280
--- /dev/null
+++ b/Assets/Tests/EditMode/Core/WeightedPickTests.cs
@@ -0,0 +1,98 @@
+using System.Collections.Generic;
+using NUnit.Framework;
+using BaseGames.Core;
+
+namespace BaseGames.Tests.EditMode.Core
+{
+ ///
+ /// WeightedPick 加权随机取索引的共享原语测试。
+ /// 该原语被敌人选招 / Boss 选招 / 战利品掉落共用,故对"绝不选中零权重项"等
+ /// 确定性性质做强断言(多次采样),避免任一消费端出现选到不合格项的缺陷。
+ ///
+ public class WeightedPickTests
+ {
+ static List W(params float[] w) => new List(w);
+
+ [Test]
+ public void Null_ReturnsMinusOne()
+ {
+ Assert.AreEqual(-1, WeightedPick.Index(null));
+ }
+
+ [Test]
+ public void Empty_ReturnsMinusOne()
+ {
+ Assert.AreEqual(-1, WeightedPick.Index(W()));
+ }
+
+ [Test]
+ public void AllZero_ReturnsMinusOne()
+ {
+ Assert.AreEqual(-1, WeightedPick.Index(W(0f, 0f, 0f)));
+ }
+
+ [Test]
+ public void AllNegative_ReturnsMinusOne()
+ {
+ // 负权重按 0 处理(非法输入不应造成负总和/错误索引)
+ Assert.AreEqual(-1, WeightedPick.Index(W(-1f, -5f)));
+ }
+
+ [Test]
+ public void SinglePositive_AlwaysThatIndex()
+ {
+ var w = W(0f, 3f, 0f);
+ for (int i = 0; i < 50; i++)
+ Assert.AreEqual(1, WeightedPick.Index(w));
+ }
+
+ [Test]
+ public void NeverPicksZeroWeight()
+ {
+ // 索引 0/2 权重为 0,无论抽多少次都不该被选中
+ var w = W(0f, 1f, 0f, 2f);
+ for (int i = 0; i < 200; i++)
+ {
+ int idx = WeightedPick.Index(w);
+ Assert.IsTrue(idx == 1 || idx == 3, $"选中了零权重索引 {idx}");
+ }
+ }
+
+ [Test]
+ public void NeverPicksNegativeWeight()
+ {
+ var w = W(-10f, 1f);
+ for (int i = 0; i < 200; i++)
+ Assert.AreEqual(1, WeightedPick.Index(w));
+ }
+
+ [Test]
+ public void AllPositive_EveryIndexReachable()
+ {
+ // 等权重下,足够多次采样应覆盖所有索引(验证不会恒定只返回首个/末个)
+ var w = W(1f, 1f, 1f);
+ var seen = new HashSet();
+ for (int i = 0; i < 500; i++) seen.Add(WeightedPick.Index(w));
+ CollectionAssert.AreEquivalent(new[] { 0, 1, 2 }, seen);
+ }
+
+ [Test]
+ public void HeavyWeight_DominatesDistribution()
+ {
+ // 权重 99:1 时,重权重索引应占绝大多数(宽松阈值,避免随机性造成偶发失败)
+ var w = W(99f, 1f);
+ int heavy = 0;
+ const int N = 1000;
+ for (int i = 0; i < N; i++) if (WeightedPick.Index(w) == 0) heavy++;
+ Assert.Greater(heavy, N * 0.9f, $"重权重仅被选中 {heavy}/{N} 次,分布不符合权重");
+ }
+
+ [Test]
+ public void WorksWithArray()
+ {
+ // IReadOnlyList 契约:数组同样可用(消费端可复用缓存数组,免 GC)
+ float[] w = { 0f, 5f };
+ Assert.AreEqual(1, WeightedPick.Index(w));
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Core/Utils/WeightedPick.cs b/Assets/_Game/Scripts/Core/Utils/WeightedPick.cs
new file mode 100644
index 00000000..21affd6b
--- /dev/null
+++ b/Assets/_Game/Scripts/Core/Utils/WeightedPick.cs
@@ -0,0 +1,51 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace BaseGames.Core
+{
+ ///
+ /// 加权随机取索引的共享原语(纯逻辑、无状态、可单测)。
+ ///
+ /// 项目中"按权重随机选一个"的场景(敌人选招 / Boss 选招 / 战利品掉落)此前各写了一份
+ /// 累加-掷点-回退的循环,语义相同却各自维护。统一到本处,消除平行实现。
+ ///
+ /// 调用方职责:把候选的**有效权重**填入一个列表/数组(不合格候选填 0),
+ /// 本原语只负责"按权重挑一个索引",不关心候选是什么、为何不合格。
+ /// 列表可由调用方缓存复用 → 选取过程零 GC 分配。
+ ///
+ public static class WeightedPick
+ {
+ ///
+ /// 按 加权随机返回一个索引。
+ /// 权重 ≤ 0 的项永不被选中(负权重按 0 处理);无任何正权重时返回 -1。
+ ///
+ public static int Index(IReadOnlyList weights)
+ {
+ if (weights == null) return -1;
+
+ int count = weights.Count;
+ float total = 0f;
+ for (int i = 0; i < count; i++)
+ {
+ float w = weights[i];
+ if (w > 0f) total += w;
+ }
+ if (total <= 0f) return -1;
+
+ float roll = Random.value * total;
+ float accum = 0f;
+ for (int i = 0; i < count; i++)
+ {
+ float w = weights[i];
+ if (w <= 0f) continue;
+ accum += w;
+ if (roll <= accum) return i;
+ }
+
+ // 浮点累加误差兜底:返回最后一个正权重项(绝不返回零权重项)
+ for (int i = count - 1; i >= 0; i--)
+ if (weights[i] > 0f) return i;
+ return -1;
+ }
+ }
+}