6.2 期望成本与「多少抽出货」

用 mulberry32 确定性 PRNG 模拟 5 万名玩家「抽到首个 SSR 需要多少抽」,把软保底与硬保底一起跑进分布,得到均值、中位数与 p90/p99,再折算成玉璧与人民币期望成本。用 B 层脚本把模拟均值与闭式期望做对照,用 P 层测试证明 TS 侧与 Python 侧逐位一致。

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 礼包性价比与价值锚点 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引