在《胜利女神:妮姬》(NIKKE)这个游戏里,装备养成的后期基本都耗在词条上:词条随机生成,洗练石产出有限,锁定一个词条又要额外消耗石头。真正让人头疼的不是“哪些词条强”,而是“我到底要准备多少石头才够”,以及“当前这套词条应该继续洗还是先收手”。这两个问题如果只靠体感,很容易出现两种结果:石头全花光也没洗出目标,或者洗出不错词条后又手贱洗掉。
我做的这个装备洗练计算器,就是用来解决这两个问题的。它把词条池数量、目标词条数、槽位数量、每次洗练消耗这些参数填进去,就能算出期望需要多少石头,并且在预算内给出一个可操作的建议:继续洗、停手攒石头、还是先保住当前词条。整个工具用纯前端实现,保存成一个 HTML 文件就能在浏览器里直接运行,不需要搭建服务端,也不需要安装依赖。
下面先从计算器要解决的问题说起,然后依次拆解概率模型、核心代码、验证方法和排查思路。
1. 先理解装备洗练计算器要解决什么问题
1.1 装备词条系统的核心数值
先梳理游戏里和装备洗练相关的几个概念。一件装备有若干词条槽,每次洗练会按照一定规则重新生成槽位上的词条,消耗一定数量的洗练石;如果不想让某个还不错的词条被洗掉,可以用锁定功能,但锁定会额外增加该轮消耗。
对计算器来说,真正影响概率的输入参数只有五个:词条池总数、目标词条数、词条槽数、目标最少词条数量、单次洗练消耗。把这五个参数说清楚,整个模型就立住了。
| 参数 | 含义 | 举例 |
|---|---|---|
| 词条池总数 N | 一次洗练时可能出现的所有词条类型数量 | 20 |
| 目标词条数 T | 你认为值得保留的词条类型数量 | 5 |
| 词条槽数 K | 一件装备最多拥有的词条槽数量 | 4 |
| 目标最少词条数 m | 你希望最终至少保留几个目标词条 | 2 |
| 单次洗练消耗 | 每洗一次消耗的基础石头数 | 10 |
| 锁定单个词条消耗 | 锁定一个目标词条后追加的石头数 | 15 |
这里要提醒一句:不同版本、不同装备位,词条池数量和词条权重都可能不同。计算器可以先把“等概率随机”作为默认模型,但实际使用前,要按当前游戏版本校准词条池数量。代码本身不绑定任何具体版本,改参数就能适配。
1.2 计算器的输入、输出和边界
界面上大致需要这样一组输入字段:
- 词条池总数:所有可能随机到的词条类型数量。
- 目标词条数量:玩家主观定义“好词条”的数量。
- 词条槽数:装备固定属性。
- 当前已有目标词条数:洗练前已经拥有的好词条数量。
- 目标最少词条数:洗练结束时要达到的目标。
- 单次洗练消耗基础石头。
- 已锁定词条数。
- 锁定单个词条额外消耗。
- 当前持有石头的预算上限。
输出则包括三块:
- 期望洗练次数。
- 期望石头消耗总量。
- 在指定预算内至少成功一次的累积概率。
- 基于预算成功率和当前进度的操作建议。
这里要明确边界。计算器处理的是“词条类型”的命中概率,不处理“词条数值大小”。简单说,它回答的是“还要几次才能洗出至少两个目标词条”,而不是“洗出来的攻击力数值是 80% 还是 95%”。数值档位属于另一层概率模型,可以在后面扩展,但不应该混进第一版计算器里。
2. 期望石头数背后的概率模型
2.1 从几何分布说起:单目标词条的期望
先看最简单的场景:装备只有一个词条槽,每次洗练等概率从 N 个词条中抽一个,目标词条集合大小是 T。那么单次成功的概率是:
p = T / N
从开始洗到第一次成功,需要的次数 X 服从几何分布。几何分布有一个很直接的结论:
E[X] = 1 / p
举例:N = 20,T = 5,那么 p = 0.25,期望次数是 4 次。如果单次消耗 10 石头,期望石头数就是 40。
这个公式看着简单,但实际使用时要特别注意一个误区:期望不等于保证。几何分布的方差很大,p = 0.25 时,中位数大约是 3,均值是 4,但超过 4 次才成功的概率仍然接近 32%。换句话说,按期望次数准备石头,大概只有三分之二的概率能成功。
注意:不要只按期望值准备石头。期望值是用在长期平均和预算规划上的,单次洗练是否出货,永远是个概率事件。更可靠的指标是“预算内累积成功率”。
2.2 多槽位、保留词条和锁定机制如何影响概率
单槽模型太理想,实际装备往往有多个词条槽,而且词条不会重复。比如词条池有 20 种词条,装备有 4 个槽,一次洗练会随机填上 4 个不同词条。这种情况下,一次洗练里“恰好命中 t 个目标词条”的概率要用超几何分布来计算:
P(X = t) = C(T, t) * C(N - T, K - t) / C(N, K)
其中 C 是组合数。上式含义是:从 T 个目标词条中选出 t 个,同时从 N - T 个非目标词条中选出 K - t 个,所有组合数除以总组合数。
“至少命中 m 个目标词条”的概率则是:
P(X >= m) = sum_{t=m}^{min(K,T)} C(T, t) * C(N - T, K - t) / C(N, K)
期望需要的洗练次数就是 1 / P(X >= m)。
下面用一个具体例子演示。词条池 N = 20,目标词条 T = 5,槽位 K = 4。对比三种情况:
| 场景 | 剩余槽位 | 目标需求 | 单次成功概率 | 期望次数 | 每轮消耗 | 期望石头 |
|---|---|---|---|---|---|---|
| 不锁定,四槽至少两个目标 | 4 | 2 | 约 0.2487 | 约 4.02 | 10 | 约 40.2 |
| 锁定一个目标后再洗三槽 | 3 | 1 | 约 0.5304 | 约 1.89 | 25 | 约 47.1 |
这里有一个非常关键的数值现象:锁定一个目标词条后,期望次数确实从 4 次降到了 1.89 次,但因为锁定费用太高,总期望石头成本反而从 40.2 涨到了 47.1。所以“锁定是否划算”不是看期望次数,而是看总成本。
可以用临界值来辅助决策。假设基础消耗 base = 10,不锁定时单次成功概率 p_no_lock = 0.2487,锁定时单次成功概率 p_lock = 0.5304,锁定额外消耗为 x,那么锁定更划算的条件是:
(1 / p_lock) * (base + x) <= (1 / p_no_lock) * base
代入数值后得到 x 约小于等于 11.3。也就是说,当锁定单个词条的额外消耗超过 11 石头时,在这个参数组合下反而不如直接重洗。这个结论是计算器最值得给出的判断之一。
还需要注意一个模型细节:锁定了一个目标词条后,目标词条池也要跟着减少。比如原来 T = 5,锁定 1 个目标词条,剩余随机抽取时目标词条只剩 4 个。如果还在用 T = 5 计算,概率会被明显高估。
2.3 用蒙特卡洛模拟校验公式
理论公式算完,最好再用随机模拟做一次交叉验证。蒙特卡洛模拟的思路很简单:把洗练过程用代码重复大量次数,统计平均需要多少次成功,然后和公式结果对账。
模拟逻辑如下:
- 生成一个大小为 N 的词条池,标记其中 T 个为目标词条。
- 每次洗练从池中不放回抽取 K 个词条。
- 判断抽取结果中是否命中至少 m 个目标词条。
- 未命中就继续,命中则记录当前次数。
- 重复大量试验后求平均。
由于这里用的是“不放回抽取”,模拟模型和超几何分布保持一致。模拟次数越大,平均值越接近公式结果;如果模拟值和公式偏差明显,说明代码里有一处参数没有对齐,需要优先排查。
3. 用纯前端实现一个可运行的计算器
3.1 项目结构与界面布局
实现选择单文件 HTML,是因为这类计算器使用场景很轻:要么在手机上打开,要么在电脑上开着游戏窗口边看边算。单文件不需要服务器,不需要打包,也不需要安装 Node.js。
目录结构只需要一个文件:
wash-calculator/ ├── index.html └── README.md界面分成三个区域:顶部是参数配置表单,中间是结果展示,底部是蒙特卡洛模拟校验区。表单参数和计算结果之间不需要后端,全部由 JavaScript 在浏览器本地计算。
HTML 表单骨架可以这样写:
<div class="config"> <label>词条池总数 N <input type="number" id="poolSize" value="20" min="1"> </label> <label>目标词条数 T <input type="number" id="targetCount" value="5" min="1"> </label> <label>词条槽数 K <input type="number" id="slotCount" value="4" min="1"> </label> <label>当前已有目标词条数 <input type="number" id="currentGood" value="0" min="0"> </label> <label>目标最少词条数 m <input type="number" id="minTarget" value="2" min="1"> </label> <label>单次洗练基础消耗 <input type="number" id="baseCost" value="10" min="1"> </label> <label>已锁定词条数 <input type="number" id="lockedCount" value="0" min="0"> </label> <label>锁定单个词条额外消耗 <input type="number" id="lockCost" value="15" min="0"> </label> <label>预算石头数量 <input type="number" id="budget" value="100" min="1"> </label> <button id="calculateBtn">计算期望</button> </div>这段表单的特点是所有参数都可以直接编辑,方便针对不同装备和版本调整。value 里的默认值只是演示用,实际使用时按游戏当前版本填写。
3.2 核心算法代码:期望、概率、模拟
计算器最核心的部分是三个函数:组合数、超几何概率、主流程求解。先看组合数实现:
function comb(n, k) { if (k < 0 || k > n) return 0; if (k === 0 || k === n) return 1; let result = 1; for (let i = 1; i <= k; i++) { result = result * (n - k + i) / i; } return result; }这里用递推方式计算组合数,避免直接做阶乘导致数字溢出。JavaScript 的 Number 类型在组合数较大时会有精度问题,但词条池规模通常不超过几百,这个实现足够。
然后是超几何概率和至少命中 m 个目标词条的概率:
function hypergeometric(N, T, K, t) { return comb(T, t) * comb(N - T, K - t) / comb(N, K); } function successProbability(N, T, K, m) { if (m <= 0) return 1; if (K < m) return 0; let sum = 0; const maxT = Math.min(K, T); for (let t = m; t <= maxT; t++) { sum += hypergeometric(N, T, K, t); } return sum; }需要处理两个边界:m 小于等于 0 时表示要求已经满足,单次成功率直接是 1,期望次数为 0;K 小于 m 时表示槽位数量根本装不下这么多目标词条,概率为 0。
主流程里要把“当前已有目标词条数”和“锁定词条数”转换成模型需要的槽位和目标需求。比如当前已有 r 个目标词条,并且全部锁定,那真正参与随机洗练的槽位就是 K - r,剩下的需求是 m - r:
function normalizeParams(input) { const remainingSlots = Math.max(0, input.slotCount - input.lockedCount); const needHits = Math.max(0, input.minTarget - input.currentGood); return { pool: input.poolSize, target: input.targetCount, slots: remainingSlots, minHits: needHits, perAttemptCost: input.baseCost + input.lockedCount * input.lockCost, }; }这个归一化很容易错。很多人直接把 K 和 m 填进公式,忘了把已经锁定并且已经达标的部分从随机范围里扣掉,结果就是期望次数被高估或低估。锁定词条在模型里等价于“移除一个槽位,同时减少一个需求”。
最终期望石头数的计算函数是:
function expectedStones(params) { const p = successProbability(params.pool, params.target, params.slots, params.minHits); if (p <= 0) return Infinity; const expectedAttempts = 1 / p; return { probability: p, expectedAttempts: expectedAttempts, expectedStones: expectedAttempts * params.perAttemptCost, }; }预算内成功率也要单独算。这里不是看期望,而是看在预算允许的尝试次数内,至少成功一次的概率:
function probabilityWithinBudget(p, budget, perAttemptCost) { if (p <= 0 || p > 1) return 0; const allowedAttempts = Math.floor(budget / perAttemptCost); if (allowedAttempts <= 0) return 0; return 1 - Math.pow(1 - p, allowedAttempts); }最后是蒙特卡洛模拟函数。这里用一套随机抽样逻辑模拟整个洗练过程,用来和服务度公式对账:
function sampleWithoutReplace(pool, size) { const source = pool.slice(); const result = []; for (let i = 0; i < size; i++) { const index = Math.floor(Math.random() * source.length); result.push(source.splice(index, 1)[0]); } return result; } function monteCarlo(poolSize, targetSize, slots, minHits, trials, limit) { const pool = Array.from({ length: poolSize }, (_, i) => i); const targets = new Set(Array.from({ length: targetSize }, (_, i) => i)); let totalAttempts = 0; let successCount = 0; for (let trial = 0; trial < trials; trial++) { for (let attempt = 1; attempt <= limit; attempt++) { const chosen = sampleWithoutReplace(pool, slots); let hits = 0; for (const c of chosen) { if (targets.has(c)) hits++; } if (hits >= minHits) { totalAttempts += attempt; successCount++; break; } } } return successCount > 0 ? totalAttempts / successCount : Infinity; }模拟上限要设置一个比较大的值,比如 100000,防止在概率极低时无限循环。如果成功率本身就接近 0,模拟结果会出现 Infinity,这也是一个检查输入参数是否合理的信号。
3.3 生成下一步操作建议的决策逻辑
算出数字还不够,计算器要直接给出建议。建议逻辑可以分成两段:先判断预算内成功率,再判断当前是否已经达到目标。
决策规则使用一张对照表:
| 预算内成功率 | 建议 |
|---|---|
| 大于等于 80% | 当前预算内成功率较高,可以正常继续洗练 |
| 50% 到 80% | 可以洗,但必须设置止损线,不要超预算硬洗 |
| 小于 50% | 建议停手攒石头,或者降低目标词条数量 |
建议生成的 JavaScript 可以写成这样:
function buildAdvice(result, params) { const lines = []; const rate = result.probInBudget; if (rate >= 0.8) { lines.push('当前预算内成功率较高,可以按计划继续洗练。'); } else if (rate >= 0.5) { lines.push('预算内成功率中等,建议设置止损线,例如只消耗预算的一半。'); } else { lines.push('预算内成功率较低,建议先攒石头,或者降低目标词条数量。'); } if (params.currentGood >= params.minTarget) { lines.push('当前已经达到目标词条数量,继续洗练的收益主要来自词条数值提升,需要自行权衡风险。'); } return lines; }这里没有把“期望石头数”作为唯一决策依据,因为期望值只代表长期平均。对一个石头存量有限的玩家来说,预算内成功率比期望值更能反映“我现在该不该赌这一把”。在连续多次没出货时,要防止增加预算的冲动。
建议在洗练前就把预算上限写好。连续失败时最容易上头,计算器显示成功率没有明显变化,但预算一旦被打破,损失就不是概率问题而是资源管理问题了。
4. 运行验证:如何确认计算结果可靠
4.1 用固定参数手工验证
计算器做完后,第一件事不是填真实游戏数据,而是先用一组手算结果做对照。取一组能手工推导的参数:N = 20,T = 5,K = 4,m = 2,基础消耗 10。
组合数手算如下:
- C(20, 4) = 4845
- C(5, 2) * C(15, 2) = 1050
- C(5, 3) * C(15, 1) = 150
- C(5, 4) * C(15, 0) = 5
至少命中 2 个目标词条的概率 = (1050 + 150 + 5) / 4845 ≈ 0.2487。期望次数 = 1 / 0.2487 ≈ 4.02。期望石头 = 40.2。
如果界面上填入这组参数后得到同样的结果,说明核心公式实现没有大问题。
4.2 模拟结果与理论期望的对账
再用蒙特卡洛模拟验证。把参数 N = 20,T = 5,K = 4,m = 2 传入 monteCarlo 函数,模拟 10 万次,结果应该在 4.0 到 4.1 之间浮动。如果模拟均值明显偏离公式值,比如超过 4.3,就要检查抽样过程是不是用了有放回抽取,或者随机池生成有误。
一组可能的输出对比:
| 验证方式 | 期望次数 |
|---|---|
| 超几何公式计算 | 4.02 |
| 蒙特卡洛模拟 10 万次 | 4.03 左右 |
两者不一致的原因有两个:一是随机抽样本身有误差,试验次数越多越接近理论值;二是代码模型和公式模型不对齐,比如抽样方式、目标池更新逻辑不同。排错时优先对比后者。
4.3 边界条件和异常输入
边界条件最容易暴露代码问题。至少要测下面几组输入:
- 当前已有目标词条数等于目标最少词条数时,期望次数应为 0。
- 词条槽数小于目标最少词条数时,概率应为 0。
- 已锁定词条数大于总槽数时,应该被归一化到 0 或提示输入非法。
- 预算小于单次消耗时,预算内成功率应为 0。
- 词条池总数小于目标词条数时,属于非法输入,应提示用户。
输入参数前先做边界校验,否则计算器和浏览器都会给出误导结果。期望也是,如果直接得到 Infinity 或 NaN,优先检查输入组合是否满足基本约束。
实现时可以在 calculate 函数开头加一段校验逻辑,把所有参数转成数字,检查是否为正数,再进入计算。这样能避免很多低级错误。
5. 常见问题与排查思路
5.1 计算结果比实际体感低很多
现象:公式算出来 40 石头就能达到目标,实际花了 80 石头还没出。
可能原因有两个。第一,词条池中不同词条的权重不是均匀的,比如攻击类词条的出现概率明显低于防御类,而公式默认所有词条等概率。第二,期望值本身不等于“你一定会在这个次数内成功”,单次洗练的尾部概率很大。
排查步骤:
- 先确认目标词条数 T 是否合理。
- 再看游戏内词条是否独立概率,如果是权重模型,需要把均匀超几何模型替换为加权抽样模型。
- 最后还是建议看预算内成功率,不要只看期望值。
处理建议:如果游戏内确实存在词条权重差异,不能直接套用本文的等概率模型。需要先用大量洗练样本统计词条出现频率,把权重数组引入模拟代码。
5.2 词条权重和默认概率不一致
现象:界面算出的单次成功概率是 0.25,但体感远低于这个值。
原因:均匀概率只是一个简化假设。游戏可能对部分词条做了稀有度倾斜,导致目标词条的实际权重更低。
检查方式:用模拟统计词条出现频率。如果某个目标词条 1000 次里只出现 50 次,而均匀概率应该是 50 次以上,说明这个词条被降权了。
处理建议:把模型从“目标词条集合大小”升级为“每个词条的权重数组”。单次命中概率不再是 T / N,而是目标词条权重之和除以所有词条权重之和。多槽位情况也需要改为按权重不放回抽样。
5.3 浏览器打开页面后按钮无响应
现象:双击打开 HTML 文件,点击“计算期望”没有任何反应。
排查