6.1 定了卡池结构:SSR 基础概率 16‰、软保底从第 60 抽起、硬保底 80 抽。结构定完,玩家和策划都会问同一个问题——这个池子平均要抽多少下才出、要花多少钱。这个问题看起来有一个答案(「期望抽数」),实际上是一条分布:有人第 1 抽出,有人第 78 抽出,两者体验天差地别。本节把这条分布跑出来。第 5 章已经讲过期望、方差与体验区间 的一般口径,本节把它落到抽卡成本这个具体场景上。
「多少抽出货」是一个分布,不是一个数
先看没有保底的情形。基础概率 16‰ 时,抽到首个 SSR 的抽数服从几何分布,期望是 1000 / 16 = 62.5 抽。但 SLG 几乎不会让玩家真的平均抽 62.5 次——因为软保底会从第 60 抽起把概率往上抬:第 60 抽 56‰、第 61 抽 96‰……到第 79 抽 816‰,第 80 抽硬保底必出。保底把长尾截断了,均值和中位数都被拉低。
所以「多少抽出货」要回答三件事:
- 均值:长期平均,决定付费定价与产出预算。
- 中位数:一半玩家在这个抽数内出货,决定「典型体验」。
- p90 / p99:90% / 99% 的玩家在这个抽数内出货,决定最坏体感——保底机制主要保护的就是这一端。
仿真:5 万名玩家各自抽到首个 SSR
下面这段是 S 层仿真脚本。它用自实现的确定性 PRNG(mulberry32)逐抽模拟,把软保底与硬保底一起写进概率序列,跑 5 万名玩家并统计分布,最后折算成玉璧与人民币成本:
# file: sim/n06_expected_cost.py
# tier: S
# verify: python3 sim/n06_expected_cost.py --config config/ --out out/ --seed 20261012 --players 50000
"""抽卡期望成本仿真:用自实现的确定性 PRNG(mulberry32)模拟「抽到首个 SSR 需要多少抽」,
含软保底(softPityStart 起每抽加 step‰)与硬保底(hardPityPulls 必出),统计分布并折算成
玉璧与人民币成本。产出 out/n06_cost.json + out/n06_cost.csv + out/n06_cost.svg。
PRNG 与 proto/src/n06_gacha.ts 同算法同种子,可逐位对照。"""
from __future__ import annotations
import argparse
import csv
import json
import os
from svg_line import render
MASK32 = 0xFFFFFFFF
def load(cfg_dir: str, name: str) -> dict:
with open(os.path.join(cfg_dir, f"{name}.json"), encoding="utf-8") as fh:
return json.load(fh)
def mulberry32(seed: int):
"""确定性 PRNG,逐位复刻 JS 版 mulberry32:每次 yield 一个 uint32。"""
a = seed & MASK32
while True:
a = (a + 0x6D2B79F5) & MASK32
t = (a ^ (a >> 15)) & MASK32
t = (t * (a | 1)) & MASK32
u = (t ^ (t >> 7)) & MASK32
im = (u * (t | 61)) & MASK32
t = (((t + im) & MASK32) ^ t) & MASK32
yield (t ^ (t >> 14)) & MASK32
def ssr_permille_at(pull_index: int, base: int, soft_start: int, step: int, hard: int) -> int:
"""第 pull_index 抽(自上次 SSR 起,1 起)的 SSR 概率,单位千分比。"""
if pull_index >= hard:
return 1000
p = base + (step * (pull_index - soft_start + 1) if pull_index >= soft_start else 0)
return min(1000, p)
def simulate(pool: dict, seed: int, players: int) -> list[int]:
base = pool["rarityPermille"]["ssr"]
soft, step, hard = pool["pity"]["softPityStart"], pool["pity"]["softPityStepPermille"], pool["pity"]["hardPityPulls"]
rng, out = mulberry32(seed), []
for _ in range(players):
pulls = 0
while True:
pulls += 1
if (next(rng) * 1000) >> 32 < ssr_permille_at(pulls, base, soft, step, hard):
break
out.append(pulls)
return out
def percentile(sorted_vals: list[int], q: int) -> int:
"""最近秩分位数(1 起),q 为百分比整数。"""
idx = min(max((len(sorted_vals) * q + 99) // 100, 1), len(sorted_vals))
return sorted_vals[idx - 1]
def main() -> int:
here = os.path.dirname(os.path.abspath(__file__))
ap = argparse.ArgumentParser()
ap.add_argument("--config", default=os.path.join(here, "..", "config"))
ap.add_argument("--out", default=os.path.join(here, "..", "out"))
ap.add_argument("--seed", type=int, default=20261012)
ap.add_argument("--players", type=int, default=50000)
ap.add_argument("--pool", default="limited")
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
cfg, pricing = load(args.config, "n06_gacha"), load(args.config, "pricing")
pool = next(p for p in cfg["pools"] if p["id"] == args.pool)
pulls = simulate(pool, args.seed, args.players)
sv, total_pulls = sorted(pulls), sum(pulls)
cents_per_jade = 100 // pricing["currency"]["perYuan"]
cost_jade = total_pulls * cfg["pullCostJade"] // args.players
hist = [0] * (max(pulls) + 1)
for p in pulls:
hist[p] += 1
with open(os.path.join(args.out, "n06_cost.csv"), "w", encoding="utf-8", newline="") as fh:
w = csv.writer(fh)
w.writerow(["pulls", "players", "sharePermille"])
w.writerows([i, c, c * 1000 // args.players] for i, c in enumerate(hist) if c)
report = {"version": 1, "pool": pool["id"], "seed": args.seed, "players": args.players,
"pullCostJade": cfg["pullCostJade"], "min": sv[0], "max": sv[-1],
"mean": round(total_pulls / args.players, 3), "median": percentile(sv, 50),
"p90": percentile(sv, 90), "p99": percentile(sv, 99),
"comprehensivePermille": 1000 * args.players // total_pulls,
"costJadeMean": cost_jade, "costCentsMean": cost_jade * cents_per_jade}
with open(os.path.join(args.out, "n06_cost.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
render(hist, os.path.join(args.out, "n06_cost.svg"),
f"{pool['id']} pulls-to-first-SSR histogram (n={args.players})")
print(json.dumps(report, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑(--seed 20261012 --players 50000)得到:
{"version": 1, "pool": "limited", "seed": 20261012, "players": 50000, "pullCostJade": 150,
"min": 1, "max": 78, "mean": 40.448, "median": 43, "p90": 66, "p99": 71,
"comprehensivePermille": 24, "costJadeMean": 6067, "costCentsMean": 60670}
几个读法(同机相对量级,绝对数值不可移植):
- 均值 40.4 抽,中位数 43 抽。相比无保底的 62.5 抽,保底把均值压掉了约三分之一;中位数 43 抽意味着一半玩家在 43 抽内出货。有意思的是中位数和无保底时一模一样(都是 43 抽)——保底从第 60 抽才生效,它根本不碰中位数,只砍长尾。
- p90 = 66 抽、p99 = 71 抽、max = 78 抽。没有玩家抽到 80(硬保底),因为第 79 抽概率已到 816‰、期望上第 78 抽就兜住了。保底真正保护的是长尾:无保底时 p90 要 143 抽、p99 要 286 抽(几何分布解析值),有保底后压到 p90 = 66、p99 = 71。
- 综合概率 24‰(1 万次抽卡里出 240 个 SSR),高于公示的 16‰ 基础概率。这正是 6.1 说的「含保底综合概率」——公示基础概率 16‰、体感却接近 24‰,两者都要给。
- 期望成本:均值 40.4 抽 × 150 玉璧 = 6067 玉璧,按 10 玉璧/元折算是 606.7 元。p90 的玩家要花 66×150 = 9900 玉璧(990 元),p99 要 10650 玉璧(1065 元)。
B 基准:模拟均值 vs 闭式期望
仿真是「抽样」,闭式期望是「算出来的真值」。两者对不上,要么仿真写错了,要么期望公式写错了。下面这段是 B 层基准脚本,它把模拟均值和闭式期望 E[P] = Σ_{k≥1} Π_{i<k}(1 − p_i/1000) 做对照,偏差超阈值只软告警、不硬失败:
# file: sim/n06_expected_cost_bench.py
# tier: B
# verify: python3 sim/n06_expected_cost_bench.py --config config/ --out out/ --seed 20261012 --players 50000
"""B 基准:把 S 层抽卡仿真的平均抽数与闭式期望做对照,算千分比偏差。
软告警不硬失败——偏差超阈值只打 WARN,退出码恒为 0。"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from n06_expected_cost import load, simulate, ssr_permille_at # noqa: E402
WARN_THRESHOLD_PERMILLE = 30
def analytic_mean_pulls(pool: dict) -> float:
"""闭式期望 E[P] = sum_{k>=1} prod_{i<k}(1 - p_i/1000)。"""
base = pool["rarityPermille"]["ssr"]
soft, step, hard = pool["pity"]["softPityStart"], pool["pity"]["softPityStepPermille"], pool["pity"]["hardPityPulls"]
survival, exp = 1.0, 0.0
for k in range(1, hard + 2):
exp += survival
survival *= (1000 - ssr_permille_at(k, base, soft, step, hard)) / 1000.0
return exp
def main() -> int:
here = os.path.dirname(os.path.abspath(__file__))
ap = argparse.ArgumentParser()
ap.add_argument("--config", default=os.path.join(here, "..", "config"))
ap.add_argument("--out", default=os.path.join(here, "..", "out"))
ap.add_argument("--seed", type=int, default=20261012)
ap.add_argument("--players", type=int, default=50000)
ap.add_argument("--pool", default="limited")
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
cfg = load(args.config, "n06_gacha")
pool = next(p for p in cfg["pools"] if p["id"] == args.pool)
sim_mean, ana_mean = sum(simulate(pool, args.seed, args.players)) / args.players, analytic_mean_pulls(pool)
dev = round((sim_mean - ana_mean) * 1000 / ana_mean)
rep = {"version": 1, "pool": pool["id"], "players": args.players,
"simMean": round(sim_mean, 3), "analyticMean": round(ana_mean, 3),
"deviationPermille": dev, "thresholdPermille": WARN_THRESHOLD_PERMILLE,
"warn": abs(dev) > WARN_THRESHOLD_PERMILLE}
with open(os.path.join(args.out, "n06_cost_bench.json"), "w", encoding="utf-8") as fh:
json.dump(rep, fh, ensure_ascii=False, indent=2)
print(f"{'WARN' if rep['warn'] else 'OK'} pool={pool['id']} simMean={sim_mean:.3f} "
f"analyticMean={ana_mean:.3f} dev={dev}permille")
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑得到:
OK pool=limited simMean=40.448 analyticMean=40.536 dev=-2permille
模拟均值 40.448 抽与闭式期望 40.536 抽相差 -2‰,远在 30‰ 的阈值内。这说明仿真脚本和期望公式在同一份保底参数下互相印证——两个独立实现(抽样 vs 求和)给出同一个数,是这个池子数值自洽的直接证据。注意这是同机相对量级:换种子、换玩家数,模拟均值会在 40.5 附近小幅漂移,但闭式期望 40.536 是固定的。
跨语言对照:TS 侧逐位一致
6.1 给出了 proto/src/n06_gacha.ts(mulberry32 + 抽数逻辑)。下面这段是 P 层测试,它 import 那份模块,断言三件事:TS 的 mulberry32 输出与 Python 逐位相同、公示表与 Python 一致、1000 名玩家的首个 SSR 抽数分布与 Python 一致:
// file: proto/test/n06.test.ts
// tier: P
// verify: node --test proto/test/n06.test.ts
// 跨语言对照:TS 侧与 Python 侧(sim/n06_expected_cost.py)用同一份 PRNG 与同一份 config,
// 断言 mulberry32 输出、公示概率表与首个 SSR 抽数分布逐位一致。
import { test } from "node:test";
import assert from "node:assert/strict";
import { loadGacha, mulberry32, rarityTable, pullsToFirstSsr } from "../src/n06_gacha.ts";
// Python 侧 sim/n06_expected_cost.py --seed 20261012 的前 8 个 uint32 输出
const PY_U32 = [1465115847, 76464436, 1565854490, 38558363, 2925694734, 2837803693, 3521919924, 3889422783];
test("mulberry32 与 Python 逐位一致(seed 20261012)", () => {
const rng = mulberry32(20261012);
const got = Array.from({ length: 8 }, () => rng());
assert.deepEqual(got, PY_U32);
});
test("公示概率之和恰为 1000‰ 且每档可整除", () => {
const cfg = loadGacha();
const limited = cfg.pools.find((p) => p.id === "limited")!;
const { rows, total } = rarityTable(limited);
assert.equal(total, 1000);
assert.ok(rows.every((r) => r.exact));
});
test("1000 名玩家首个 SSR 抽数分布与 Python 一致", () => {
const cfg = loadGacha();
const limited = cfg.pools.find((p) => p.id === "limited")!;
const rng = mulberry32(20261012);
const pulls = Array.from({ length: 1000 }, () => pullsToFirstSsr(limited, rng));
assert.deepEqual(pulls.slice(0, 5), [4, 34, 17, 4, 64]);
const mean = pulls.reduce((a, b) => a + b, 0) / pulls.length;
assert.equal(Number(mean.toFixed(3)), 41.079);
});
本机实跑(Node 26.5.0):
✔ mulberry32 与 Python 逐位一致(seed 20261012)
✔ 公示概率之和恰为 1000‰ 且每档可整除
✔ 1000 名玩家首个 SSR 抽数分布与 Python 一致
ℹ tests 3 / ℹ pass 3 / ℹ fail 0
期望成本怎么读
把数字翻译成产品语言,这份卡池的结论是:
- 「平均 40 抽出货、606 元」是均值,不是承诺。均值被少数早出货的玩家拉低,一半玩家实际要 43 抽以上。策划做付费预算时用均值,做玩家预期管理时用中位数。
- 保底把最坏体感锁死了。没有保底时,p90 要 143 抽、p99 要 286 抽;有保底(60 起软保底 + 80 硬保底)后 p99 = 71 抽。保底的成本不是「送一次 SSR」,而是「把长尾砍短」,它换的是玩家的留存,不是收入。
- 综合概率 24‰ vs 基础概率 16‰ 的差,就是保底对出货率的抬升。这个差越大,保底越慷慨;公示时两个数都要给,否则玩家的预期会对不上体感。
下一节把视角从「抽」换成「买」:同样一笔钱,买玉璧直充、买礼包、还是等折扣,性价比差多少。
阅读导航:上一节:6.1 卡池结构与概率公示 · 下一节:6.3 礼包性价比与价值锚点 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。