pythonで書くガチャ-実践編１-

#内容
これまでガチャ処理のみについて考えてきましたが、実際のゲームにおいて

- ガチャを実行する画面を表示
- ユーザが選択してガチャを実行

以上を考慮したデータ設計、処理に対応できる改修を考えてみます。
また、今回からガチャに関するデータの格納にDBを利用します。

**以下のソースを改修して実装しています**
> [pythonで書くガチャ-期間設定機能の追加-](https://qiita.com/saib-ryo/items/9c76e0d9de454f5f0cec)


##冗長な設定情報をまとめる

####ガチャ情報

|  id | start_time | end_time | gacha_group | gacha_lottery_id |
|:---:|:--------:|:------:|:------------:|:----:|
|  <font color="Blue">1</font>  | 2020-05-01 00:00:00 | 2020-05-31 23:59:59 | A | normal_1       |
|  <font color="Blue">2</font> | 2020-05-01 00:00:00 | 2020-05-31 23:59:59 | A | normal_11      |
|  <font color="Red">6</font>  | 2020-06-01 00:00:00 | 2020-06-30 23:59:59 | <font color="Orange">C</font> | normal_1       |
|  <font color="Red">7</font>  | 2020-06-01 00:00:00 | 2020-06-30 23:59:59 | <font color="Orange">C</font> | normal_11      |
####ガチャ情報の要素

- 実施期間
- 対象ガチャアイテムのグループ
- ガチャの引き方

**ガチャ情報**の役割は、`実施期間`における`対象ガチャアイテムのグループ`を設定し、`ガチャの引き方`に紐付けることです。IDの**1**と**２**（又は**6**と**7**）の違いは`ガチャの引き方`が、単発（1回）、または11連の違いのみです。
冗長な情報の設定は、手間が増え運用時の設定ミスにも繋がります。できれば同じ情報を複数設定することは避けたいので、データ構造の改修を行います。

####ガチャ情報データ構造の変更
**gacha**情報からgacha_lottery_idの代わりに`gacha_type`という項目を追加します。
**gacha_lottery**にも`gacha_type`を追加し、ガチャ情報とガチャ方式定義情報の紐付けを行います

**gacha**

|  id | start_time | end_time | gacha_group | gacha_type |
|:---:|:--------:|:------:|:------------:|:----:|
|  <font color="Blue">1</font>  | 2020-05-01 00:00:00 | 2020-05-31 23:59:59 | A | normal       |
|  <font color="Red">6</font>  | 2020-06-01 00:00:00 | 2020-06-30 23:59:59 | <font color="Orange">C</font> | normal       |
**gacha_lottery**

|  id | gacha_type |item_type | times | rarity | omake_times | omake_rarity| cost |
|:---:|:----:|:----:|:----:|:----:|:----:|:------:|:------:|
|  normal_1  | normal |  0  | 1  |  0   |  0    |  0    | 10 |
|  normal_11 | normal |  0  | 10 |  0   |  1    |  3   | 100 |

実際のゲーム内のガチャ画面において以下のように表示されることをイメージしてください

<img width="304" alt="gacha_2.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/625647/3873c7db-0536-b4df-a962-210f2cce3a81.png">

##実装
今回の実装から各種情報はDBより取得しますので、まずテーブルの作成とデータのセットを行います

###ガチャDB情報（構造、データの変更があれば実行）

```python:gacha_db
# -*- coding: utf-8 -*-
import sqlite3
import random

def get_items():
    items = {
        5101: {"rarity": 5, "item_name": "UR_勇者", "item_type": 1, "hp": 1200},
        4201: {"rarity": 4, "item_name": "SSR_戦士", "item_type": 2, "hp": 1000},
        4301: {"rarity": 4, "item_name": "SSR_魔法使い", "item_type": 3, "hp": 800},
        4401: {"rarity": 4, "item_name": "SSR_神官", "item_type": 4, "hp": 800},
        3201: {"rarity": 3, "item_name": "SR_戦士", "item_type": 2, "hp": 600},
        3301: {"rarity": 3, "item_name": "SR_魔法使い", "item_type": 3, "hp": 500},
        3401: {"rarity": 3, "item_name": "SR_神官", "item_type": 4, "hp": 500},
        2201: {"rarity": 2, "item_name": "R_戦士", "item_type": 2, "hp": 400},
        2301: {"rarity": 2, "item_name": "R_魔法使い", "item_type": 3, "hp": 300},
        2401: {"rarity": 2, "item_name": "R_神官", "item_type": 4, "hp": 300},
        3199: {"rarity": 3, "item_name": "SR_勇者", "item_type": 1, "hp": 600},
        # 以下、追加
        4101: {"rarity": 4, "item_name": "SSR_勇者", "item_type": 1, "hp": 1000},
        5201: {"rarity": 5, "item_name": "UR_戦士", "item_type": 2, "hp": 1300},
        5301: {"rarity": 5, "item_name": "UR_魔法使い", "item_type": 3, "hp": 1000},
    }

    return convert_values(items)

def get_gacha_items():
    items = {
        1:  {"gacha_group": "A", "weight": 3, "item_id": 5101},
        2:  {"gacha_group": "A", "weight": 9, "item_id": 4201},
        3:  {"gacha_group": "A", "weight": 9, "item_id": 4301},
        4:  {"gacha_group": "A", "weight": 9, "item_id": 4401},
        5:  {"gacha_group": "A", "weight": 20, "item_id": 3201},
        6:  {"gacha_group": "A", "weight": 20, "item_id": 3301},
        7:  {"gacha_group": "A", "weight": 20, "item_id": 3401},
        8:  {"gacha_group": "A", "weight": 40, "item_id": 2201},
        9:  {"gacha_group": "A", "weight": 40, "item_id": 2301},
        10: {"gacha_group": "A", "weight": 40, "item_id": 2401},
        11: {"gacha_group": "B", "weight": 15, "item_id": 4201},
        12: {"gacha_group": "B", "weight": 30, "item_id": 3201},
        13: {"gacha_group": "B", "weight": 55, "item_id": 2201},
        # 以下、追加
        14: {"gacha_group": "C", "weight": 1, "item_id": 5101},
        15: {"gacha_group": "C", "weight": 1, "item_id": 5201},
        16: {"gacha_group": "C", "weight": 1, "item_id": 5301},
        17: {"gacha_group": "C", "weight": 9, "item_id": 4101},
        18: {"gacha_group": "C", "weight": 6, "item_id": 4201},
        19: {"gacha_group": "C", "weight": 6, "item_id": 4301},
        20: {"gacha_group": "C", "weight": 6, "item_id": 4401},
        21: {"gacha_group": "C", "weight": 20, "item_id": 3201},
        22: {"gacha_group": "C", "weight": 20, "item_id": 3301},
        23: {"gacha_group": "C", "weight": 20, "item_id": 3401},
        24: {"gacha_group": "C", "weight": 40, "item_id": 2201},
        25: {"gacha_group": "C", "weight": 40, "item_id": 2301},
        26: {"gacha_group": "C", "weight": 40, "item_id": 2401},
    }

    return convert_values(items)

def get_gacha():
    items = {
        1: {"start_time": "2020-05-01 00:00:00", "end_time": "2020-05-31 23:59:59", "gacha_group": "A",
            "gacha_type": "normal"},
        3: {"start_time": "2020-05-25 00:00:00", "end_time": "2020-05-31 23:59:59", "gacha_group": "B",
            "gacha_type": "fighter"},
        4: {"start_time": "2020-06-01 00:00:00", "end_time": "2020-06-04 23:59:59", "gacha_group": "C",
            "gacha_type": "omake_2"},
        5: {"start_time": "2020-05-20 00:00:00", "end_time": "2020-05-31 23:59:59", "gacha_group": "A",
            "gacha_type": "omake_fighter"},
        6: {"start_time": "2020-06-01 00:00:00", "end_time": "2020-06-30 23:59:59", "gacha_group": "C",
            "gacha_type": "normal"},
    }

    return convert_values(items)

def get_gacha_lottery():
    items = {
        "normal_1":  {"gacha_type": "normal", "item_type": 0, "times": 1, "rarity": 0, "omake_times": 0, "omake_rarity": 0, "cost":10},
        "normal_11":  {"gacha_type": "normal", "item_type": 0, "times": 10, "rarity": 0, "omake_times": 1, "omake_rarity": 3, "cost":100},
        "fighter":  {"gacha_type": "fighter", "item_type": 0, "times": 2, "rarity": 0, "omake_times": 0, "omake_rarity": 0, "cost":30},
        "omake_2":  {"gacha_type": "omake_2", "item_type": 0, "times": 9, "rarity": 2, "omake_times": 2, "omake_rarity": 3, "cost":150},
        "omake_fighter":  {"gacha_type": "omake_fighter", "item_type": 2, "times": 5, "rarity": 0, "omake_times": 1, "omake_rarity": 3, "cost":100}
    }

    return convert_values(items)


def convert_values(items: dict):
    values = []
    keys = []
    for id,info in items.items():
        if len(keys) == 0 :
            keys = list(info.keys())
            keys.insert(0,'id')

        value = list(info.values())
        value.insert(0,id)
        values.append(tuple(value))
    return keys,values

def print_rows(rows, keys: list):
    for row in rows:
        result = []
        for key in keys:
            result.append(str(row[key]))
        print(",".join(result))


def main():
    con = sqlite3.connect("data.db")
    con.row_factory = sqlite3.Row
    cursor = con.cursor()

    # gacha
    cursor.execute("DROP TABLE IF EXISTS gacha")

    #id
    #start_time
    #end_time
    #gacha_group
    #gacha_lottery_id
    cursor.execute(
        """CREATE TABLE gacha 
          (id INTEGER PRIMARY KEY AUTOINCREMENT
          ,start_time DATETIME
          ,end_time DATETIME
          ,gacha_group VARCHAR(32)
          ,gacha_type VARCHAR(32)
          )
        """
    )

    #         1:  {"gacha_group": "A", "weight": 3, "item_id": 5101},

    cursor.execute("DROP TABLE IF EXISTS gacha_items")
    cursor.execute(
        """CREATE TABLE gacha_items 
          (id INTEGER PRIMARY KEY AUTOINCREMENT
          ,gacha_group VARCHAR(32)
          ,weight INTEGER
          ,item_id INTEGER
          )
        """
    )

    #  "normal_1":  {"item_type": 0, "times": 1, "rarity": 0, "omake_times": 0, "omake_rarity": 0, "cost":10},

    cursor.execute("DROP TABLE IF EXISTS gacha_lottery")
    cursor.execute(
        """CREATE TABLE gacha_lottery 
          (id VARCHAR(32) PRIMARY KEY
          ,gacha_type VARCHAR(32)
          ,item_type INTEGER
          ,times INTEGER
          ,rarity INTEGER
          ,omake_times INTEGER
          ,omake_rarity INTEGER
          ,cost INTEGER
          )
        """
    )

    #        5101: {"rarity": 5, "item_name": "UR_勇者", "item_type": 1, "hp": 1200},
    cursor.execute("DROP TABLE IF EXISTS items")
    cursor.execute(
        """CREATE TABLE items 
          (id INTEGER PRIMARY KEY AUTOINCREMENT
          ,rarity INTEGER
          ,item_name VARCHAR(64)
          ,item_type INTEGER
          ,hp INTEGER
          )
        """
    )

    keys, values = get_items()
    sql = "insert into {0}({1}) values({2})".format('items', ','.join(keys), ','.join(['?'] * len(keys)))
    cursor.executemany(sql,values)
    select_sql = "SELECT * FROM items ORDER BY id"
    result = cursor.execute(select_sql)
    print("===items===")
    print_rows(result, keys)

    keys, values = get_gacha_items()
    sql = "insert into {0}({1}) values({2})".format('gacha_items', ','.join(keys), ','.join(['?'] * len(keys)))
    cursor.executemany(sql,values)
    select_sql = "SELECT * FROM gacha_items ORDER BY id"
    result = cursor.execute(select_sql)
    print("===gacha_items===")
    print_rows(result, keys)


    keys, values = get_gacha()
    sql = "insert into {0}({1}) values({2})".format('gacha', ','.join(keys), ','.join(['?'] * len(keys)))
    cursor.executemany(sql,values)
    select_sql = "SELECT * FROM gacha ORDER BY id"
    result = cursor.execute(select_sql)
    print("===gacha===")
    print_rows(result, keys)

    keys, values = get_gacha_lottery()
    sql = "insert into {0}({1}) values({2})".format('gacha_lottery', ','.join(keys), ','.join(['?'] * len(keys)))
    cursor.executemany(sql,values)
    select_sql = "SELECT * FROM gacha_lottery ORDER BY id"
    result = cursor.execute(select_sql)
    print("===gacha_lottery===")
    print_rows(result, keys)


    con.commit()
    con.close()

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

###ガチャ処理
```python:gacha.py
import random
import sqlite3
from datetime import datetime

def convert_row2dict(row) -> dict:
    keys = row.keys()
    return {key: row[key] for key in keys}


def gacha(lots, times: int=1) -> list:
    return random.choices(tuple(lots), weights=lots.values(), k=times)

def get_rarity_name(rarity: int) -> str:
    rarity_names = {5: "UR", 4: "SSR", 3: "SR", 2: "R", 1: "N"}
    return rarity_names[rarity]

# 実行可能ガチャ情報リスト
def get_gacha_list(cursor, now_time: int) -> dict:
    select_sql = "SELECT * FROM gacha ORDER BY id"
    rows = cursor.execute(select_sql)
    results = {}
    for row in rows:
        start_time = int(datetime.strptime(row["start_time"], '%Y-%m-%d %H:%M:%S').timestamp())
        end_time = int(datetime.strptime(row["end_time"], '%Y-%m-%d %H:%M:%S').timestamp())
        # 日時の範囲で対象ガチャ情報を絞り込みます
        if start_time <= now_time <= end_time:
            results[row["id"]] = convert_row2dict(row)

    return results

# 実行可能ガチャ情報リスト（gacha_lottery情報含む）
def get_available_gacha_info_list(cursor, now_time: int) -> dict:
    gacha_list = get_gacha_list(cursor, now_time)
    for gacha_id, info in gacha_list.items():
        lottery_info_list = get_gacha_lottery_by_type(cursor, info["gacha_type"])
        gacha_list[gacha_id]["gacha_lottery_list"] = lottery_info_list
    return gacha_list

def get_gacha(cursor, gacha_id: int, now_time: int) -> dict:
    select_sql = "SELECT * FROM gacha WHERE id = ? ORDER BY id"
    cursor.execute(select_sql, (gacha_id,))
    row = cursor.fetchone()
    start_time = int(datetime.strptime(row["start_time"], '%Y-%m-%d %H:%M:%S').timestamp())
    end_time = int(datetime.strptime(row["end_time"], '%Y-%m-%d %H:%M:%S').timestamp())
    # 日時の範囲で対象ガチャ情報を絞り込みます
    if start_time <= now_time <= end_time:
        return convert_row2dict(row)

    return {}


def get_gacha_lottery(cursor, gacha_lottery_id: str) -> dict:
    select_sql = "SELECT * FROM gacha_lottery WHERE id = ? ORDER BY id"
    cursor.execute(select_sql, (gacha_lottery_id,))
    row = cursor.fetchone()
    return convert_row2dict(row)


def get_gacha_lottery_by_type(cursor, gacha_type: str) -> list:
    select_sql = "SELECT * FROM gacha_lottery WHERE gacha_type = ? ORDER BY id"
    rows = cursor.execute(select_sql, (gacha_type,))
    results = []
    for row in rows:
        row_dict = convert_row2dict(row)
        results.append(row_dict)
    return results


def get_items_all(cursor) -> dict:
    select_sql = "SELECT * FROM items ORDER BY id"
    rows = cursor.execute(select_sql)
    results = {}
    for row in rows:
        row_dict = convert_row2dict(row)
        results[row["id"]] = row_dict
    return results


def get_gacha_items(cursor, gacha_group: str) -> dict:
    select_sql = "SELECT * FROM gacha_items WHERE gacha_group = ? ORDER BY id"
    rows = cursor.execute(select_sql, (gacha_group,))
    results = {}
    for row in rows:
        row_dict = convert_row2dict(row)
        results[row["id"]] = row_dict
    return results


def get_gacha_items_all(cursor) -> dict:
    select_sql = "SELECT * FROM gacha_items ORDER BY id"
    rows = cursor.execute(select_sql)
    results = {}
    for row in rows:
        row_dict = convert_row2dict(row)
        results[row["id"]] = row_dict
    return results



def get_gacha_info(cursor, gacha_id: int, gacha_lottery_id: str, now_time: int):
    gacha = get_gacha(cursor, gacha_id, now_time)
    gacha_lottery = get_gacha_lottery(cursor, gacha_lottery_id)

    if gacha["gacha_type"] != gacha_lottery["gacha_type"]:
        return None, None

    return gacha, gacha_lottery


def set_gacha(cursor, now_time: int):
    cursor = cursor
    now_time = now_time
    items = get_items_all(cursor)

    # 抽選対象リストを抽出
    def get_lots(gacha_group: str, lottery_info: dict):
        gacha_items = get_gacha_items(cursor, gacha_group)
        dic_gacha_items = {}
        for gacha_item_id, gacha_item in gacha_items.items():
            gacha_item["item_info"] = items[gacha_item["item_id"]]
            dic_gacha_items[gacha_item_id] = gacha_item

        lots = {}
        omake_lots = {}
        for id, info in dic_gacha_items.items():
            if lottery_info["item_type"] and lottery_info["item_type"] != info["item_info"]["item_type"]:
                continue

            if not(lottery_info["rarity"]) or lottery_info["rarity"] <= info["item_info"]["rarity"]:
                lots[id] = info["weight"]

            if lottery_info["omake_times"]:
                if not(lottery_info["omake_rarity"]) or lottery_info["omake_rarity"] <= info["item_info"]["rarity"]:
                    omake_lots[id] = info["weight"]

        return lots, omake_lots

    # ガチャ実行
    def exec(exec_gacha_id: int, exec_gacha_lottery_id: str) -> list:
        gacha_info, gacha_lottery_info = get_gacha_info(cursor, exec_gacha_id, exec_gacha_lottery_id, now_time)

        print("==%s==:gacha_group:%s" % (gacha_lottery_info["id"], gacha_info["gacha_group"]))
        lots, omake_lots = get_lots(gacha_info["gacha_group"], gacha_lottery_info)
        ids = gacha(lots, gacha_lottery_info["times"])
        if len(omake_lots) > 0:
            ids.extend(gacha(omake_lots, gacha_lottery_info["omake_times"]))

        return ids

    return exec


def main():
    con = sqlite3.connect("data.db")
    con.row_factory = sqlite3.Row
    cursor = con.cursor()


    # 動作確認のため、ガチャ実行日時を指定
    now_time = int(datetime.strptime("2020-05-01 00:00:00", '%Y-%m-%d %H:%M:%S').timestamp())

    # 初期化（実行日時、アイテム等をセット）
    func_gacha = set_gacha(cursor, now_time)

    items = get_items_all(cursor)
    gacha_items = get_gacha_items_all(cursor)
    # 単発ガチャを実行
    # gacha_idとgacha_lottery_idが実行時に渡される
    ids = func_gacha(1,"normal_1")
    for id in ids:
        item_info = items[gacha_items[id]["item_id"]]
        print("ID:%d, %s, %s" % (id, get_rarity_name(item_info["rarity"]), item_info["item_name"]))

    # 11連ガチャを実行
    # gacha_idとgacha_lottery_idが実行時に渡される
    ids = func_gacha(1,"normal_11")
    for id in ids:
        item_info = items[gacha_items[id]["item_id"]]
        print("ID:%d, %s, %s" % (id, get_rarity_name(item_info["rarity"]), item_info["item_name"]))

    #con.commit()
    con.close()


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

###実行結果
```
==normal_1==:gacha_group:A
ID:8, R, R_戦士
==normal_11==:gacha_group:A
ID:5, SR, SR_戦士
ID:8, R, R_戦士
ID:3, SSR, SSR_魔法使い
ID:10, R, R_神官
ID:9, R, R_魔法使い
ID:9, R, R_魔法使い
ID:8, R, R_戦士
ID:1, UR, UR_勇者
ID:10, R, R_神官
ID:10, R, R_神官
ID:7, SR, SR_神官
```

##補足
ガチャの実行時に**gacha_id**と**gacha_lottery_id**を引数として渡している事に疑問を持たれた方もいるかと思います。ガチャを実行するのに必要なのは**gacha_lottery_id**で、紐付けられたgacha_typeで、その処理が行われる日時に該当する**gacha_id**を取得すれば、ガチャ処理としては問題ありません。
しかし、ガチャの実施期間の`切り替わるタイミング`でガチャを実行した場合、ユーザは自分が引きたい対象アイテムとは、異なる対象アイテムのガチャを実行することになるかもしれません。

####例
*ユーザは5/31 23:59:59に画面に表示されていたガチャを実行、しかし処理のタイミングで6/1に変わり、ガチャ対象アイテムが**A**から<font color="Orange">C</font>に変わってしまった*

このような事を避けるために、ガチャ実行時に**gacha_id**も引数として渡して、もしその**gacha_id**が実施期間対象外であれば、そのガチャは終了しましたということでユーザに通知して、ガチャ画面を再度表示します。
ガチャは、課金が絡むことを前提とした処理のため、厳密にユーザの意図と異なる処理が行われないようにしなければなりません。

今回のソースでは、若干エラー処理を省いていますので、アプリケーションとして必要なエラー処理を考え実装してください