ガチャ確率表記と抽選ロジックの不一致を防ぐ実装

## はじめに

昨今ソーシャルゲームを中心としてガチャ確率表記は必須です。
そこで気を付けたいのが抽選ロジックとの不一致です。

先にありがちな、悪い例を上げます。
（疑似コード）

```py
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#で同じような実装を書いてました）
（なお乱数の質に関しては本題ではないので、ここでは無視してください）

## ソースコード

順にコピペすれば動きます。

### カードが持つ要素

```py
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() #フェス限定
```

### カードリストの操作

```py
#要素に注目してカードリストを辞書化する
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]
```

### 整数化のための操作

```py
#最小公倍数
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()}
```

少数の発生はマスター次第なので、適宜計算結果が整数であることを保証する必要があります。
（最終的に欲しいのは比率なので、どの時点で少数が発生しようと整数に直すことができます）

### 抽選箱の作成

```py
#排出率に従って抽選箱を作成する
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で割るのは封入数を最小化するためです。なくても成り立ちます）

### 確率アップ処理

```py
#特定レアリティの排出率を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をアップ後の排出率で設定するなら不要です。

### テストデータ

```py
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以上確定のガチャ。

### 確率表記と排出テスト

```py
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小さく設定してしまった、などのミスは防げません。
それはワークフローや別種の自動化でカバーする必要があります。