Pythonで書くガチャ-BOXガチャ-

#内容
BOXガチャを作成してみます。
下記のソースを流用します。
（[Pythonで書くガチャ-データ設計-](https://qiita.com/saib-ryo/items/1309c4c94d9f6629ba6d)）

##BOXガチャとは
抽選箱（BOX）の中に、決められた枚数のくじが入っていて、**全て引ききる**とガチャが終了となる仕組みです。（自動的に下位レアリティのくじを補充するようなBOXガチャはインチキですのでご注意ください。大炎上します）

##BOXガチャの確率
抽選においてアイテム毎の重みの設定はありません。
一つのアイテムを引く確率は、BOX内にある全体の枚数（ｎ）を母数とした確率になります

```math
  p = \frac{1}{n}
```
1枚引いた後の確率は以下のようになります

```math
  p = \frac{1}{n-1}
```

##BOXガチャの実装
```python:gacha_box.py
import random

#　BOXの状態を保持するためクロージャを使います
def gacha_box(gacha_items: dict):
    # IDを抽出
    # weight（重み）は使用しません
    items = [key for key in gacha_items.keys()]

    def lottery(times: int=1):
        # 抽選数が残数より多い場合には残数に合わせる
        if len(items) < times:
            times = len(items)
        # 重複しない抽選
        ids =  random.sample(items, k=times)
        # アイテムから引いたIDを削除
        for key in ids:
            items.remove(key)
        return ids

    return lottery

def gacha(gacha_items: dict, times: int=1) -> list:
    # 抽選に必要な、IDと重みの辞書を作成
    lots = {key: info["weight"] for key, info in gacha_items.items()}
    return random.choices(tuple(lots), weights=lots.values(), k=times)

def main():
    # ガチャアイテム情報
    gacha_items = {
        1: {"weight": 1, "rarity": 5, "item_name": "UR_HOGE"},
        2: {"weight": 1, "rarity": 5, "item_name": "UR_FUGA"},
        3: {"weight": 9, "rarity": 4, "item_name": "SSR_HOGE"},
        4: {"weight": 9, "rarity": 4, "item_name": "SSR_FUGA"},
        5: {"weight": 20, "rarity": 3, "item_name": "SR_HOGE"},
        6: {"weight": 20, "rarity": 3, "item_name": "SR_FUGA"},
        7: {"weight": 30, "rarity": 2, "item_name": "R_HOGE"},
        8: {"weight": 30, "rarity": 2, "item_name": "R_FUGA"},
        9: {"weight": 40, "rarity": 1, "item_name": "N_HOGE"},
        10: {"weight": 40, "rarity": 1, "item_name": "N_FUGA"}
    }

    # レアリティ名
    rarity_names = {5: "UR", 4: "SSR", 3: "SR", 2: "R", 1: "N"}

    # 抽選回数
    times = 10

    # 抽選してidのリストを取得
    ids = gacha(gacha_items, times)

    # 結果出力
    print("通常ガチャ")
    for id in ids:
        print("ID:%d, %s, %s" % (id, rarity_names[gacha_items[id]["rarity"]], gacha_items[id]["item_name"]))

    print("=======")
    # lottery関数をfuncに格納
    func = gacha_box(gacha_items)
    times = 3
    box_ids = func(times)
    print("BOXガチャ：1回目, 抽選設定数:%d, 抽選結果数:%d" %(times,len(box_ids)))
    for id in box_ids:
        print("ID:%d, %s, %s" % (id, rarity_names[gacha_items[id]["rarity"]], gacha_items[id]["item_name"]))

    times = 8
    box_ids = func(times)
    print("BOXガチャ：2回目, 抽選設定数:%d, 抽選結果数:%d" %(times,len(box_ids)))
    for id in box_ids:
        print("ID:%d, %s, %s" % (id, rarity_names[gacha_items[id]["rarity"]], gacha_items[id]["item_name"]))

if __name__ == '__main__':
    main()
```

##実行結果
```
通常ガチャ
ID:9, N, N_HOGE
ID:4, SSR, SSR_FUGA
ID:3, SSR, SSR_HOGE
ID:10, N, N_FUGA
ID:9, N, N_HOGE
ID:4, SSR, SSR_FUGA
ID:7, R, R_HOGE
ID:5, SR, SR_HOGE
ID:9, N, N_HOGE
ID:9, N, N_HOGE
=======
BOXガチャ：1回目, 抽選設定数:3, 抽選結果数:3
ID:2, UR, UR_FUGA
ID:8, R, R_FUGA
ID:4, SSR, SSR_FUGA
BOXガチャ：2回目, 抽選設定数:8, 抽選結果数:7
ID:6, SR, SR_FUGA
ID:1, UR, UR_HOGE
ID:5, SR, SR_HOGE
ID:9, N, N_HOGE
ID:10, N, N_FUGA
ID:3, SSR, SSR_HOGE
ID:7, R, R_HOGE
```

##考察
BOXガチャの実行結果は、通常ガチャと異なり、1回目、2回目を通じてIDの重複がありません。
また、2回目は抽選設定数と抽選結果数が異なる場合の制御が行われ、BOX内の残数以内で抽選されていることが分かります。
実際にはBOX内の残数をガチャ実行前にチェックして、残数以上のガチャが実行できないように制御するのが正しい方法です。
gacha_box関数の戻り値に、BOX内の残数を取得する関数を付加すると良いでしょう。

```python
#　BOXの状態を保持するためクロージャを使います
def gacha_box(gacha_items: dict):
    # IDを抽出
    # weight（重み）は使用しません
    items = [key for key in gacha_items.keys()]

    def lottery(times: int=1):
        # 抽選数が残数より多い場合には残数に合わせる
        if len(items) < times:
            times = len(items)
        # 重複しない抽選
        ids =  random.sample(items, k=times)
        # アイテムから引いたIDを削除
        for key in ids:
            items.remove(key)
        return ids

    # BOX内の残数を取得
    def remain():
        return len(items)

    return lottery, remain
```
```python
   func, remain = gacha_box(gacha_items)
   times = 10
   if remain() < times:
       print("BOX残数エラー")
       return False
   box_ids = func(times)
```