PythonでDiscordのTRPG用のダイスボットを自作してみた

## 始まり
始めに断っておきますが、私はTRPGもDiscordもにわか勢です。

最近、身内でTRPG(主にCoC)をぼちぼちやるのですが、毎回ダイス振って判定するのってメンドくさいよねって話になりました。
セッション環境はdiscordを使い始めたので、discord上で動くbotにしようということになりました。正直なところ、既に先人のお方が作られた素晴らしいものがあるので「[discord-bcdicebot](https://shunshun94.github.io/discord-bcdicebot/index.html)使えばよいのでは?」というのが賢い選択だと思います。
ですが、ポンコツの私には使い方がわからなかったので、勉強も兼ねて友人に手伝ってもらいながら作ってみました。

Python + Googleスプレッドシート (+ Heroku)で実現しています。
ソースコード全体は[GitHub](https://github.com/Sashimimochi/discord_dice_bot)に晒しておきます。

## PythonからGoogleスプレッドシートにアクセスする
ダイスを振るだけなら不要なのですが、技能の成否判定も自動でしたいのでキャラクターシートをGoogleスプレッドシートに作って判定することにしました。
Googleスプレッドシートの操作方法はこちらを参考にしました。

- [Google スプレッドシートをpythonで操作する](https://qiita.com/Hidekazu-Karino/items/5201fce7249693357602)

こちらを参考にOAuth用のクライアントIDを作成してjsonファイルをダウンロードするところまで進めます。
セキュリティを度外視するならGoogle Apps ScriptでHTTPリクエストを受け付けるという方法もあるかとは思います。

スプレッドシートの仕様は適当でこんな感じです。

|技能|合計値|純減|初期値|職業P|興味P|
|:--|:--|:--|:--|:--|:--|
|HP|9|0|9|0|0|
|MP|7|0|7|0|0|
|SAN|60|0|60|0|0|
|db|1d4|0|1d4|0|0|
|こぶし|50|0|50|0|0|
|図書館|50|0|25|25|0|

初期値の決め方は下記を参照しました。

- [クトゥルフＴＲＰＧ技能一覧（かんたん解説](https://www.kya.site/entry/2018/06/26/%E3%82%AF%E3%83%88%E3%82%A5%E3%83%95%EF%BC%B4%EF%BC%B2%EF%BC%B0%EF%BC%A7%E6%8A%80%E8%83%BD%E4%B8%80%E8%A6%A7%EF%BC%88%E3%81%8B%E3%82%93%E3%81%9F%E3%82%93%E8%A7%A3%E8%AA%AC)
- [キャラクター作成ルール](http://www.geocities.jp/fematerials/CoC/01_CharacterMaking.html)

この中で使うのは**技能**と**合計値**のカラムです。これを以下のような感じでpythonから取得します。

```python
def get_gs():
    scopes = ['https://www.googleapis.com/auth/spreadsheets']
    json_file = './hoge.json'#OAuth用クライアントIDの作成でダウンロードしたjsonファイル
    credentials = ServiceAccountCredentials.from_json_keyfile_name(json_file, scopes=scopes)
    http_auth = credentials.authorize(Http())

    # スプレッドシート用クライアントの準備
    doc_id = 'doc_id'#これはスプレッドシートのURLのうちhttps://docs.google.com/spreadsheets/d/以下の部分です
    gs = gspread.authorize(credentials)
    gfile   = gs.open_by_key(doc_id) #読み書きするgoogle spreadsheet
    return gfile
```
ユーザーごとにでシートを切り替えられるようにします。シート名はなんでも良いのですが、私はdiscordから自動でシートを切り替えられるようにdiscordのIDをシート名にしています。

```python
def get_charactor(sheet_name):

    gfile = get_gs()

    worksheet = gfile.worksheet(sheet_name)

    charactor = {}
    #技能名カラム
	cell_keys = worksheet.col_values(1)
    #合計値カラム
	cell_values = worksheet.col_values(2)
    for k,v in zip(cell_keys, cell_values):
        charactor[k] = v
    return charactor
```

ここまででスプレッドシートの設定は終了です。

## Discord側のBOTを作る

次に[Discord](https://discordapp.com/)の設定をしていきます。

### Bot作成

DiscordのBOTの作り方はこちらを参考にさせていただきました

- [DiscordでBOT作成メモ w/ discord.py](https://blog.cover1sea.net/prg/2120/)
- [PythonでDiscord botを作る 【discord.py解説】](https://graffitinote.hatenadiary.jp/entry/2017/12/26/004657)
- [DiscordのBotをPythonで作ってみた](https://qiita.com/penguin-knight/items/3f245bf0b52f2f25845f)
- [Pythonで実用Discord bot(discord.py解説)](https://qiita.com/1ntegrale9/items/9d570ef8175cf178468f)


### メッセージを取る

discordのメッセージ欄に特定の入力があったらダイスを振るようにします。入力形式は以下の仕様にします。ダイスを振るトリガーは`dice`にします。

```Console
dice 1d100 技能名
```
これで受け取ってメッセージを返す待機botの処理を実装します。

```python
client = discord.Client()
client_id = conf['client_id']

@client.event
async def on_ready():
    print('Logged in')
    print('-----')

@client.event
async def on_message(message):
    # 開始ワード
    if message.content.startswith('dice'):
        # 送り主がBotではないか
        if client.user != message.author:
            info = parse('dice {}d{} {}', message.content)
            if info:
                if info[1].isdecimal() and info[0].isdecimal():
                    dice_num = int(info[0])
                    dice_size = int(info[1])
                    key = info[2]
                    # メッセージを書きます
                    m = message.author.name + ' '
                    if key == '一時的狂気':
                        m = temp_madness()
                    elif key == '不定の狂気':
                        m = ind_madness()
                    elif key == 'dice':
                        m = simple_dice(dice_size, dice_num)
                    else:
                        chara = get_charactor(str(message.author))
                        msg, result = judge(chara, key, dice_size, dice_num)
                        m += msg
                        if result:
                            d = damage(chara, key)
                        else:
                            d = None
                        if d is not None:
                            m += '\nダメージ: ' + str(np.sum(d)) + ' = ' + str(d)
                    # メッセージが送られてきたチャンネルへメッセージを送ります
                    await client.send_message(message.channel, m)

client.run(client_id)
```

### ダイス
ダイス振る部分を実装します。単純に入力したダイスを振るものと成否判定をするものの2種類用意します。
まず、`1〜dice_size`までの一様整数乱数を1つ生成します。

```python
def dice(dice_size):
    num = np.random.randint(1, int(dice_size))
    return num
```

単純にダイスを振る場合は次のようにしています。上記のダイスを`dice_num`回分振ります。`2d6`なら`1d6`のダイスを2回振っています。あとでメッセージ表示の際に個別のダイス結果も見たいのでダイス結果は`numpy.array`にしています。合計値は`np.sum`で計算します。`msg`はdiscordに返すメッセージです。

```python
def simple_dice(dice_size, dice_num):
    dice_val = np.array([], dtype=np.int64)
    for i in range(dice_num):
        dice_val = np.append(dice_val, dice(dice_size))
    msg = 'dice: ' + str(np.sum(dice_val)) + ' = ' + str(dice_val)
    return msg
```
discord上ではこんな感じになります。
![daa0ec9db009ee3a418450bc5e781ead.jpg](https://qiita-image-store.s3.amazonaws.com/0/199063/c829e901-872a-6157-13a6-e6263ce8041e.jpeg)

成否判定をするときも`simple_dice`をベースに実装します。

### 成否判定
スプレッドシートから引っ張ってきた情報を参照して成否判定をさせます。
取得したスプレッドシートの情報は辞書型にして持たせておきます。

```python
charactor = {
	'HP': 9,
	'MP': 7,
	'SAN': 60,
	'こぶし': 50,
	'図書館': 50
}
```
と言っても、インスタンス生成とかしているわけではないので、判定のたびにスプレッドシートからデータを参照しているので、たぶん非効率的です。

処理の流れは

1. 入力メッセージからダイスのサイズと数を取得する
1. ダイスを振ってダイス値を取得する
1. 技能値をダイス値を比較する

といった感じです。ちなみに卓ルールで5以下でクリティカル、96以上でファンブルにしています。`return`のbool値はダメージ判定時に使用します。

```python
def judge(charactor, key, dice_size, dice_num):
    dice_val = np.array([], dtype=np.int64)
    for i in range(dice_num):
        dice_val = np.append(dice_val, dice(dice_size))
    if int(charactor[key]) >= np.sum(dice_val):
        msg = key + ' ' + str(charactor[key]) + ' >= ' + str(np.sum(dice_val)) + ' = ' + str(dice_val)
        if np.sum(dice_val) <= 5:
            msg += ' 【クリティカル】'
        msg += ' Success'
        return msg, True
    else:
        msg = key + ' ' + str(charactor[key]) + ' < ' + str(np.sum(dice_val)) + ' = ' + str(dice_val)
        if np.sum(dice_val) >= 96:
            msg += ' 【ファンブル】'
        msg += ' Fail'
        return msg, False
```
discord上ではこのように見えます。
![0c4638e5b209ab36f55615788c200e2c.jpg](https://qiita-image-store.s3.amazonaws.com/0/199063/eab7db76-3050-102f-74e4-3dc5f16ce8a0.jpeg)

### ダメージ判定
特定の`技能名`で技能判定が成功した際に自動でダメージロールを振るようにしました。トリガーとなる`技能名`はあらかじめダメージがわかっている**こぶし**,**頭突き**,**キック**だけに絞って実装します。マーシャルアーツは考慮していません。
ダメージボーナスがあればマイナスも含めて追加しています。

```python
def damage(charactor, key):
    d = np.array([], dtype=np.int64)
    if key == 'こぶし':
        d = np.append(d, dice(3))
    elif key == '頭突き':
        d = np.append(d, dice(4))
    elif key == 'キック':
        d = np.append(d, dice(6))
    else:
        return None

    if 'd' in charactor['db']:
        result = parse('{}d{}', charactor['db'])
        dice_size = int(result[1])
        dice_num = int(result[0])
        for i in range(np.abs(dice_num)):
            if dice_num < 0:
                d = np.append(d, -dice(dice_size))
            else:
                d = np.append(d, dice(dice_size))
    return d
```

- 成功  
![0f66094ef3ff2cfbb2c6899fa6dbc468.jpg](https://qiita-image-store.s3.amazonaws.com/0/199063/8a146206-1058-9da6-b563-e209979c6dc4.jpeg)
- 失敗  
![f3519de16b5129f665654e4e0334d1b7.jpg](https://qiita-image-store.s3.amazonaws.com/0/199063/d0e727fb-5758-04a3-a6d8-c4b854b1d0a0.jpeg)
- ダメージボーナスあり  
![5c9890fa428b2d87386954e0c80a6fa5.jpg](https://qiita-image-store.s3.amazonaws.com/0/199063/5b40f42a-4aec-7bda-3d52-1ae01ea7ef52.jpeg)
- ダメージボーナスあり(マイナス)  
![720e61ae3edcb3ae1cb08a82a5eec24a.jpg](https://qiita-image-store.s3.amazonaws.com/0/199063/c09d8e6a-30f9-89a4-4c88-a2c24f9b5e2e.jpeg)

### 狂気表
discord上で次のように入力した場合に狂気票を振るようにします。

```
dice 1d10 狂気の種類
```
`狂気の種類`は`一時的発狂`と`不定の狂気`の2種類が振れます。`1d10`はフォーマットの統一の為につけていますが、実質使っていません。
一瞬、狂気表もスプレッドシートに書こうかと思いましたが、処理速度をあげる為にハードコーディングしてます~~(正直、面倒臭かったので)~~。

```python
def temp_madness():
    roll = {}
    roll[1] = '鸚鵡返し（誰かの動作・発言を真似することしか出来なくなる）'
	#(中略)
    roll[20] = '過信（自分を全能と信じて、どんなことでもしてしまう）'
    msg = roll[dice(20)]
    msg += '\n一時的狂気(' + str(dice(10)+4) + 'ラウンドまたは' + str(dice(6)*10+30) + '分)'
    return msg

def ind_madness():
    roll = {}
    roll[1] = '失語症（言葉を使う技能が使えなくなる）'
	#(中略)
    roll[10] = '殺人癖（誰彼構わず殺そうとする） '
    msg = roll[dice(10)]
    msg += '\n不定の狂気(' + str(dice(10)*10) + '時間)'
    return msg
```

- 一時的狂気  
![1c237209fa36504fbeda0ac9d5a32475.jpg](https://qiita-image-store.s3.amazonaws.com/0/199063/0d36b1e1-45ed-2f15-de75-7ed2104b2dba.jpeg)
- 不定の狂気  
![eebbceb517ec43a602ee1a6e9de05c8c.jpg](https://qiita-image-store.s3.amazonaws.com/0/199063/7afd15d2-2c4f-f9f8-da67-1e71850ffe0f.jpeg)


## Herokuにデプロイする
そのままではローカルでbotを起動している時しか使えないので不便です。そこでサーバーを立てて常時使えるようにします。今回は[Heroku](https://jp.heroku.com/)を使います。

### Procfile
まずは、Heroku上で動かす実行スクリプトを作成します。今回は`trpg_bot.py`を実行するだけなので

```
woker: python trpg_bot.py
```
とします。
### requirements.txt
`requirements.txt`にpythonファイル内で使っているライブラリを記入します。今回は以下のライブラリを使用しています。

```
oauth2client
httplib2
gspread
discord
parse
numpy
```
### デプロイ
基本的にはHerokuのアカウントを作ってCreate New AppしてHeroku Gitにしたがって進めれば良いです。

```Console
# Herokuにログインする
$ heroku login
# リポジトリをクローンする
$ heroku git:clone -a trpg_dice_bot
$ cd trpg_dice_bot
```

cloneしたリポジトリに作成したアプリケーションなどを格納します。

- trpg_bot.py #メインアプリケーション
- config.json #設定ファイル
- requirements.txt # さっき作ったやつ
- Procfile #さっき作ったやつ
- oauth.json #Googleスプレッドシートを作った時にダウンロードしたoauthのjsonファイルです

```
$ git add .
$ git commit -am "make it better"
$ git push heroku master
```
無事デプロイに成功すると

```Console
remote: Verifying deploy... done.
```
のようなメッセージが表示されます。
デプロイした時点ではまだdiscord上ではbotはオフラインのはずです。
![コメント 2019-02-27 220947.jpg](https://qiita-image-store.s3.amazonaws.com/0/199063/651fb2a9-e6df-1a8f-25c9-ccf90845f1f2.jpeg)
有効にするには、Resourcesからアプリを起動すればdiscord上でbotがオンラインになるはずです。
ちなみにHerokuの無料枠は550時間です。
![Heroku](https://qiita-image-store.s3.amazonaws.com/0/199063/99980738-162d-8a83-c8f4-346511bc86bc.jpeg)
無事、オンラインになりました。
![コメント 2019-02-27 220928.jpg](https://qiita-image-store.s3.amazonaws.com/0/199063/162bf664-5f13-ccab-cc51-5125566e5f19.jpeg)

このあたりを参考にさせていただいています。

- [いろいろワカラなくていいから取り急ぎPythonのWebアプリをherokuに置きたいときにやること](https://qiita.com/sgigagaeru/items/ec25932729eb7c23085e)
- [DISCORDの千枝ちゃんBOTを２４時間起動にしたみた](https://qiita.com/Yoshi_T/items/48fd4bee8a12d432de5b)

これで快適なTRPGライフが送れるはず。
少しでも誰かの参考になれば幸いです。

## その他のリファレンス
- [TRPGオンラインセッションツールガイド（仮）Discord用ダイスボット](https://seesaawiki.jp/trpg_tool_guide/d/Discord%CD%D1%A5%C0%A5%A4%A5%B9%A5%DC%A5%C3%A5%C8)