5.2 伪随机与保底机制

纯随机没有记忆,长尾会把少数玩家拖到 150 抽以上。用确定性 PRNG 跑 20000 名玩家对比纯基础与软/硬保底:均值从 33.25 抽降到 24.54,p90 从 76 抽压到 44 抽,硬保底命中人数从 3292 人降到 0 人。

5.1 的掉落表是无记忆的:每一次抽条都独立,抽过多少次不影响下一次。这在数学上最干净,在体感上最糟糕——因为玩家记住的从来不是平均值,而是那次连抽 40 发什么都没出。这一节讲的就是怎么给随机加上「记忆」,把长尾收进一个可接受的区间。

边界先划清:这一节只谈出货的抽数分布怎么被保底改变,不谈「一抽多少钱、保底值不值」。后者是定价问题,留给第 6 章 。

纯随机没有记忆

如果每次抽卡独立地以 3% 概率出货,那么「出货所需抽数」服从几何分布。它有两个不受欢迎的性质:

  • 长尾:p90 是 76 抽,p99 是 152 抽。也就是说,每一百个玩家里,有一个要抽到 150 次以上才出。5.3 会把这个分布算细,这里先记住结论:尾部极长。
  • 体感与概率背离:玩家觉得「连抽 30 发不出货很不正常」,但在 3% 下,30 抽全不出的概率是 0.97³⁰ ≈ 40.1%——接近四成的人都会经历。可玩家的直觉是「不可能这么背」,于是体感就成了「这游戏概率有问题」。

结论是:纯独立随机在数学上正确,在产品上不可接受。修正它有三种常见手段。

三种修正手段

手段机制效果
硬保底连续 N 抽未出,第 N 抽必出尾部被一刀切断,上界确定
软保底连续 N 抽未出后,概率逐抽递增尾部平滑收窄,无硬拐点
伪随机分布(PRD)概率随失败次数上升,成功即重置降低方差,方差比独立随机更小

三者可以叠加。《王朝纪元》用的是软保底 + 硬保底的组合:软保底负责让体验「越抽越有希望」,硬保底负责兜底一个绝对上界。这也正是真实竞品里常见的一种公开可观察结构(例如多数手游在达到一定抽数后概率公示会写明「概率提升」并给出硬保底——此处仅作公开可观察机制引用,具体数值因游戏而异,本文所有数字都来自自己的 config/)。

保底参数

保底参数与掉落表放在同一份 config/ 里。下面这段是 D 层配置片段(完整文件见 5.1 的 config/n05_drops.json):

"pity": {
  "targetRarity": "legendary",
  "basePct": 3,
  "softPityStart": 40,
  "softPityStepPct": 6,
  "hardPity": 60
}

读出这张表的规则:

  • 第 1–39 抽:出货概率恒为 basePct = 3%。
  • 第 40 抽起:概率按 3% + (n − 40 + 1) × 6% 递增,第 40 抽是 9%,第 41 抽 15%……
  • 第 60 抽:hardPity 触发,概率 100%。

三个数字各有分工:basePct 定基础概率,softPityStart 定「开始变暖」的位置,hardPity 定绝对上界。softPityStepPct 决定尾巴收得多快。

仿真:纯基础 vs 软/硬保底

下面这段是 S 层仿真脚本,用同一确定性 PRNG 各跑 20000 名玩家,统计两种模式下「多少抽出货」的分布与累计分布函数(CDF)。

# file: sim/n05_pity.py
# tier: S
# verify: python3 sim/n05_pity.py --config config/ --out out/
"""伪随机与保底机制(5.2):对比「纯基础概率」与「软/硬保底」两种抽卡序列。

纯基础模式:每次独立 basePct 概率出货(几何分布,无记忆)。
保底模式:连续未出货到 softPityStart 后概率线性递增,到 hardPity 必出。
两模式用同一确定性 PRNG 跑固定人数,统计「多少抽出货」的分布与 CDF。

产出:out/n05_pity.json + out/n05_pity.csv + out/n05_pity.svg
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import sys
import xml.etree.ElementTree as ET

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from svg_line import polyline_points  # noqa: E402

SVG_NS = "http://www.w3.org/2000/svg"


def mulberry32(seed):
    """确定性 PRNG:与 proto/src/n05_drops.ts 逐位一致。"""
    state = seed & 0xFFFFFFFF

    def nxt():
        nonlocal state
        state = (state + 0x6D2B79F5) & 0xFFFFFFFF
        t = ((state ^ (state >> 15)) * (state | 1)) & 0xFFFFFFFF
        t = (t ^ (t + (((t ^ (t >> 7)) * (t | 61)) & 0xFFFFFFFF))) & 0xFFFFFFFF
        return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296.0

    return nxt


def load(path):
    with open(path, encoding="utf-8") as f:
        return json.load(f)


def pity_pct(n, base, soft_start, step, hard):
    """第 n 抽(自上次出货起算)的出货概率(整数百分比)。"""
    if n >= hard:
        return 100
    if n >= soft_start:
        return min(100, base + (n - soft_start + 1) * step)
    return base


def pulls_base(pct, rng):
    n = 0
    while True:
        n += 1
        if rng() * 100 < pct:
            return n


def pulls_pity(cfg, rng):
    n = 0
    while True:
        n += 1
        if rng() * 100 < pity_pct(n, cfg["basePct"], cfg["softPityStart"],
                                  cfg["softPityStepPct"], cfg["hardPity"]):
            return n


def pctl(sorted_vals, p):
    return sorted_vals[min(len(sorted_vals) - 1, (p * len(sorted_vals)) // 100)]


def summarize(vals):
    s = sorted(vals)
    n = len(s)
    mean = sum(s) / n
    var = sum((v - mean) ** 2 for v in s) / n
    return {"mean": round(mean, 3), "std": round(var ** 0.5, 3),
            "p50": pctl(s, 50), "p90": pctl(s, 90), "p99": pctl(s, 99), "max": s[-1]}


def render_multi(path, series, title):
    allv = [v for _, ys, _ in series for v in ys]
    lo, hi = min(allv), max(allv)
    svg = ET.Element("svg", {"xmlns": SVG_NS, "viewBox": "0 0 640 360", "width": "640", "height": "360"})
    ET.SubElement(svg, "rect", {"width": "640", "height": "360", "fill": "#ffffff"})
    for _, ys, color in series:
        ET.SubElement(svg, "polyline", {"points": polyline_points(ys, lo, hi),
                                        "fill": "none", "stroke": color, "stroke-width": "2"})
    t = ET.SubElement(svg, "text", {"x": "56", "y": "16", "font-size": "13", "fill": "#111"})
    t.text = title
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return path


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()
    cfg = load(os.path.join(args.config, "n05_drops.json"))["pity"]
    players = load(os.path.join(args.config, "n05_drops.json"))["sim"]["players"]
    base = cfg["basePct"]
    hard = cfg["hardPity"]

    rng = mulberry32(20261012)
    base_vals = [pulls_base(base, rng) for _ in range(players)]
    pity_vals = [pulls_pity(cfg, rng) for _ in range(players)]

    # CDF:累计到第 n 抽出货的玩家占比
    max_n = max(max(base_vals), max(pity_vals))
    cdf_base, cdf_pity = [], []
    for n in range(1, max_n + 1):
        cdf_base.append(100.0 * sum(1 for v in base_vals if v <= n) / players)
        cdf_pity.append(100.0 * sum(1 for v in pity_vals if v <= n) / players)

    report = {
        "version": 1, "players": players, "basePct": base,
        "softPityStart": cfg["softPityStart"], "hardPity": hard,
        "base": summarize(base_vals), "pity": summarize(pity_vals),
        "baseHardCapHits": sum(1 for v in base_vals if v >= hard),
        "pityHardCapHits": sum(1 for v in pity_vals if v >= hard),
    }
    os.makedirs(args.out, exist_ok=True)
    with open(os.path.join(args.out, "n05_pity.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "n05_pity.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["pulls", "cdfBasePct", "cdfPityPct"])
        for n in range(max_n):
            w.writerow([n + 1, round(cdf_base[n], 4), round(cdf_pity[n], 4)])
    render_multi(os.path.join(args.out, "n05_pity.svg"),
                 [("纯基础", cdf_base, "#d1242f"), ("软/硬保底", cdf_pity, "#1f6feb")],
                 f"出货 CDF:基础 p50={report['base']['p50']} / 保底 p50={report['pity']['p50']}")
    print(f"纯基础:均值 {report['base']['mean']} p50 {report['base']['p50']} "
          f"p90 {report['base']['p90']} p99 {report['base']['p99']} max {report['base']['max']}")
    print(f"保底  :均值 {report['pity']['mean']} p50 {report['pity']['p50']} "
          f"p90 {report['pity']['p90']} p99 {report['pity']['p99']} max {report['pity']['max']}")
    print(f"硬保底命中:基础 {report['baseHardCapHits']} 人 / 保底 {report['pityHardCapHits']} 人(共 {players})")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

本机实跑输出(同机相对量级,绝对数值不可移植):

纯基础:均值 33.246 p50 23 p90 76 p99 152 max 305
保底  :均值 24.543 p50 23 p90 44 p99 48 max 52
硬保底命中:基础 3292 人 / 保底 0 人(共 20000)

三行数字,三个结论:

  1. 均值从 33.25 抽降到 24.54 抽——保底把「平均要抽多久」压掉了约四分之一。
  2. p50 两边都是 23 抽,p90 却从 76 抽压到 44 抽。中位数几乎不动,说明保底没怎么改变「大多数人」的体验,它改的是尾巴。
  3. 硬保底命中人数从 3292 人(16.5%)降到 0 人。纯基础下有 16.5% 的玩家要抽满 60 次以上;保底模式下没有一个人需要抽到 60 抽——因为软保底早在第 52 抽之前就把所有人都捞出来了。

读 CDF:保底把概率搬到了哪里

out/n05_pity.csv 记录了两条 CDF。抽样看几个关键抽数:

抽数纯基础累计保底累计
2045.53%45.66%
2350.35%50.44%
3969.70%69.47%
4070.68%72.38%
4373.48%86.45%
4575.13%94.33%
4877.23%99.37%
4977.93%99.75%

第 40 抽之前,两条线几乎重合——这是保底的第一个设计要点:软保底之前不能动,否则等于变相提高基础概率,稀释了「抽到即惊喜」的爽感。第 40 抽起两条线急剧分岔:到第 45 抽,保底玩家已有 94.33% 出货,纯基础只有 75.13%;到第 48 抽,保底 99.37%,纯基础还停在 77.23%。

out/n05_pity.svg 把这两条 CDF 画在一起:红线(纯基础)是一条平缓爬升的曲线,蓝线(保底)在 40 抽处拐了个明显的弯。那个拐点就是保底「记忆」开始起作用的地方。

保底设计的三条纪律

从这张图和数字里能抽出三条可复用的纪律:

  1. 软保底的起点要藏在体感阈值之后。40 抽是个心理坎——多数玩家在 40 抽内就该有结果,软保底若从第 10 抽就启动,等于把基础概率偷偷抬高,长线看会拉低单抽的期望价值。
  2. 硬保底是兜底,不是主菜。本例里软保底已经让硬保底命中数为 0——硬保底的真正作用是给一个可公示、可承诺的上界,让玩家相信「最坏也就 60 抽」,而不是真的指望玩家抽到 60 抽。
  3. 保底不改变中位数,只改变尾部。p50 从 23 到 23 纹丝不动。所以保底不该被当成拉高平均出货率的手段——它是体验保险,不是数值杠杆。

小结

这一节给纯随机加上了「记忆」:

  1. 纯随机的两个病:长尾(p99 = 152 抽)与体感背离(30 抽不出货占 40%)。
  2. 软 + 硬保底把均值压到 24.54 抽、p90 压到 44 抽、硬保底命中清零,同时几乎不动中位数。
  3. CDF 的拐点是保底生效的位置,起点要选在体感阈值之后。
  4. 三条纪律:起点藏后、硬保底做承诺、保底是保险不是杠杆。

保底改变了分布的形状,但「这分布到底多稳、尾部还剩多少风险」还没量化。下一节用期望、方差与体验区间把整条分布钉死。

阅读导航:上一节:5.1 掉落表与概率分布 · 下一节:5.3 期望、方差与体验区间 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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