pythonで書くガチャ-実践編３・ステップアップガチャの機能追加-

#内容
[前回](https://qiita.com/saib-ryo/items/cb1a178a82fa5070dce3)、ステップアップガチャを作成しました。
今回は、そのステップアップガチャに制御処理を追加して、より実践的な使い方ができるように改修してみます。

**以下のソースを改修して実装しています**
>[pythonで書くガチャ-実践編２・ステップアップガチャの基本-
](https://qiita.com/saib-ryo/items/cb1a178a82fa5070dce3#%E5%AE%9F%E8%A3%85)

##ステップアップガチャの改修
今回の改修として、ステップアップガチャに以下の制御処理を追加します。

- 繰り返し
- リセット

## 繰り返し
[前回](https://qiita.com/saib-ryo/items/cb1a178a82fa5070dce3)作ったステップアップガチャは、最終ステップまで進行したら終了となる仕様でした。
実際のゲームにおいては、最終ステップ後に繰り返して最後のステップが引けるようなガチャが存在します。
それを実現するためには、`繰り返し`の制御を追加する必要があります。
今回は、以下の３種類のステップアップガチャを作る方法を考えてみます。

- step_up_N：ステップアップ基本（最終ステップで終了）
- step_up_S1：ステップアップ繰り返し（最終ステップ後はステップ1に戻る）
- step_up_S2：ステップアップ最終繰り返し（最終ステップを永遠に繰り返す）

#### ガチャ種類設定の追加（繰り返し）
ガチャの引き方設定（gacha_lottery）について、**繰り返しを行うステップ**を定義するために
ガチャ種類設定（gacha_type）というテーブルを作成します。

**gacha_type**

|  id |  kind | rotation_step_no |
|:---:|:----:|:----:|:----:|
|  step_up_N | step_up | 0 |
|  step_up_S1| step_up | 1 |
|  step_up_S2| step_up | 4 |

`rotation_step_no`の項目が、最終ステップ後に移行するステップを示しています。
`0`の場合は、最終ステップで終了という定義になります。

#### ガチャ引き方設定の改修
ガチャの引き方設定（gacha_lottery）の`gacha_type`という項目を、上記で設定した`ガチャ種類設定のID（gacha_type_id）`に置き換えます。

**gacha_lottery**

|  id | gacha_type_id | step_no | item_type | times | rarity | omake_times | omake_rarity| cost |
|:---:|:----:|:----:|:----:|:----:|:----:|:----:|:------:|:------:|
|  step_up_N_1  | step_up_N |  1  |  0  | 1  |  0   |  0    |  0    | 10 |
|  step_up_N_2  | step_up_N |  2  |  0  | 2  |  0   |  1    |  3    | 30 |
|  step_up_N_3  | step_up_N |  3  |  0  | 3  |  0   |  2    |  3    | 50 |
|  step_up_N_4  | step_up_N |  4  |  0  | 4  |  0   |  1    |  4    | 50 |
|  step_up_S1_1  | step_up_S1 |  1  |  0  | 1  |  0   |  0    |  0    | 10 |
|  step_up_S1_2  | step_up_S1 |  2  |  0  | 2  |  0   |  1    |  3    | 30 |
|  step_up_S1_3  | step_up_S1 |  3  |  0  | 1  |  0   |  2    |  3   | 30 |
|  step_up_S1_4  | step_up_S1 |  4  |  0  | 4  |  0   |  1    |  4    | 50 |
|  step_up_S2_1  | step_up_S2 |  1  |  0  | 1  |  0   |  0    |  0    | 10 |
|  step_up_S2_2  | step_up_S2 |  2  |  0  | 2  |  0   |  1    |  3    | 30 |
|  step_up_S2_3  | step_up_S2 |  3  |  0  | 4  |  0   |  1    |  5    | 50 |
|  step_up_S2_4  | step_up_S2 |  4  |  0  | 4  |  0   |  1    |  3    | 50 |

#### ガチャ期間設定の改修
ガチャの引き方設定（gacha_lottery）とガチャ種類設定（gacha_type）に関連を紐付けるように、同様にガチャ期間設定（gacha）の`gacha_type`という項目を`ガチャ種類設定のID（gacha_type_id）`に置き換えます。

**gacha**

|  id | start_time | end_time | gacha_group | gacha_type_id |
|:---:|:--------:|:------:|:------------:|:----:|
|  10  | 2020-05-01 00:00:00 | 2020-05-31 23:59:59 | A | step_up_N     |
|  11  | 2020-06-01 00:00:00 | 2020-06-30 23:59:59 | C | step_up_N     |
|  12  | 2020-05-01 00:00:00 | 2020-07-31 23:59:59 | A | step_up_S1    |
|  13  | 2020-05-01 00:00:00 | 2020-05-31 23:59:59 | A | step_up_S2    |
|  14  | 2020-06-01 00:00:00 | 2020-06-30 23:59:59 | C | step_up_S2    |

<img width="880" alt="gacha_step_p1.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/625647/f9198a34-3e4e-dd50-a404-b668d582b4dd.png">


## リセット

#### ガチャ種類設定の追加（リセット）
運用によっては、あるタイミングで初期状態に戻したい場合もあります。
そこで、ガチャ種類設定（gacha_type）にリセットの定義を追加します。

今回追加するリセットの定義によって、期間によって自動的にリセットされる設定になります。

**reset_type**
定義する*reset_type*は、以下の２つです。

- daily：毎日初回アクセス時に初期状態(ステップ1)に戻る
- monthly:毎月初回アクセス時に初期状態(ステップ1)に戻る

`monthlyについては、実用性は低いですが、敢えて違いを理解するために設定してあります`

**gacha_type**

|  id |  kind | rotation_step_no | reset_type |
|:---:|:----:|:----:|:----:|:----:|
|  step_up_N | step_up | 0 |daily|
|  step_up_S1| step_up | 1 |monthly|
|  step_up_S2| step_up | 4 ||

今回は、ガチャ期間設定（gacha）に沿って、gacha_typeを設定します。
**step_up_S2**は、reset_typeが空なので、リセットの設定はありません。

<img width="881" alt="gacha_step_p0.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/625647/a8cdb70b-036f-29df-bf1a-a73dc8152482.png">


## 実装
実装上の仕様をまとめると以下の通りになります

### 繰り返し（最終ステップまで実行した際）
 - **rotation_step_no**に`0`が設定されている場合
  - ユーザガチャステップ情報の**step_no**をインクリメント（+1）して更新（ガチャ終了状態）
 - **rotation_step_no**に`0`以外が設定されている場合
  - ユーザガチャステップ情報の**step_no**を、**rotation_step_no**にて更新

### リセット
 - **reset_type**に`daily`が設定されている場合
  - ユーザガチャステップ情報の**updated_time**が前日の日付であれば、`step_no=1`で更新

 - **reset_type**に`monthly`が設定されている場合
  - ユーザガチャステップ情報の**updated_time**が前月の日付であれば、`step_no=1`で更新


###ガチャDB情報
構造・データの変更、履歴をリセットしたい場合は、この処理を再度実行してください

```python:gacha_db.py
# -*- 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 = {
        10: {"start_time": "2020-05-01 00:00:00", "end_time": "2020-05-31 23:59:59", "gacha_group": "A",
            "gacha_type_id": "step_up_N"},
        11: {"start_time": "2020-06-01 00:00:00", "end_time": "2020-06-30 23:59:59", "gacha_group": "C",
             "gacha_type_id": "step_up_N"},
        12: {"start_time": "2020-05-01 00:00:00", "end_time": "2020-07-31 23:59:59", "gacha_group": "A",
             "gacha_type_id": "step_up_S1"},
        13: {"start_time": "2020-05-01 00:00:00", "end_time": "2020-05-31 23:59:59", "gacha_group": "A",
             "gacha_type_id": "step_up_S2"},
        14: {"start_time": "2020-06-01 00:00:00", "end_time": "2020-06-30 23:59:59", "gacha_group": "C",
             "gacha_type_id": "step_up_S2"},
    }

    return convert_values(items)

def get_gacha_type():
    items = {
        "step_up_N":  {"rotation_step_no": 0, "kind": "step_up", "reset_type": "daily"},
        "step_up_S1": {"rotation_step_no": 1, "kind": "step_up", "reset_type": "monthly"},
        "step_up_S2": {"rotation_step_no": 4, "kind": "step_up", "reset_type": None},
    }

    return convert_values(items)


def get_gacha_lottery():
    items = {
        "normal_1":  {"gacha_type_id": "normal", "step_no": 0, "item_type": 0, "times": 1, "rarity": 0, "omake_times": 0, "omake_rarity": 0, "cost":10},
        "normal_11":  {"gacha_type_id": "normal", "step_no": 0, "item_type": 0, "times": 10, "rarity": 0, "omake_times": 1, "omake_rarity": 3, "cost":100},
        "fighter":  {"gacha_type_id": "fighter", "step_no": 0, "item_type": 0, "times": 2, "rarity": 0, "omake_times": 0, "omake_rarity": 0, "cost":30},
        "omake_2":  {"gacha_type_id": "omake_2", "step_no": 0, "item_type": 0, "times": 9, "rarity": 2, "omake_times": 2, "omake_rarity": 3, "cost":150},
        "omake_fighter":  {"gacha_type_id": "omake_fighter", "step_no": 0, "item_type": 2, "times": 5, "rarity": 0, "omake_times": 1, "omake_rarity": 3, "cost":100},
        "step_up_N_1": {"gacha_type_id": "step_up_N", "step_no": 1, "item_type": 0, "times": 1, "rarity": 0, "omake_times": 0,
                     "omake_rarity": 0, "cost": 10},
        "step_up_N_2": {"gacha_type_id": "step_up_N", "step_no": 2, "item_type": 0, "times": 2, "rarity": 0, "omake_times": 1,
                      "omake_rarity": 3, "cost": 30},
        "step_up_N_3": {"gacha_type_id": "step_up_N", "step_no": 3, "item_type": 0, "times": 3, "rarity": 0, "omake_times": 2,
                      "omake_rarity": 3, "cost": 50},
        "step_up_N_4": {"gacha_type_id": "step_up_N", "step_no": 4, "item_type": 0, "times": 4, "rarity": 0, "omake_times": 1,
                      "omake_rarity": 4, "cost": 50},
        "step_up_S1_1": {"gacha_type_id": "step_up_S1", "step_no": 1, "item_type": 0, "times": 1, "rarity": 0,
                        "omake_times": 0, "omake_rarity": 0, "cost": 10},
        "step_up_S1_2": {"gacha_type_id": "step_up_S1", "step_no": 2, "item_type": 0, "times": 2, "rarity": 0,
                        "omake_times": 1, "omake_rarity": 3, "cost": 30},
        "step_up_S1_3": {"gacha_type_id": "step_up_S1", "step_no": 3, "item_type": 0, "times": 1, "rarity": 0,
                        "omake_times": 2, "omake_rarity": 3, "cost": 30},
        "step_up_S1_4": {"gacha_type_id": "step_up_S1", "step_no": 4, "item_type": 0, "times": 3, "rarity": 0,
                        "omake_times": 2, "omake_rarity": 4, "cost": 50},
        "step_up_S2_1": {"gacha_type_id": "step_up_S2", "step_no": 1, "item_type": 0, "times": 1, "rarity": 0,
                        "omake_times": 0, "omake_rarity": 0, "cost": 10},
        "step_up_S2_2": {"gacha_type_id": "step_up_S2", "step_no": 2, "item_type": 0, "times": 2, "rarity": 0,
                        "omake_times": 1, "omake_rarity": 3, "cost": 30},
        "step_up_S2_3": {"gacha_type_id": "step_up_S2", "step_no": 3, "item_type": 0, "times": 4, "rarity": 0,
                        "omake_times": 1, "omake_rarity": 5, "cost": 50},
        "step_up_S2_4": {"gacha_type_id": "step_up_S2", "step_no": 4, "item_type": 0, "times": 4, "rarity": 0,
                        "omake_times": 1, "omake_rarity": 3, "cost": 50},
    }

    return convert_values(items)

def get_users():
    items = {
        "u001": {"nick_name": "taro"},
        "u002": {"nick_name": "hana"},
    }

    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()

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

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

    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
          )
        """
    )

    #　追加
    cursor.execute("DROP TABLE IF EXISTS gacha_type")
    cursor.execute(
        """CREATE TABLE gacha_type 
          (id VARCHAR(32) PRIMARY KEY
          ,kind VARCHAR(32)
          ,rotation_step_no INTEGER
          ,reset_type VARCHAR(32)
          )
        """
    )

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

    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
          )
        """
    )

    cursor.execute("DROP TABLE IF EXISTS users")
    cursor.execute(
        """CREATE TABLE users 
          (id VARCHAR(16) PRIMARY KEY
          ,nick_name VARCHAR(64)
          )
        """
    )

    #　改修
    cursor.execute("DROP TABLE IF EXISTS gacha_user_step")
    cursor.execute(
        """CREATE TABLE gacha_user_step 
          (user_id VARCHAR(16) 
          ,gacha_id INTEGER
          ,gacha_type_id VARCHAR(32)
          ,step_no INTEGER DEFAULT 2
          ,updated_time DATETIME          
          ,PRIMARY KEY(user_id,gacha_id,gacha_type_id)
          )
        """
    )


    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_type()
    sql = "insert into {0}({1}) values({2})".format('gacha_type', ','.join(keys), ','.join(['?'] * len(keys)))
    cursor.executemany(sql,values)
    select_sql = "SELECT * FROM gacha_type ORDER BY id"
    result = cursor.execute(select_sql)
    print("===gach_type===")
    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)

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

    con.commit()
    con.close()

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

###ガチャ処理
```python:gacha.py
import random
import sqlite3
from datetime import datetime
from typing import Dict, List, Tuple, Optional

def convert_row2dict(row) -> Dict:
    if row is None:
        return {}
    return {key: row[key] for key in row.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, user_id:str) -> 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_id"])
        gacha_user = get_gacha_user_step(cursor, user_id, gacha_id, info["gacha_type_id"])

        set_list = []
        for lottery_info in lottery_info_list:
            if lottery_info["step_no"] > 0:
                now_step_no = 1
                if len(gacha_user) > 0:
                    now_step_no = gacha_user["step_no"] + 1
                if now_step_no == lottery_info["step_no"]:
                    set_list.append(lottery_info)
            else:
                set_list.append(lottery_info)
        gacha_list[gacha_id]["gacha_lottery_list"] = set_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_type(cursor, gacha_type_id: str) -> Dict:
    select_sql = "SELECT * FROM gacha_type WHERE id = ? ORDER BY id"
    cursor.execute(select_sql, (gacha_type_id,))
    return convert_row2dict(cursor.fetchone())

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,))
    return convert_row2dict(cursor.fetchone())


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

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

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,))
    return {
        row["id"]: convert_row2dict(row)
        for row in rows
    }

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

def get_gacha_info(cursor, gacha_id: int, gacha_lottery_id: str, now_time: int, user_id: str) -> Tuple[Optional[Dict], Optional[Dict]]:
    gacha = get_gacha(cursor, gacha_id, now_time)
    gacha_lottery = get_gacha_lottery(cursor, gacha_lottery_id)

    if len(gacha) == 0:
        print("===実行できないgacha_id:%s===" % (gacha_id))
        return None, None

    if len(gacha_lottery) == 0:
        print("===存在しないgacha_lottery_id:%s===" % (gacha_lottery_id))
        return None, None

    if gacha["gacha_type_id"] != gacha_lottery["gacha_type_id"]:
        print("===gacha_type_idが異なる:%s,%s===" % (gacha["gacha_type_id"],gacha_lottery["gacha_type_id"]))
        return None, None

    # step_upガチャのチェック
    if gacha_lottery["step_no"] > 0:
        # user_stepのresetを行う
        reset_gacha_user_step(cursor, user_id, gacha_id, gacha["gacha_type_id"], now_time)

        max_step_no = get_max_step_no(cursor, gacha["gacha_type_id"])
        gacha_user = get_gacha_user_step(cursor, user_id, gacha_id, gacha["gacha_type_id"])
        step_no = 1
        if len(gacha_user) > 0:
            step_no = gacha_user["step_no"]
        if max_step_no < step_no:
            print("===最大ステップまで実行済みです（最大ステップ:%s、実行しようとしているステップ:%s）===" %(max_step_no,gacha_lottery["step_no"]))
            return None, None

        if gacha_lottery["step_no"] != step_no:
            print("===実行できるステップと異なる（現在のステップ:%s、実行しようとしているステップ:%s）===" %(step_no, gacha_lottery["step_no"]))
            return None, None

    return gacha, gacha_lottery


def get_gacha_user_step(cursor, user_id: str, gacha_id: int, gacha_type_id: str) -> Dict:
    select_sql = "SELECT * FROM gacha_user_step WHERE user_id = ? AND gacha_id = ? AND gacha_type_id = ?"
    cursor.execute(select_sql, (user_id,gacha_id,gacha_type_id))
    row = cursor.fetchone()

    return convert_row2dict(row)

def get_max_step_no(cursor, gacha_type_id: str) -> Dict:
    select_sql = "SELECT MAX(step_no) AS max_step_no FROM gacha_lottery WHERE gacha_type_id = ?"
    cursor.execute(select_sql, (gacha_type_id,))
    row = cursor.fetchone()

    row_dict = convert_row2dict(row)
    return row_dict["max_step_no"]

def insert_gacha_user_step(cursor, step_no: int, user_id: str, gacha_id: int, gacha_type_id: str, update_time: int) -> None:
    dt = datetime.fromtimestamp(update_time)
    keys = ("user_id", "gacha_id", "gacha_type_id", "updated_time", "step_no")
    sql = "insert into {0}({1}) values({2})".format('gacha_user_step', ','.join(keys), ','.join(['?'] * len(keys)))
    cursor.execute(sql, (user_id, gacha_id, gacha_type_id, dt, step_no))

def update_gacha_user_step(cursor, step_no: int, user_id: str, gacha_id: int, gacha_type_id: str, update_time: int) -> None:
    dt = datetime.fromtimestamp(update_time)
    sql = "UPDATE gacha_user_step SET step_no = ?, updated_time = ? WHERE user_id = ? AND gacha_id = ? AND gacha_type_id = ?"
    cursor.execute(sql, (step_no, dt, user_id, gacha_id, gacha_type_id))

def next_gacha_user_step(cursor, user_id: str, gacha_id: int, gacha_type_id: str, update_time: int) -> None:
    row = get_gacha_user_step(cursor, user_id, gacha_id, gacha_type_id)
    if len(row) == 0:
        insert_gacha_user_step(
            cursor,
            rotation_gacha_user_step(cursor, gacha_type_id, 1),
            user_id, gacha_id, gacha_type_id, update_time
        )
    else:
        update_gacha_user_step(
            cursor,
            rotation_gacha_user_step(cursor, gacha_type_id, row["step_no"]),
            user_id, gacha_id, gacha_type_id, update_time
        )

# 繰り返し判定を加味した次のステップ番号
def rotation_gacha_user_step(cursor, gacha_type_id: str, step_no: int) -> int:
    if step_no == get_max_step_no(cursor, gacha_type_id):
        row = get_gacha_type(cursor, gacha_type_id)
        if row["rotation_step_no"] > 0:
            print("check:{0},{1},{2}".format(gacha_type_id, step_no,row["rotation_step_no"]))
            return row["rotation_step_no"]
        else:
            print("check:{0},{1},{2}".format(gacha_type_id, step_no,step_no + 1))
            return step_no + 1
    else:
        print("check:{0},{1},{2}".format(gacha_type_id, step_no, step_no + 1))
        return step_no + 1

# リセット処理
def reset_gacha_user_step(cursor, user_id: str, gacha_id: int, gacha_type_id: str, now_time: int) -> None:
    user_step = get_gacha_user_step(cursor, user_id, gacha_id, gacha_type_id)
    if len(user_step) == 0:
        return

    dt = datetime.fromtimestamp(now_time)
    gacha_type = get_gacha_type(cursor, gacha_type_id)
    if gacha_type["reset_type"] == "daily":
        if dt.day != datetime.strptime(user_step["updated_time"], '%Y-%m-%d %H:%M:%S').day:
            update_gacha_user_step(cursor,1,user_id, gacha_id, gacha_type_id, now_time)
    elif gacha_type["reset_type"] == "monthly":
        if dt.month != datetime.strptime(user_step["updated_time"], '%Y-%m-%d %H:%M:%S').month:
            update_gacha_user_step(cursor,1,user_id, gacha_id, gacha_type_id, now_time)

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

    # 抽選対象リストを抽出
    def get_lots(gacha_group: str, lottery_info: dict) -> Tuple[Dict,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, user_id)
        if gacha_info is None or gacha_lottery_info is None:
            return []

        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"]))

        # ステップアップガチャの場合は実行回数の更新を行う
        if len(ids) > 0 and gacha_lottery_info["step_no"] > 0:
            next_gacha_user_step(cursor, user_id, exec_gacha_id, gacha_info["gacha_type_id"], now_time)

        return ids

    return exec

def test_step(cursor, user_id, now_time, gacha_id, gacha_type_id, step_no_list):
    print("[TEST:{0}]".format(gacha_type_id))

    # 実行可能なgacha_lottery
    available_list = get_available_gacha_info_list(cursor,now_time,user_id)
    for available_gacha_id,available in available_list.items():
        print(available_gacha_id,available)

    func_gacha = set_gacha(cursor, now_time, user_id)
    items = get_items_all(cursor)
    gacha_items = get_gacha_items_all(cursor)

    # ステップアップガチャ
    for step_no in step_no_list:
        gacha_lottery_id = "{0}_{1}".format(gacha_type_id,str(step_no))
        ids = func_gacha(gacha_id,gacha_lottery_id)
        if len(ids) > 0:
            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"]))

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

    # 実際には、認証系の情報からユーザIDを取得することになります
    user_id = "u001"

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

    # step_up_Nのテスト
    test_step(cursor, user_id, now_time, 10, "step_up_N", [1,2,3,4,1,])

    print("\n【日付を進めることによりリセットされて実行可能となることの確認】")
    now_time = int(datetime.strptime("2020-05-02 00:00:00", '%Y-%m-%d %H:%M:%S').timestamp())
    test_step(cursor, user_id, now_time, 10, "step_up_N", [1,2,3,4,])

    # step_up_S1のテスト
    print("\n【step1に戻ることを確認】")
    test_step(cursor, user_id, now_time, 12, "step_up_S1", [1,2,3,4,1,2,])
    print("\n【日付を進めてリセットされることの確認：前月はstep2まで完了していた状態】")
    now_time = int(datetime.strptime("2020-06-01 00:00:00", '%Y-%m-%d %H:%M:%S').timestamp())
    test_step(cursor, user_id, now_time, 12, "step_up_S1", [1,2,3,4,])

    # step_up_S2のテスト
    print("\n【step4で繰り返し実行可能なことを確認】")
    test_step(cursor, user_id, now_time, 14, "step_up_S2", [1,2,3,4,4,])

    con.commit()
    con.close()

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

##実行結果
```
[TEST:step_up_N]
10 {'id': 10, 'start_time': '2020-05-01 00:00:00', 'end_time': '2020-05-31 23:59:59', 'gacha_group': 'A', 'gacha_type_id': 'step_up_N', 'gacha_lottery_list': []}
12 {'id': 12, 'start_time': '2020-05-01 00:00:00', 'end_time': '2020-07-31 23:59:59', 'gacha_group': 'A', 'gacha_type_id': 'step_up_S1', 'gacha_lottery_list': [{'id': 'step_up_S1_2', 'gacha_type_id': 'step_up_S1', 'step_no': 2, 'item_type': 0, 'times': 1, 'rarity': 0, 'omake_times': 1, 'omake_rarity': 3, 'cost': 20}]}
13 {'id': 13, 'start_time': '2020-05-01 00:00:00', 'end_time': '2020-05-31 23:59:59', 'gacha_group': 'A', 'gacha_type_id': 'step_up_S2', 'gacha_lottery_list': [{'id': 'step_up_S2_1', 'gacha_type_id': 'step_up_S2', 'step_no': 1, 'item_type': 0, 'times': 1, 'rarity': 0, 'omake_times': 0, 'omake_rarity': 0, 'cost': 10}]}
==step_up_N_1==:gacha_group:A
check:step_up_N,1,2
ID:9, R, R_魔法使い
==step_up_N_2==:gacha_group:A
check:step_up_N,2,3
ID:10, R, R_神官
ID:9, R, R_魔法使い
ID:4, SSR, SSR_神官
==step_up_N_3==:gacha_group:A
check:step_up_N,3,4
ID:9, R, R_魔法使い
ID:10, R, R_神官
ID:9, R, R_魔法使い
ID:7, SR, SR_神官
ID:5, SR, SR_戦士
==step_up_N_4==:gacha_group:A
check:step_up_N,4,5
ID:9, R, R_魔法使い
ID:9, R, R_魔法使い
ID:10, R, R_神官
ID:5, SR, SR_戦士
ID:1, UR, UR_勇者
===最大ステップまで実行済みです（最大ステップ:4、実行しようとしているステップ:1）===

【日付を進めることによりリセットされて実行可能となることの確認】
[TEST:step_up_N]
10 {'id': 10, 'start_time': '2020-05-01 00:00:00', 'end_time': '2020-05-31 23:59:59', 'gacha_group': 'A', 'gacha_type_id': 'step_up_N', 'gacha_lottery_list': []}
12 {'id': 12, 'start_time': '2020-05-01 00:00:00', 'end_time': '2020-07-31 23:59:59', 'gacha_group': 'A', 'gacha_type_id': 'step_up_S1', 'gacha_lottery_list': [{'id': 'step_up_S1_2', 'gacha_type_id': 'step_up_S1', 'step_no': 2, 'item_type': 0, 'times': 1, 'rarity': 0, 'omake_times': 1, 'omake_rarity': 3, 'cost': 20}]}
13 {'id': 13, 'start_time': '2020-05-01 00:00:00', 'end_time': '2020-05-31 23:59:59', 'gacha_group': 'A', 'gacha_type_id': 'step_up_S2', 'gacha_lottery_list': [{'id': 'step_up_S2_1', 'gacha_type_id': 'step_up_S2', 'step_no': 1, 'item_type': 0, 'times': 1, 'rarity': 0, 'omake_times': 0, 'omake_rarity': 0, 'cost': 10}]}
==step_up_N_1==:gacha_group:A
check:step_up_N,1,2
ID:7, SR, SR_神官
==step_up_N_2==:gacha_group:A
check:step_up_N,2,3
ID:10, R, R_神官
ID:5, SR, SR_戦士
ID:6, SR, SR_魔法使い
==step_up_N_3==:gacha_group:A
check:step_up_N,3,4
ID:10, R, R_神官
ID:8, R, R_戦士
ID:5, SR, SR_戦士
ID:4, SSR, SSR_神官
ID:7, SR, SR_神官
==step_up_N_4==:gacha_group:A
check:step_up_N,4,5
ID:9, R, R_魔法使い
ID:8, R, R_戦士
ID:7, SR, SR_神官
ID:5, SR, SR_戦士
ID:3, SSR, SSR_魔法使い

【step1に戻ることを確認】
[TEST:step_up_S1]
10 {'id': 10, 'start_time': '2020-05-01 00:00:00', 'end_time': '2020-05-31 23:59:59', 'gacha_group': 'A', 'gacha_type_id': 'step_up_N', 'gacha_lottery_list': []}
12 {'id': 12, 'start_time': '2020-05-01 00:00:00', 'end_time': '2020-07-31 23:59:59', 'gacha_group': 'A', 'gacha_type_id': 'step_up_S1', 'gacha_lottery_list': [{'id': 'step_up_S1_2', 'gacha_type_id': 'step_up_S1', 'step_no': 2, 'item_type': 0, 'times': 1, 'rarity': 0, 'omake_times': 1, 'omake_rarity': 3, 'cost': 20}]}
13 {'id': 13, 'start_time': '2020-05-01 00:00:00', 'end_time': '2020-05-31 23:59:59', 'gacha_group': 'A', 'gacha_type_id': 'step_up_S2', 'gacha_lottery_list': [{'id': 'step_up_S2_1', 'gacha_type_id': 'step_up_S2', 'step_no': 1, 'item_type': 0, 'times': 1, 'rarity': 0, 'omake_times': 0, 'omake_rarity': 0, 'cost': 10}]}
==step_up_S1_1==:gacha_group:A
check:step_up_S1,1,2
ID:10, R, R_神官
==step_up_S1_2==:gacha_group:A
check:step_up_S1,2,3
ID:5, SR, SR_戦士
ID:7, SR, SR_神官
==step_up_S1_3==:gacha_group:A
check:step_up_S1,3,4
ID:7, SR, SR_神官
ID:7, SR, SR_神官
ID:7, SR, SR_神官
==step_up_S1_4==:gacha_group:A
check:step_up_S1,4,1
ID:2, SSR, SSR_戦士
ID:10, R, R_神官
ID:7, SR, SR_神官
ID:3, SSR, SSR_魔法使い
ID:1, UR, UR_勇者
==step_up_S1_1==:gacha_group:A
check:step_up_S1,1,2
ID:9, R, R_魔法使い
==step_up_S1_2==:gacha_group:A
check:step_up_S1,2,3
ID:10, R, R_神官
ID:6, SR, SR_魔法使い

【日付を進めてリセットされることの確認：前月はstep2まで完了していた状態】
[TEST:step_up_S1]
11 {'id': 11, 'start_time': '2020-06-01 00:00:00', 'end_time': '2020-06-30 23:59:59', 'gacha_group': 'C', 'gacha_type_id': 'step_up_N', 'gacha_lottery_list': [{'id': 'step_up_N_1', 'gacha_type_id': 'step_up_N', 'step_no': 1, 'item_type': 0, 'times': 1, 'rarity': 0, 'omake_times': 0, 'omake_rarity': 0, 'cost': 10}]}
12 {'id': 12, 'start_time': '2020-05-01 00:00:00', 'end_time': '2020-07-31 23:59:59', 'gacha_group': 'A', 'gacha_type_id': 'step_up_S1', 'gacha_lottery_list': [{'id': 'step_up_S1_4', 'gacha_type_id': 'step_up_S1', 'step_no': 4, 'item_type': 0, 'times': 3, 'rarity': 0, 'omake_times': 2, 'omake_rarity': 4, 'cost': 50}]}
14 {'id': 14, 'start_time': '2020-06-01 00:00:00', 'end_time': '2020-06-30 23:59:59', 'gacha_group': 'C', 'gacha_type_id': 'step_up_S2', 'gacha_lottery_list': []}
==step_up_S1_1==:gacha_group:A
check:step_up_S1,1,2
ID:10, R, R_神官
==step_up_S1_2==:gacha_group:A
check:step_up_S1,2,3
ID:5, SR, SR_戦士
ID:4, SSR, SSR_神官
==step_up_S1_3==:gacha_group:A
check:step_up_S1,3,4
ID:8, R, R_戦士
ID:5, SR, SR_戦士
ID:6, SR, SR_魔法使い
==step_up_S1_4==:gacha_group:A
check:step_up_S1,4,1
ID:6, SR, SR_魔法使い
ID:5, SR, SR_戦士
ID:7, SR, SR_神官
ID:2, SSR, SSR_戦士
ID:3, SSR, SSR_魔法使い

【step4で繰り返し実行可能なことを確認】
[TEST:step_up_S2]
11 {'id': 11, 'start_time': '2020-06-01 00:00:00', 'end_time': '2020-06-30 23:59:59', 'gacha_group': 'C', 'gacha_type_id': 'step_up_N', 'gacha_lottery_list': [{'id': 'step_up_N_1', 'gacha_type_id': 'step_up_N', 'step_no': 1, 'item_type': 0, 'times': 1, 'rarity': 0, 'omake_times': 0, 'omake_rarity': 0, 'cost': 10}]}
12 {'id': 12, 'start_time': '2020-05-01 00:00:00', 'end_time': '2020-07-31 23:59:59', 'gacha_group': 'A', 'gacha_type_id': 'step_up_S1', 'gacha_lottery_list': [{'id': 'step_up_S1_2', 'gacha_type_id': 'step_up_S1', 'step_no': 2, 'item_type': 0, 'times': 1, 'rarity': 0, 'omake_times': 1, 'omake_rarity': 3, 'cost': 20}]}
14 {'id': 14, 'start_time': '2020-06-01 00:00:00', 'end_time': '2020-06-30 23:59:59', 'gacha_group': 'C', 'gacha_type_id': 'step_up_S2', 'gacha_lottery_list': []}
===実行できるステップと異なる（現在のステップ:4、実行しようとしているステップ:1）===
===実行できるステップと異なる（現在のステップ:4、実行しようとしているステップ:2）===
===実行できるステップと異なる（現在のステップ:4、実行しようとしているステップ:3）===
==step_up_S2_4==:gacha_group:C
check:step_up_S2,4,4
ID:24, R, R_戦士
ID:25, R, R_魔法使い
ID:23, SR, SR_神官
ID:25, R, R_魔法使い
ID:23, SR, SR_神官
==step_up_S2_4==:gacha_group:C
check:step_up_S2,4,4
ID:26, R, R_神官
ID:25, R, R_魔法使い
ID:15, UR, UR_戦士
ID:22, SR, SR_魔法使い
ID:20, SSR, SSR_神官
```

## 考察
#### step_up_N（繰り返しなし、毎日リセット）
繰り返しの設定はないため、４回目を実行後に終了していることが確認できます。
そして、日付が変わると再びステップ1から実行可能であることが確認できます。

#### step_up_S1（繰り返しあり、毎月リセット）
繰り返しの設定があるため、４回目を実行後にStep1に戻り、実行可能できることが確認できます。
そして、月が変わるとステップ1に戻され、そこから実行可能であることが確認できます。

#### step_up_S2（繰り返しあり、リセットなし）
繰り返しの設定があるため、４回目を実行後にStep4を繰り返し実行可能できることが確認できます。

### 仕様のメリット
[前回](https://qiita.com/saib-ryo/items/cb1a178a82fa5070dce3)のロジックに、`繰り返し`と`リセット`の制御処理を追加しました。
少し難しくなっていますが、それほど大きな改修をしていません。
（*テストコードをまとめる方が、手間がかかりました。。。*）

今回の機能追加により、`マスタの設定`によりガチャの進行状況の制御が実現可能となりました。
マスタの設定の組み合わせによって、色々なパターンのガチャを作成することが可能です。
運用においては、ユーザへ効果的なアプローチ方法を、仮説、検証を繰り返すことが必要です。
プランナーがマスタの設定で、それが出来るようなロジックにしておくことで、効率的な運用が実現可能となります