はじめに
昨今ソーシャルゲームを中心としてガチャ確率表記は必須です。
そこで気を付けたいのが抽選ロジックとの不一致です。
先にありがちな、悪い例を上げます。
(疑似コード)
def lottery(cards, ratio):
#何らかの抽選ロジック(例えば乱数をもとにratioとcardsから排出対象を決める)
return card
def counting_for_test(cards):
#レアリティごとの排出割合を集計して返す処理が書いてある想定
return counts
def test()
cards = [{id:1,rarity:R},{id:2,rarity:R},...,{id:80,rarity:SSR}]
ratio = {R:79,SR:20,SSR:1}
#確率表記
print(ratio)
#抽選テスト
res = [lottery(cards, ratio) for i in range(50000)]
print(counting_for_test(res))
lotteryはratioに依存しているため一見正しそうですが、それはlotteryがバグっていない前提です。
仮にlotteryにバグがあった場合、確率表記だけは意図通りに動作するので詐欺になります。
(リリース当初は問題なくても、後々ピックアップ機能を追加するなど、lotteryに手を加えるたびにそのリスクが発生します)
確率設定をカードごとの整数比に直して抽選箱を作ると、それを使って表記も抽選も行えるのでこれを防げます。
Pythonで軽く書いてみました。
どの言語にも変換可能です。
(実際、私はC#で同じような実装を書いてました)
(なお乱数の質に関しては本題ではないので、ここでは無視してください)
ソースコード
順にコピペすれば動きます。
カードが持つ要素
import math
import numpy as np
from functools import reduce
from enum import IntEnum, auto
#カードのレアリティ
class Rarity(IntEnum):
N = auto()
R = auto()
SR = auto()
SSR = auto()
#カードの5属性
class Element(IntEnum):
Blaze = auto() #頭文字Forestと変えたいのでFireではない
Water = auto()
Forest = auto()
Light = auto()
Dark = auto()
#カードのリリース区分
class Series(IntEnum):
Launch = auto() #初期リリース
Olympus = auto() #追加パック
Japanese = auto() #追加パック
Nordic = auto() #追加パック
Grimm = auto() #追加パック
Festival = auto() #フェス限定
カードリストの操作
#要素に注目してカードリストを辞書化する
def classify_cards(cards, label):
res = {}
for card in sorted(cards, key=lambda x:x[label]):
key = card[label]
if key not in res:
res[key] = []
res[key].append(card)
return res
#要素に注目してカード枚数を数える
def classify_counts(cards, label):
res = {}
for card in sorted(cards, key=lambda x:x[label]):
key = card[label]
if key not in res:
res[key] = 0
res[key] += 1
return res
#カードリストを要素で絞り込む
def filter_cards(cards, label, values):
return [card for card in cards if card[label] in values]
#カードリストから要素のみ抽出する
def card_values(cards, label):
return [card[label] for card in cards]
整数化のための操作
#最小公倍数
def lcm(a, b):
return a*b // math.gcd(a,b)
#実数を整数と10のN乗に因数分解する
def factorize_to_integer(number):
s = str(number)
i = s.find('.')
mul10 = 10**(len(s)-i-1) if i != -1 else 1
return int(number*mul10), mul10
#比率を整数に直す
def integerize_ratio(ratio):
mul10 = max([factorize_to_integer(v)[1] for v in ratio.values()])
return {k:int(v*mul10) for k,v in ratio.items()}
少数の発生はマスター次第なので、適宜計算結果が整数であることを保証する必要があります。
(最終的に欲しいのは比率なので、どの時点で少数が発生しようと整数に直すことができます)
抽選箱の作成
#排出率に従って抽選箱を作成する
def create_lottery_box(cards, source_ratio):
counts = classify_counts(cards, 'rarity')
counts_lcm = reduce(lcm, counts.values())
ratio = integerize_ratio(source_ratio)
ratio_gcd = reduce(math.gcd, ratio.values())
return [card for card in cards if card['rarity'] in ratio
for i in range(ratio[card['rarity']]//ratio_gcd * counts_lcm//counts[card['rarity']])]
ここが本題です。
カードごとの排出率を全体枚数に対する整数比に直して箱に封入します。
「レアリティ排出率」/「レアリティ内のカード枚数」が整数比になるよう計算しています。
(ratio_gcdで割るのは封入数を最小化するためです。なくても成り立ちます)
確率アップ処理
#特定レアリティの排出率をN倍にして、その分最低レアリティの排出率を下げる
def rarity_up(source_ratio, rarity, source_rate):
ratio = integerize_ratio(source_ratio)
rate, mul10 = factorize_to_integer(source_rate)
before_ratio_val = ratio[rarity]*mul10
ratio = {k:v*(rate if k == rarity else mul10) for k,v in ratio.items()}
ratio[min(ratio.keys())] -= ratio[rarity] - before_ratio_val
return ratio
#特定カードの排出率をN倍にする
def pickup(cards, ids, source_rate):
pickup_cards = filter_cards(cards, 'id', ids)
pickup_rarities = set(card_values(pickup_cards, 'rarity'))
rarities = set(card_values(cards, 'rarity'))
rarity_card_dict = classify_cards(cards, 'rarity')
res = []
for rarity in rarities:
if rarity in pickup_rarities:
rarity_cards = rarity_card_dict[rarity]
total = len(rarity_cards)
pickup = len(filter_cards(pickup_cards, 'rarity', [rarity]))
rate, mul10 = factorize_to_integer(source_rate)
#ピックアップ対象の排出率x、対象外の排出率y
# x = 1/total * source_rate ...元の排出率をN倍にする
# y = (1 - x * pickup) / (total - pickup) ...確率アップ対象を除外した排出率を分配する
# ↓ 式の整理
x = rate * total - rate * pickup
y = mul10 * total - rate * pickup
assert x > 0 and y > 0, "ピックアップ排出率がオーバーフローします。"
xy_gcd = math.gcd(x, y)
x = x // xy_gcd
y = y // xy_gcd
res += [card for card in rarity_cards for i in range(x if card['id'] in ids else y)]
else:
res += rarity_card_dict[rarity]
return res
pickupはレアリティ内のカードごとの枚数比を、倍率に従って変化させます。
create_lottery_boxは単純にcards分処理するので、ここで変化した比率がダイレクトに反映されます。
(xy_gcdで割るのも最小化目的です。なくても成り立ちます)
rarity_upはもともとのratioをアップ後の排出率で設定するなら不要です。
テストデータ
def create_card(seq, rarity, series, element):
id = seq.next()
return {'id':id, 'rarity':rarity, 'series':series, 'element':element,
'name':"%03s-%s-%s-%s"%(id,series.name,rarity.name,element.name)}
class Sequence:
def __init__(self):
self.value = 0
def next(self):
self.value += 1
return self.value
def test():
#カードリスト作成
seq = Sequence()
all_cards = [create_card(seq, Rarity.N, Series.Launch, e) for i in range(8) for e in Element] +\
[create_card(seq, Rarity.R, Series.Launch, e) for i in range(8) for e in Element] +\
[create_card(seq, Rarity.SR, Series.Launch, e) for i in range(4) for e in Element] +\
[create_card(seq, Rarity.SSR, Series.Launch, e) for i in range(2) for e in Element] +\
[create_card(seq, Rarity.SSR, Series.Olympus, e) for e in Element] +\
[create_card(seq, Rarity.SSR, Series.Japanese, e) for e in Element] +\
[create_card(seq, Rarity.SSR, Series.Nordic, e) for e in Element] +\
[create_card(seq, Rarity.SSR, Series.Grimm, e) for e in Element] +\
[create_card(seq, Rarity.SSR, Series.Festival, e) for e in Element]
fes_cards = filter_cards(all_cards, 'series', [Series.Festival])
cards = [card for card in all_cards if card not in fes_cards]
grimm_cards = filter_cards(cards, 'series', [Series.Grimm])
#確率設定
free_ratio = {Rarity.N:79, Rarity.R:20, Rarity.SR:1}
rare_ratio = {Rarity.R:79, Rarity.SR:20, Rarity.SSR:1}
sr_ratio = {Rarity.SR:95, Rarity.SSR:5}
for ratio in [free_ratio, rare_ratio, sr_ratio]:
sum_ratio = float(sum(ratio.values()))
assert sum_ratio==100, "排出率の合計が[%s%%]です。"%sum_ratio
#排出テスト
lottery_test("無料ガチャ", cards, free_ratio)
lottery_test("レアガチャ", cards, rare_ratio)
lottery_test("SSR確率アップガチャ", cards, rarity_up(rare_ratio, Rarity.SSR, 2))
lottery_test("属性ガチャ", filter_cards(cards, 'element', [Element.Blaze]), rare_ratio)
lottery_test("新シリーズ追加ガチャ", pickup(cards, card_values(grimm_cards, 'id'), 5), rare_ratio)
lottery_test("限定フェスガチャ", pickup(cards + fes_cards, card_values(fes_cards, 'id'), 5), rare_ratio)
lottery_test("SR以上確定ガチャ", cards, sr_ratio)
ありがちなソーシャルゲームの設定を入れています。
(確率と倍率は単純な整数を入れてますが、少数でも問題ない実装です)
- 初期リリースと追加シリーズのオリュンポス、日本神話、北欧神話、グリム童話。
- 最新シリーズはグリム童話。追加時のガチャでピックアップ。
- 限定フェスでのみ排出されるカードが存在。開催時はピックアップ。
- 他に無料ガチャ、通常のレアガチャ、SSR確率アップガチャ、特定属性だけ出るガチャ、SR以上確定のガチャ。
確率表記と排出テスト
def lottery_test(title, cards, ratio):
#確率表記
box = create_lottery_box(cards, ratio)
print_card_counts("%s 確率表記"%title, box)
#抽選する
res = np.random.choice(box, 5000000)
print_card_counts("%s 排出テスト"%title, res)
def print_card_counts(title, cards):
print(title)
#レアリティごとの枚数と割合
counts = classify_counts(cards, 'rarity')
print(counts)
lambda_rates = lambda counts: {k:"%.3f%%"%(v/sum(counts.values())*100) for k,v in counts.items()}
print(lambda_rates(counts))
#カード種類ごとの枚数と割合
counts = classify_counts(cards, 'name')
rates = lambda_rates(counts)
for name in counts.keys():
print("%6s枚"%counts[name], rates[name], name)
print()
test()
確率表記は箱を開示するだけ、抽選は箱からランダムに選ぶだけです。
どちらも同じ箱を参照するので、不一致の余地はほぼありません。
厳密にはnp.random.choice, print_card_countsにバグがあると不一致の可能性はあります。
しかしその程度は初回のQAで担保できるでしょう(以降の機能追加で変更が生じる部分ではありません)。
テスト出力(抜粋)
新シリーズ追加ガチャ 確率表記
{<Rarity.R: 2>: 47400, <Rarity.SR: 3>: 12000, <Rarity.SSR: 4>: 600}
{<Rarity.R: 2>: '79.000%', <Rarity.SR: 3>: '20.000%', <Rarity.SSR: 4>: '1.000%'}
1185枚 1.975% 41-Launch-R-Blaze
1185枚 1.975% 42-Launch-R-Water
1185枚 1.975% 43-Launch-R-Forest
1185枚 1.975% 44-Launch-R-Light
1185枚 1.975% 45-Launch-R-Dark
1185枚 1.975% 46-Launch-R-Blaze
1185枚 1.975% 47-Launch-R-Water
1185枚 1.975% 48-Launch-R-Forest
1185枚 1.975% 49-Launch-R-Light
1185枚 1.975% 50-Launch-R-Dark
1185枚 1.975% 51-Launch-R-Blaze
1185枚 1.975% 52-Launch-R-Water
1185枚 1.975% 53-Launch-R-Forest
1185枚 1.975% 54-Launch-R-Light
1185枚 1.975% 55-Launch-R-Dark
1185枚 1.975% 56-Launch-R-Blaze
1185枚 1.975% 57-Launch-R-Water
1185枚 1.975% 58-Launch-R-Forest
1185枚 1.975% 59-Launch-R-Light
1185枚 1.975% 60-Launch-R-Dark
1185枚 1.975% 61-Launch-R-Blaze
1185枚 1.975% 62-Launch-R-Water
1185枚 1.975% 63-Launch-R-Forest
1185枚 1.975% 64-Launch-R-Light
1185枚 1.975% 65-Launch-R-Dark
1185枚 1.975% 66-Launch-R-Blaze
1185枚 1.975% 67-Launch-R-Water
1185枚 1.975% 68-Launch-R-Forest
1185枚 1.975% 69-Launch-R-Light
1185枚 1.975% 70-Launch-R-Dark
1185枚 1.975% 71-Launch-R-Blaze
1185枚 1.975% 72-Launch-R-Water
1185枚 1.975% 73-Launch-R-Forest
1185枚 1.975% 74-Launch-R-Light
1185枚 1.975% 75-Launch-R-Dark
1185枚 1.975% 76-Launch-R-Blaze
1185枚 1.975% 77-Launch-R-Water
1185枚 1.975% 78-Launch-R-Forest
1185枚 1.975% 79-Launch-R-Light
1185枚 1.975% 80-Launch-R-Dark
600枚 1.000% 81-Launch-SR-Blaze
600枚 1.000% 82-Launch-SR-Water
600枚 1.000% 83-Launch-SR-Forest
600枚 1.000% 84-Launch-SR-Light
600枚 1.000% 85-Launch-SR-Dark
600枚 1.000% 86-Launch-SR-Blaze
600枚 1.000% 87-Launch-SR-Water
600枚 1.000% 88-Launch-SR-Forest
600枚 1.000% 89-Launch-SR-Light
600枚 1.000% 90-Launch-SR-Dark
600枚 1.000% 91-Launch-SR-Blaze
600枚 1.000% 92-Launch-SR-Water
600枚 1.000% 93-Launch-SR-Forest
600枚 1.000% 94-Launch-SR-Light
600枚 1.000% 95-Launch-SR-Dark
600枚 1.000% 96-Launch-SR-Blaze
600枚 1.000% 97-Launch-SR-Water
600枚 1.000% 98-Launch-SR-Forest
600枚 1.000% 99-Launch-SR-Light
600枚 1.000% 100-Launch-SR-Dark
4枚 0.007% 101-Launch-SSR-Blaze
4枚 0.007% 102-Launch-SSR-Water
4枚 0.007% 103-Launch-SSR-Forest
4枚 0.007% 104-Launch-SSR-Light
4枚 0.007% 105-Launch-SSR-Dark
4枚 0.007% 106-Launch-SSR-Blaze
4枚 0.007% 107-Launch-SSR-Water
4枚 0.007% 108-Launch-SSR-Forest
4枚 0.007% 109-Launch-SSR-Light
4枚 0.007% 110-Launch-SSR-Dark
4枚 0.007% 111-Olympus-SSR-Blaze
4枚 0.007% 112-Olympus-SSR-Water
4枚 0.007% 113-Olympus-SSR-Forest
4枚 0.007% 114-Olympus-SSR-Light
4枚 0.007% 115-Olympus-SSR-Dark
4枚 0.007% 116-Japanese-SSR-Blaze
4枚 0.007% 117-Japanese-SSR-Water
4枚 0.007% 118-Japanese-SSR-Forest
4枚 0.007% 119-Japanese-SSR-Light
4枚 0.007% 120-Japanese-SSR-Dark
4枚 0.007% 121-Nordic-SSR-Blaze
4枚 0.007% 122-Nordic-SSR-Water
4枚 0.007% 123-Nordic-SSR-Forest
4枚 0.007% 124-Nordic-SSR-Light
4枚 0.007% 125-Nordic-SSR-Dark
100枚 0.167% 126-Grimm-SSR-Blaze
100枚 0.167% 127-Grimm-SSR-Water
100枚 0.167% 128-Grimm-SSR-Forest
100枚 0.167% 129-Grimm-SSR-Light
100枚 0.167% 130-Grimm-SSR-Dark
新シリーズ追加ガチャ 排出テスト
{<Rarity.R: 2>: 3949492, <Rarity.SR: 3>: 1000613, <Rarity.SSR: 4>: 49895}
{<Rarity.R: 2>: '78.990%', <Rarity.SR: 3>: '20.012%', <Rarity.SSR: 4>: '0.998%'}
99240枚 1.985% 41-Launch-R-Blaze
98946枚 1.979% 42-Launch-R-Water
98718枚 1.974% 43-Launch-R-Forest
99098枚 1.982% 44-Launch-R-Light
98927枚 1.979% 45-Launch-R-Dark
98531枚 1.971% 46-Launch-R-Blaze
97981枚 1.960% 47-Launch-R-Water
99174枚 1.983% 48-Launch-R-Forest
98883枚 1.978% 49-Launch-R-Light
98473枚 1.969% 50-Launch-R-Dark
98491枚 1.970% 51-Launch-R-Blaze
98551枚 1.971% 52-Launch-R-Water
98716枚 1.974% 53-Launch-R-Forest
98492枚 1.970% 54-Launch-R-Light
98760枚 1.975% 55-Launch-R-Dark
98576枚 1.972% 56-Launch-R-Blaze
99285枚 1.986% 57-Launch-R-Water
98660枚 1.973% 58-Launch-R-Forest
98583枚 1.972% 59-Launch-R-Light
98559枚 1.971% 60-Launch-R-Dark
98834枚 1.977% 61-Launch-R-Blaze
99094枚 1.982% 62-Launch-R-Water
98636枚 1.973% 63-Launch-R-Forest
98553枚 1.971% 64-Launch-R-Light
98689枚 1.974% 65-Launch-R-Dark
98669枚 1.973% 66-Launch-R-Blaze
99066枚 1.981% 67-Launch-R-Water
98738枚 1.975% 68-Launch-R-Forest
98875枚 1.978% 69-Launch-R-Light
98329枚 1.967% 70-Launch-R-Dark
99133枚 1.983% 71-Launch-R-Blaze
98843枚 1.977% 72-Launch-R-Water
98583枚 1.972% 73-Launch-R-Forest
98243枚 1.965% 74-Launch-R-Light
99190枚 1.984% 75-Launch-R-Dark
98424枚 1.968% 76-Launch-R-Blaze
98602枚 1.972% 77-Launch-R-Water
98745枚 1.975% 78-Launch-R-Forest
99000枚 1.980% 79-Launch-R-Light
98602枚 1.972% 80-Launch-R-Dark
50098枚 1.002% 81-Launch-SR-Blaze
50076枚 1.002% 82-Launch-SR-Water
50150枚 1.003% 83-Launch-SR-Forest
49991枚 1.000% 84-Launch-SR-Light
49916枚 0.998% 85-Launch-SR-Dark
50162枚 1.003% 86-Launch-SR-Blaze
49945枚 0.999% 87-Launch-SR-Water
49999枚 1.000% 88-Launch-SR-Forest
50123枚 1.002% 89-Launch-SR-Light
50073枚 1.001% 90-Launch-SR-Dark
50085枚 1.002% 91-Launch-SR-Blaze
49993枚 1.000% 92-Launch-SR-Water
49973枚 0.999% 93-Launch-SR-Forest
49759枚 0.995% 94-Launch-SR-Light
50003枚 1.000% 95-Launch-SR-Dark
49905枚 0.998% 96-Launch-SR-Blaze
50326枚 1.007% 97-Launch-SR-Water
50241枚 1.005% 98-Launch-SR-Forest
50138枚 1.003% 99-Launch-SR-Light
49657枚 0.993% 100-Launch-SR-Dark
345枚 0.007% 101-Launch-SSR-Blaze
327枚 0.007% 102-Launch-SSR-Water
357枚 0.007% 103-Launch-SSR-Forest
347枚 0.007% 104-Launch-SSR-Light
311枚 0.006% 105-Launch-SSR-Dark
314枚 0.006% 106-Launch-SSR-Blaze
292枚 0.006% 107-Launch-SSR-Water
377枚 0.008% 108-Launch-SSR-Forest
329枚 0.007% 109-Launch-SSR-Light
294枚 0.006% 110-Launch-SSR-Dark
312枚 0.006% 111-Olympus-SSR-Blaze
332枚 0.007% 112-Olympus-SSR-Water
323枚 0.006% 113-Olympus-SSR-Forest
347枚 0.007% 114-Olympus-SSR-Light
343枚 0.007% 115-Olympus-SSR-Dark
378枚 0.008% 116-Japanese-SSR-Blaze
356枚 0.007% 117-Japanese-SSR-Water
309枚 0.006% 118-Japanese-SSR-Forest
340枚 0.007% 119-Japanese-SSR-Light
350枚 0.007% 120-Japanese-SSR-Dark
307枚 0.006% 121-Nordic-SSR-Blaze
328枚 0.007% 122-Nordic-SSR-Water
344枚 0.007% 123-Nordic-SSR-Forest
318枚 0.006% 124-Nordic-SSR-Light
326枚 0.007% 125-Nordic-SSR-Dark
8311枚 0.166% 126-Grimm-SSR-Blaze
8246枚 0.165% 127-Grimm-SSR-Water
8375枚 0.168% 128-Grimm-SSR-Forest
8299枚 0.166% 129-Grimm-SSR-Light
8358枚 0.167% 130-Grimm-SSR-Dark
実際に表記と排出結果が一致しているのが確かめられます。
おわりに
結果は一緒でも実装を工夫することで、サービスが持つ致命的なリスクを減らせます。
プログラマはこういった目に見えない部分こそ、気を配っていきたいですね。
なお本実装は表記と抽選にずれがないというだけで、確率設定自体には無関心です。
したがって確率を予定より大きくor小さく設定してしまった、などのミスは防げません。
それはワークフローや別種の自動化でカバーする必要があります。