保底不是把機率調高:用狀態機算抽卡保底與裝備強化
加了保底,平均抽數可能沒變多少,等待最久的玩家卻會差很多。固定 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 的處分涉及權重變更卻未告知玩家,機率結構每次改動都要留紀錄並反映在公告上。保底計數器的重置、跨池繼承、重送與存放位置也要明訂,重送尤其不能重複扣款或多加計數。
延伸閱讀
- Genshin Impact Wiki〈Wish〉:轉載遊戲內祈願說明的機率與 90 抽保底,並註明軟保底是玩家實測得出,社群維護的維基,機率以遊戲內公告為準。
- Wikipedia〈Absorbing Markov chain〉:標準形式、基本矩陣
N = (I - Q)^-1與期望步數t = N1,百科條目,用來對照公式。 - Wikipedia〈Gacha game〉:軟保底、硬保底與 sparking 的定義,以及日本 2012 年禁止 complete gacha 等監管沿革,百科條目。
- Korea JoongAng Daily〈Antitrust agency slaps Nexon with $8.9 million fine for misleading users〉:2024-01-03 處分的金額與法律依據,新聞報導。
- TechRadar〈Nexon fined almost $9 million〉與 KED Global〈Nexon fined over MapleStory in-game item selling〉:方塊機率結構變動的時間線,新聞報導。
- Inven Global〈Nexon’s MapleStory Appeals Fine〉:上訴進度,新聞報導,判決結果以法院公告為準。
想法與技術判斷出自 Sheng,和 Claude 一起起草 · 範例在 Python 3.13.16 實測。