保底不是把機率調高:用狀態機算抽卡保底與裝備強化

加了保底,平均抽數可能沒變多少,等待最久的玩家卻會差很多。固定 1.6%、硬保底 100 抽、軟保底逐抽加機率,三套規則平均都要 62 到 63 抽,最倒楣的 1% 玩家卻分別要 286、100、92 抽。機率一旦跟「已經失敗幾次」有關,就得把歷史放進狀態裡,才能算出等待次數的分布。

在〈掉落率 1%,為什麼挖一百次還是沒有〉裡,挖了 200 次之後,下一次機率還是 1%,因為幾何分布沒有記憶性。保底刻意改掉這項條件,讓系統記住先前抽了多少次。

把「失敗幾次」當成狀態

把第 n 抽命中的機率寫成 rate(n),n 表示距離上次命中已經抽了幾次。保底規則的狀態只有這一個整數,不需要模擬,直接往下推每一抽「到這裡還沒中」的機率就能算:

alive(1) = 1
P(第 n 抽第一次中) = alive(n) · rate(n)
alive(n+1) = alive(n) · (1 - rate(n))

rate(n) 是常數時,這就是上一篇的幾何分布;到某一抽變成 1,就是硬保底;從某一抽開始遞增,就是一般說的軟保底。有了每一抽的機率,平均、中位數、P95 都能直接算出來。

《原神》的公告與社群模型

《原神》的祈願說明公告五星角色基礎機率 0.600%、綜合機率(含保底)1.600%,最多 90 抽內必定獲得。軟保底不在官方說明裡,社群維護的 Genshin Impact Wiki 寫明「soft pity」一詞是非官方的,存在與起點都是玩家實測得出,角色池大約從第 74 抽開始。社群常用的模型是從第 74 抽起每抽加 6%,這裡拿它來驗算:如果模型合理,算出來的綜合機率應該接近官方公告的 1.6%。

import sys


def first_hit_dist(rate_at):
    """rate_at(n) = chance that pull n hits, given pulls 1..n-1 all missed.
    Returns P(first hit happens on pull n) for n = 1..N (state = misses so far)."""
    dist, alive, n = [], 1.0, 1
    while alive > 1e-12:
        r = min(1.0, rate_at(n))
        dist.append(alive * r)
        alive *= 1 - r
        n += 1
    return dist


def summary(dist):
    mean = sum((i + 1) * p for i, p in enumerate(dist))
    cdf, qs, out = 0.0, [0.5, 0.9, 0.95, 0.99], {}
    for i, p in enumerate(dist):
        cdf += p
        while qs and cdf >= qs[0] - 1e-12:
            out[qs.pop(0)] = i + 1
    return mean, out, len(dist)


print("== 1. a public example: 0.6% base, guaranteed by pull 90 ==")
print("   soft pity (+6% per pull from pull 74) is a community estimate, not official")
base = 0.006
hard_only = first_hit_dist(lambda n: 1.0 if n >= 90 else base)
soft = first_hit_dist(lambda n: 1.0 if n >= 90 else base + max(0, n - 73) * 0.06)
for label, d in (("hard pity only", hard_only), ("soft + hard pity", soft)):
    mean, q, worst = summary(d)
    print(f"{label:17} mean {mean:5.2f} pulls -> consolidated rate {1/mean*100:.3f}%"
          f"  median {q[0.5]}  P90 {q[0.9]}  P99 {q[0.99]}  worst {worst}")
== 1. a public example: 0.6% base, guaranteed by pull 90 ==
   soft pity (+6% per pull from pull 74) is a community estimate, not official
hard pity only    mean 69.70 pulls -> consolidated rate 1.435%  median 90  P90 90  P99 90  worst 90
soft + hard pity  mean 62.30 pulls -> consolidated rate 1.605%  median 76  P90 80  P99 83  worst 90

只有 0.6% 加上第 90 抽硬保底,綜合機率是 1.435%,跟公告的 1.6% 對不上;加上社群估計的軟保底,算出 1.605%,才與公告數字相容。演算法本身沒有公開,模型對不對還是需驗證。

「只有硬保底」那一列的中位數是 90,因為在 0.6% 的機率下,89 抽都沒中的機率約 58.5%,超過一半的玩家得靠保底取得,保底也就成了主要的取得方式。0.6% 的基礎機率只讓約四成的人提早拿到。

平均相近,長尾差很多

回到太空採礦遊戲,替稀有鑽頭設計三套虛構規則,沿用剛才的算法比較。這些範例在 Python 3.13.16 上跑過:

print("\n== 2. three made-up rules for one rare drill head ==")
rules = {
    "flat 1.6%, no pity": (lambda n: 0.016, False),
    "1.0%, hard pity at 100": (lambda n: 1.0 if n >= 100 else 0.010, True),
    "0.8%, +5%/pull from pull 82": (lambda n: 0.008 + max(0, n - 81) * 0.05, True),
}
for name, (f, capped) in rules.items():
    mean, q, worst = summary(first_hit_dist(f))
    worst_s = str(worst) if capped else "none"
    print(f"{name:30} mean {mean:5.1f}  median {q[0.5]:3d}  P95 {q[0.95]:3d}"
          f"  P99 {q[0.99]:3d}  worst {worst_s}")
== 2. three made-up rules for one rare drill head ==
flat 1.6%, no pity             mean  62.5  median  43  P95 186  P99 286  worst none
1.0%, hard pity at 100         mean  63.4  median  69  P95 100  P99 100  worst 100
0.8%, +5%/pull from pull 82    mean  62.5  median  82  P95  90  P99  92  worst 101

平均成本差不到一抽,分位數卻差很多:

  • 固定 1.6% 的中位數最低,一半的人 43 抽內就拿到,但 P99 要 286 抽,規則裡也沒有上限。
  • 硬保底 100 抽的中位數 69,P95 和 P99 都是 100,最差的情況直接寫在規則裡。
  • 軟保底的中位數最高,約一半的人在前 81 抽就拿到,其餘的人集中在 82 到 92 抽之間,P95 和 P99 只差兩抽,長尾最短。

如果公告只寫「綜合機率約 1.6%」,三套規則都說得通,玩家卻看不出長尾的差別,最好把保底規則也一併寫明。

強化退階:吸收型 Markov chain

裝備強化失敗時,可能留在原級,也可能退一級,狀態就比保底複雜。假設從 +0 強化到 +10,每一級的成功率依序是 95%、90%、85% 一路降到 15%;+5 以上失敗會掉一級。這時候狀態是「現在幾級」,+10 是吸收態(到了就不會離開),其他是暫態。

吸收型 Markov chain 有現成的公式:把暫態之間的轉移寫成矩陣 Q,基本矩陣 N = (I - Q)^-1,從各狀態出發到被吸收的期望步數是 t = N·1。實作上不必真的求反矩陣,解 (I - Q)·t = 1 這個線性方程組就好:

def solve(a, b):
    """Gaussian elimination, small dense systems only."""
    n = len(a)
    m = [row[:] + [b[i]] for i, row in enumerate(a)]
    for c in range(n):
        piv = max(range(c, n), key=lambda r: abs(m[r][c]))
        m[c], m[piv] = m[piv], m[c]
        for r in range(n):
            if r != c and m[r][c]:
                f = m[r][c] / m[c][c]
                m[r] = [x - f * y for x, y in zip(m[r], m[c])]
    return [m[i][n] / m[i][i] for i in range(n)]


print("\n== 3. upgrade +0 to +10 as an absorbing Markov chain ==")
SUCCESS = [0.95, 0.9, 0.85, 0.75, 0.65, 0.55, 0.45, 0.35, 0.25, 0.15]  # from level i to i+1


def expected_tries(drop_from, protect=False):
    """Fail at level >= drop_from loses one level unless protected.
    Solve E[i] = 1 + s*E[i+1] + (1-s)*E[fail_to], with E[10] = 0, i.e. t = (I - Q)^-1 1."""
    n = len(SUCCESS)
    a = [[0.0] * n for _ in range(n)]
    for i, s in enumerate(SUCCESS):
        a[i][i] += 1.0
        if i + 1 < n:
            a[i][i + 1] -= s
        fail_to = i - 1 if (i >= drop_from and not protect) else i
        a[i][fail_to] -= 1 - s
    return solve(a, [1.0] * n)


for label, kw in (("never drops", dict(drop_from=99)),
                  ("drops one level from +5", dict(drop_from=5)),
                  ("drops from +5, protected", dict(drop_from=5, protect=True))):
    e = expected_tries(**kw)
    print(f"{label:25} expected tries +0->+10: {e[0]:7.1f}   +7->+10: {e[7]:6.1f}")
scrolls = sum((1 - s) / s for s in SUCCESS[5:])
print(f"protection scrolls used on average: {scrolls:.1f}")

import random
rng = random.Random(10)
def one_player(drop_from=5):
    lvl = tries = 0
    while lvl < 10:
        tries += 1
        if rng.random() < SUCCESS[lvl]:
            lvl += 1
        elif lvl >= drop_from:
            lvl -= 1
    return tries
runs = sorted(one_player() for _ in range(20_000))
print(f"20,000 players, drops from +5: mean {sum(runs)/len(runs):.1f}  median {runs[10_000]}"
      f"  P95 {runs[19_000]}  P99 {runs[19_800]}  worst {runs[-1]}")
print(f"\nPython {sys.version.split()[0]}")
== 3. upgrade +0 to +10 as an absorbing Markov chain ==
never drops               expected tries +0->+10:    23.8   +7->+10:   13.5
drops one level from +5   expected tries +0->+10:   341.9   +7->+10:  326.7
drops from +5, protected  expected tries +0->+10:    23.8   +7->+10:   13.5
protection scrolls used on average: 12.6
20,000 players, drops from +5: mean 340.0  median 241  P95 979  P99 1518  worst 3443

Python 3.13.16

成功率完全一樣,只是加上「+5 以上失敗退一級」,期望次數從 23.8 次變成 341.9 次,約是原來的 14 倍。從 +7 出發要 326.7 次,跟從 +0 出發差不多,因為高等級一失敗就往回掉,前面累積的進度保不住。兩萬個模擬玩家的平均 340.0,和公式的 341.9 吻合;中位數 241,P99 卻是 1,518 次。

保護道具取消了退階,強化次數回到 23.8,平均要用掉 12.6 張保護卷。商城定價時,可以把保護卷價格乘上 12.6,與「不用保護卷多強化 318 次」的材料成本比較,用來估計玩家會選哪一邊、願意付多少。

這裡的鏈只有十個狀態,用高斯消去法就夠了。狀態再多(例如強化等級乘上保底計數),一樣是解線性方程組,只是矩陣變大,可以改用稀疏矩陣的解法。

權重變更與機率公告

狀態和權重藏在伺服器裡,玩家只能看到結果。韓國公平交易委員會 2024 年 1 月 3 日公布對 Nexon 的處分,針對《新楓之谷》的方塊道具開罰 116 億韓元,依《電子商務消費者保護法》認定其以錯誤資訊招攬購買。根據 TechRadar 與 KED Global 的報導,方塊在 2010 年 5 月推出時各選項機率相同,同年 9 月改成部分選項較少出現,2011 年 8 月到 2021 年 3 月間某些高人氣選項的機率是 0;Nexon 在 2011 年 8 月還發過公告表示方塊的結構沒有變動。Inven Global 報導 Nexon 已提起上訴。

這個案例涉及權重表變更,和 RNG 認證無關,亂數本身可能完全正常。公告要從正式環境的設定產生,每次變更都留下紀錄,才能讓玩家看到的說明跟系統實際狀態一致,上一篇的掉落表也有相同的要求。

保底計數器的重置與保存

保底計數器是玩家的資產,處理起來得比一般遊戲狀態嚴格。命中後就歸零,或等命中「指定目標」才歸零,會得到不同的分布,規格和公告都要寫清楚。

活動池結束後,計數器是否帶到下一池,也會影響玩家要不要現在抽,改變繼承規則就等於改變玩家已經累積的資產。

客戶端逾時重送同一筆抽卡請求時,伺服器不能重複扣款,也不能讓保底計數多加一次。請求要帶冪等鍵,扣款、抽樣、更新計數器在同一個交易裡完成,做法跟〈同一筆付款送了兩次:從 Santander 的聖誕節講冪等與重送〉相同。

存放位置也要考慮:計數器放在客戶端,玩家就能改;放在快取裡,則得確認不會因過期或重啟而歸零。

補充筆記

  • 把「已經失敗幾次」當成狀態,就能逐抽算出保底的精確分布。平均成本相近,P99 仍可能差三倍以上,得另外看長尾。
  • 0.6% 加 90 抽硬保底算出 1.435%,加上社群估計的軟保底則是 1.605%,與公告的 1.6% 相容;演算法尚未公開,不能據此確認模型。
  • 強化退階可以用吸收型 Markov chain 計算,解 (I - Q)·t = 1 得到期望次數。本例加入退階後,期望次數由 23.8 增至 341.9,保護道具的價值也從這個差距估算。
  • Nexon 的處分涉及權重變更卻未告知玩家,機率結構每次改動都要留紀錄並反映在公告上。保底計數器的重置、跨池繼承、重送與存放位置也要明訂,重送尤其不能重複扣款或多加計數。

延伸閱讀

想法與技術判斷出自 Sheng,和 Claude 一起起草 · 範例在 Python 3.13.16 實測。