もちっとメモ

もちっとメモ

管理人のホームページで書ききれていない調べ物のメモページです

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

始まり

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

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

Python + Googleスプレッドシート (+ Heroku)で実現しています。 ソースコード全体はGitHubに晒しておきます。

PythonからGoogleスプレッドシートにアクセスする

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

こちらを参考に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

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

この中で使うのは技能合計値のカラムです。これを以下のような感じで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をシート名にしています。

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の設定をしていきます。

Bot作成

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

メッセージを取る

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

dice 1d100 技能名

これで受け取ってメッセージを返す待機botの処理を実装します。

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つ生成します。

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に返すメッセージです。

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

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

成否判定

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

charactor = {
    'HP': 9,
    'MP': 7,
    'SAN': 60,
    'こぶし': 50,
    '図書館': 50
}

と言っても、インスタンス生成とかしているわけではないので、判定のたびにスプレッドシートからデータを参照しているので、たぶん非効率的です。

処理の流れは

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

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

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

ダメージ判定

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

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
  • 失敗
    f3519de16b5129f665654e4e0334d1b7.jpg
  • ダメージボーナスあり
    5c9890fa428b2d87386954e0c80a6fa5.jpg
  • ダメージボーナスあり(マイナス)
    720e61ae3edcb3ae1cb08a82a5eec24a.jpg

狂気表

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

dice 1d10 狂気の種類

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

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
  • 不定の狂気
    eebbceb517ec43a602ee1a6e9de05c8c.jpg

Herokuにデプロイする

そのままではローカルでbotを起動している時しか使えないので不便です。そこでサーバーを立てて常時使えるようにします。今回はHerokuを使います。

Procfile

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

woker: python trpg_bot.py

とします。

requirement.txt

requirement.txtpythonファイル内で使っているライブラリを記入します。今回は以下のライブラリを使用しています。

oauth2client
httplib2
gspread
discord
parse
numpy

デプロイ

基本的にはHerokuのアカウントを作ってCreate New AppしてHeroku Gitにしたがって進めれば良いです。

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

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

  • trpg_bot.py #メインアプリケーション
  • config.json #設定ファイル
  • requirement.txt # さっき作ったやつ
  • Procfile #さっき作ったやつ
  • oauth.json #Googleスプレッドシートを作った時にダウンロードしたoauthのjsonファイルです
$ git add .
$ git commit -am "make it better"
$ git push heroku master

無事デプロイに成功すると

remote: Verifying deploy... done.

のようなメッセージが表示されます。 デプロイした時点ではまだdiscord上ではbotはオフラインのはずです。

コメント 2019-02-27 220947.jpg

有効にするには、Resourcesからアプリを起動すればdiscord上でbotがオンラインになるはずです。 ちなみにHerokuの無料枠は550時間です。

Heroku

無事、オンラインになりました。

コメント 2019-02-27 220928.jpg

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

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

その他のリファレンス

UnityEditorのバージョン管理をしたい

本記事は関東ゲーム制作部 Advent Calendar 2018 16日目の記事です。 adventar.org

経緯

昨日12/14にUnity 2018.3がリリースされましたね。prtimes.jp個人的には長らく切望していたNested Prefab機能が実装されたと聞いて早速使ってみたいと思いました。ですが、Unity使いの皆様ならよくあると思うのですが、「Unityのバージョンアップすると過去のが動かなくなるから上げたくないんだよなぁ...」ということがあるかと思います。デフォルトではUnityは1環境に1バージョンしか存在できないっぽい?ので、特に共同開発なんかしていると周りと足並みそろえて変更する必要があると思います。Pythonであれば、pyenvとかで複数バージョンを共存させられるけど、Unityでもそういったことできないのかな~と思っていたら2通りのやり方を見つけたので、誰かの役に立てばと思い、メモしておきます。

UnityHubを使う

コメント 2018-12-16 002612.jpg
私はこの記事を書くにあたって知りましたが、こちらを使っている人は割といるのではないでしょうか?UnityHubの特徴はこちらの記事にわかりやすくまとめられています。 qiita.com 要点だけ抜粋すると

  • 複数のバージョンを管理できる
  • Componentを追加インストールできる
  • ver.2017以降であればUnityHub経由でインストールできる。それ以前もローカルにあれば、追加できる
  • 同一バージョンのUnityEditorを同時に起動できる

あたりでしょうか。これを使えば、プロジェクトでは古いバージョンを使っているけど、自分の環境では新しいバージョンを試したいなんてときに便利だと思います。手っ取り早く使いたいならこっちを使うのがおすすめです。 コメント 2018-12-16 011026.jpg

Unity on Docker

ここからが本命かつマイナーなお話になります。最近、バージョン管理や環境を汚さないようにDocker上でいろいろやろうという話を聞きます。かくいう私もPythonの環境とかはDocker上で作ったりしています。そこで、最近流行りのDockerを使ってUnityを動かせないかと思いました。結論から言うとおすすめしません。それでも、供養の意味も込めて検討記録ということで長くはなりますが、それでもいいよという心優しい方はお付き合いください。

Docker自体のインストールも書いているといよいよ長くなりすぎるので、ここでは割愛します。私はWindows10なので、Docker for Windowsを使うためにこの辺りを参考にしました。 qiita.com ついでにGUIでコンテナ管理したいのでKitematicも入れています。

Dockerのインストールには成功しました。いよいよ、Unityのコンテナを用意します。といっても私はdocker-compose up -d程度しか使えないので、誰かいい感じのDockerfileなりdocker-compose.ymlなど提供してくれてないかなぁと検索したところ、ありました。 github.com 探せばいるもんですね。先人は偉大です。しかもReadMeを読んだところなにも考えずにdocker-compose up -dをと唱えれば良いと書かれているもんだからありがたい限りです。ということで、早速コマンドプロンプトを起動して、リポジトリをcloneしてきて、Dokerfileがあるディレクトリまで移動して、実行します。 ※ここからは回線状況にもよりますが、作業に2~3時間程度要します。また、ディスク容量に4GB程度の空きを作っておく必要があります。

$docker-compose up -d

と叩くと、Dockerfileに従ってインストールが始まります。時間がかかりそうだったので、放置していたら、タイムアウトの関係でいくつかのインストールに失敗していたのと、share directoryにのアクセスに失敗しました的なメッセージが出て止まっていました。同時にポップアップでshare directoryのアクセスを許可しますか?のようなものが出ていたので、OKを押して再インストール。うまくいかない場合は、一度インストールしたイメージ類をすべて消してから再度実行するとうまくいくと思います。

$docker image list #ダウンロードしたイメージ一覧を表示
$docker rmi *** #***で指定したイメージを削除

当該のイメージが見つかりませんでしたのようなメッセージが出た場合はタグをつけて削除するとうまくいくと思います。 www.memotansu.jp

これでコンテナのダウンロードまでは成功したようなのですが、コンテナの起動がうまくいかない。メッセージにリビルドしてねと出ていたので

$docker-compose up --build

を実行。概ね上手くいったぽいけど、すると今度は、

standard_init_linux.go:190: exec user process caused "no such file or directory"

Windows10を使っているのですが、どうやらリポジトリからcloneしてきたときに、改行コードがCRLFに変換されて落としてきています。ですが、コンテナ自体はUbuntuだからかLFでないと動かないようです。 qiita.com なので、テキストエディタで改行コードをLFに変更して再度

$docker-compose up --build

を実行するとようやくUnityのコンテナが起動!!ゴールは近いです。提供ページに書かれている通り、VNC ViewerでVM内に入ってくださいとのとこなので、お好きなVNC Viewerを使ってコンテナ内に入ります。私は今回はUltra VNCを使いました。forest.watch.impress.co.jp www.atmarkit.co.jp辺りを参考にしながら、インストールの設定は基本デフォルトで、環境変数に登録とデスクトップにショートカットアイコンを作る的なものだけチェックを入れてインストール。さーて、IPアドレスを打っていざConnectと思ったのですが、またコケました。今度は何だと思ってKitematicのログを見ると、

Creating unity-service ... done
Attaching to unity-service
unity-service | stored passwd in file: /home/adminuser/.vnc/passwd
unity-service | 16/12/2018 07:00:26 -usepw: found /home/adminuser/.vnc/passwd
unity-service | 16/12/2018 07:00:26 x11vnc version: 0.9.13 lastmod: 2011-08-10  pid: 8
unity-service | 16/12/2018 07:00:26
unity-service | 16/12/2018 07:00:26 wait_for_client: WAIT:cmd=FINDCREATEDISPLAY-Xvfb
unity-service | 16/12/2018 07:00:26
unity-service | 16/12/2018 07:00:26 initialize_screen: fb_depth/fb_bpp/fb_Bpl 24/32/2560
unity-service | 16/12/2018 07:00:26
unity-service | 16/12/2018 07:00:26 Autoprobing TCP port
unity-service | 16/12/2018 07:00:26 Autoprobing selected TCP port 5900
unity-service | 16/12/2018 07:00:26 Autoprobing TCP6 port
unity-service | 16/12/2018 07:00:26 Autoprobing selected TCP6 port 5900
unity-service | 16/12/2018 07:00:26 listen6: bind: Address already in use
unity-service | 16/12/2018 07:00:26 Not listening on IPv6 interface.
unity-service | 16/12/2018 07:00:26
unity-service |
unity-service | The VNC desktop is:      767f1c3f2d95:0
unity-service | PORT=5900
unity-service | 16/12/2018 07:05:11 Got connection from client 172.16.250.1
unity-service | 16/12/2018 07:05:11   other clients:
unity-service | 16/12/2018 07:05:11 webSocketsHandshake: unknown connection error
unity-service | 16/12/2018 07:05:11 Client 172.16.250.1 gone
unity-service | 16/12/2018 07:05:11 Statistics             events    Transmit/ RawEquiv ( saved)
unity-service | 16/12/2018 07:05:11  TOTALS              :      0 |         0/        0 (  0.0%)
unity-service | 16/12/2018 07:05:11 Statistics             events    Received/ RawEquiv ( saved)
unity-service | 16/12/2018 07:05:11  TOTALS              :      0 |         0/        0 (  0.0%)
unity-service exited with code 137

的なメッセージが出ていました。どうやらVNCアクセスに失敗していたのかよくわかりませんが、VNC Viewerを立ち上げる前にUnityのコンテナを立ち上げたのがいけなかったぽいので、一度コンテナをdownさせてからVNC Viewerを立ち上げた後に、再度

$docker-compose up --build

を実行すると、

Creating unity-service ... done
Attaching to unity-service
unity-service | stored passwd in file: /home/adminuser/.vnc/passwd
unity-service | 16/12/2018 07:24:51 -usepw: found /home/adminuser/.vnc/passwd
unity-service | 16/12/2018 07:24:51 x11vnc version: 0.9.13 lastmod: 2011-08-10  pid: 8
unity-service | 16/12/2018 07:24:51
unity-service | 16/12/2018 07:24:51 wait_for_client: WAIT:cmd=FINDCREATEDISPLAY-Xvfb
unity-service | 16/12/2018 07:24:51
unity-service | 16/12/2018 07:24:51 initialize_screen: fb_depth/fb_bpp/fb_Bpl 24/32/2560
unity-service | 16/12/2018 07:24:51
unity-service | 16/12/2018 07:24:51 Autoprobing TCP port
unity-service | 16/12/2018 07:24:51 Autoprobing selected TCP port 5900
unity-service | 16/12/2018 07:24:51 Autoprobing TCP6 port
unity-service | 16/12/2018 07:24:51 Autoprobing selected TCP6 port 5900
unity-service | 16/12/2018 07:24:51 listen6: bind: Address already in use
unity-service | 16/12/2018 07:24:51 Not listening on IPv6 interface.
unity-service | 16/12/2018 07:24:51
unity-service |
unity-service | The VNC desktop is:      6afa1c440a08:0
unity-service | PORT=5900

となり、Connection Errorは解消されたっぽい。ということで、ローカルホストにVNCServer: 127.0.0.1でアクセスすると、パスワードを聞かれました。提供元のReadMeに書かれていた通り、パスワードpass123を入力すると、無事、コンテナ内に入れました。こんな感じのコンソール画面だけが表示された画面が見えるはずです。で、肝心のUnityはどこにあるのかというと、/opt/Unity/Editor/Unityにあるとのことですが、デフォルトのディレクトリが/opt/Unity/Editorなので、その場でUnityを実行すれば起動できました。

adminuser@6afa1c440a08:~$cd Unity

あとは画面に従って通常のUnityのインストールと同様に進めていきます。 image2.png image4.png ログインもできて、さーて開発を始める準備が整ったことでしょう。新規に作るもよし、既存のプロジェクトを使うもよし。自分で作っていたプロジェクトを使いたい場合はCloudに上げておくか、ローカルであれば、cloneしてきたリポジトリ直下にあるcontextフォルダにおくように書かれていたのでお好みの箇所に置きましょう。コンテナ内からアクセスするときは、File System->context->任意のフォルダに入っています。

ところが、これで動くはず...あれ?一瞬エディター画面が表示されてAbortedとか言われるぞ...???なんかいろいろ調べるとバージョンによってはバグで動かないっぽいことを見かけたので、Unityのバージョンをアップすることにしました。 Installing Unity 2017.1.0f2 In Docker Container 使用したバージョンはDockerfileに書かれていた当時のlatestであるver.2017.2.0f3にしました。バージョンを変更したい場合は、お好みのバージョンのURLをコピーしてDockerfileの71行目あたりにある.debファイルを指定しているURLを変更すれば行けました。それ以外のバージョンを使いたい場合は、たぶんこちらのスレッドから対応したUnityのイメージを落としてくればいいんだと思います。スレッドのページを下に繰っていくと、古いのだと5.1から最新で2017.2.0b11まであるみたいです。どれが行けてどれが行けないまでは検証していないのでわかりません(^-^; バージョンアップして再度dockder-compose up -dからやり直すと無事NewProject作成で画面が表示された...? image7.png メモリが足りないのかこれ以上まともに描画されませんでした。こんな調子なので当然、プロジェクトが重すぎたのか、2dだからダメなのか自前のプロジェクトはうまく描画できませんでした。本当はこんな感じの画面を出したかったんですが... image6.png

画像は私の所属するサークルNOELで制作したゲームです。現在は第2作目のリリースに向けて鋭意制作中です。最新情報は随時Twitterで流していきますので、よろしければフォローしてやってください。@CircleNoelGame(←単なる宣伝です。)

これでめでたくDocker上でUnityが表示できるようになりました(まともに動くとは言っていない)。ものすっごく動作がもっさりしますが、とりあえず動いたということで、今回はここらへんで力尽きてしまいました。あとは割り当てメモリとか増やせばいいんですかね?でも、私の持つ低スペックPCでは3GB割り当てるのが精いっぱいなんですよね。年内に進展があれば、時間を見つけて続きを書きたいと思います。

ちなみにUnrealEngineのdockerイメージも存在はするようです。頑張ればこっちも行けるんでしょうかね(;^_^A github.com

少しでも誰かの参考になれば幸いです。よければサークルNOELもよろしくお願いします。 Twitterはこちら@CircleNoelGame

その他のリファレンス

2018年私的読みたいアドベントカレンダー(随時更新)

qiita.com

ゲーム開発

その3まである。やっぱり最近ホットなのでしょうか?その2でAgent-MLのことを書いてくださる方がいるようなので楽しみです。 qiita.com qiita.com qiita.com adventar.org ↑私も投稿しました t-n-clark.hatenadiary.jp

お勉強

最近、まじめにお仕事でも使う必要が出てきたのでこの機に勉強でもし直そうかと思ってます。GAN関連の論文紹介もちらほら見かけるのでそのあたりを中心に見ておこうと思ってます。 qiita.com qiita.com qiita.com

自作テストツールでもJenkinsで結果を可視化したい

経緯

C++で書いたコードのテストをしてテスト結果をJenkinsで可視化したい。だが、いかんせん低スペックな私にはその辺りの環境設定がわからん。周りからはとりあえず早くと急かされる。Jenkinsが読み込めるのはJUnitが出力する形式で書かれたXMLファイル。つまり、(わから)ないなら力業でその形式のXMLを書けばよかろうなのだ。 という頭の悪い発想に思い至ってしまったのが事の発端です。

JUnitの出力するXMLの構造を見てみる

今回は、こちらを参考にさせていただきました。 JUnitの実行結果のXMLフォーマット

私の稚拙な説明よりよっぽど明快に解説してくださっているので上のページを読んでいただければ十分かとは思いますが、概説すると

  • testsuite
    • name:テストスイート名(テストクラス名,テストのグループ名)
    • tests:testsuiteに含まれるテストの総件数
    • failures:testsuiteに含まれる失敗(AssertionError)となったテストの総件数
    • errors:testsuiteに含まれるエラー(AssertionError以外の例外)となったテストの総件数
    • time:testsuiteに含まれるテストの総実行時間(秒)
  • testcase:testsuite要素の子要素
    • classname:テストケースの属するクラス名
    • name:テストケースの名前(半角数字で指定する?)
    • time:テストケースの実行時間(秒)
  • failure:テストが失敗した場合に追加するtestcase要素の子要素
    • type:失敗の種類(例外クラス名など)
    • message:例外に含まれるメッセージ
  • error:テストが失敗でなくてエラーだった場合に利用。基本failure要素と同じ
  • skipped:テストがスキップされた時に追加されるtestcase要素の子要素
  • system-out:テスト実行時に標準出力に出力された情報をCDATAとして記述
  • system-err:標準エラー出力について記述する以外はsystem-out要素と同じ

といった要素(スキーマ)を記述すればよさそうです。 恐らく日本語が混じっていてはダメです。

実装

ものすごく雑に組んでみます。実際は、テスト条件や期待値を外部ファイルから設定してテスト関数を動かすなど、もう少し工夫しています。 MakeXMLResult()が最終的に出力を行っているところです。

#include <string>
#include <vector>
#include <fstream>
#include <iostream>

using namespace std;

#define SUCCESS 0
#define FAILURE 1
#define EXCEPTION 2

struct SingleResult {
    int no;
    double time;
    int result;
    string name;
    string type;
    string message;
};

struct TotalResult{
    int test_tot_num;
    int ok_num;
    int error_num;
    int failure_num;
    double test_tot_time;
};


class Test{
private:
public:
    string OutputStdMessage();
    string ErrorMessage();
    void MakeXMLResult(string outputpath,vector<SingleResult> results,TotalResult tot_result);
    SingleResult DoTest();
    TotalResult CalcTotalTest(vector<SingleResult> results);
};

string Test::OutputStdMessage(){
    return "std message";
}

string Test::ErrorMessage(){
    if(){//1件でもエラーか失敗があれば
        return "Some Failure or Error Tests Caused";
        }
    else{
        return "All Test Success";
        }
}

void Test::MakeXMLResult(string outputpath,vector<SingleResult> results,TotalResult tot_result){
    char c_filepath[_MAX_PATH];
    sprintf_s(c_filepath,"%s",outputpath.c_str());
    ofstream output(c_filepath);

    output << "<?xml version=\"1.0\" ?>" << endl;
    output << "<testsuite name=\"" << tot_result.testname << "\" " 
        << "tests=\"" << tot_result.test_tot_num << "\" "
        << "errors=\"" << tot_result.error_num << "\" "
        << "failures=\"" << tot_result.failure_num << "\" "
        << "time=\"" << tot_result.test_tot_time 
        << "\">" << endl;

    for(SingleResult result : results){
        if(result.result == SUCCESS){
            output << "<testcase classname=\"" << tot_result.testname << "\" "
                << "name=\"" << result.name << "\" "
                << "time=\"" << result.time << "\" />" << endl;
        }
        else if(result.result == FAILURE){
            output << "<testcase classname=\"" << tot_result.testname << "\" "
                << "name=\"" << result.name << "\" "
                << "time=\"" << result.time << "\" >" << endl;
            output << "<failure type=\"" << result.type << "\" "
                << "message=\"" << result.message << "\" >"
                << endl;
            output << "</failure>" << endl;
            output << "</testcase>" << endl;
            }
        else if(result.result == EXCEPTION){
            output << "<testcase classname=\"" << tot_result.testname << "\" "
                << "name=\"" << result.name << "\" "
                << "time=\"" << result.time << "\" >" << endl;
                output << "<error type=\"" << result.type << "\" "
                << "message=\"" << result.message << "\" >" << endl;

            output << "</error>" << endl;
            output << "</testcase>" << endl;
            }
    }

    output << "<system-out>" << "<![CDATA[" << OutputStdMessage() << "]]>" << "</system-out>" << endl;
    output << "<system-err>" << "<![CDATA[" << ErrorMessage() << "]]>" << "</system-err>" << endl;
    output << "</testsuite>" << endl;
}

SingleResult Test::DoTest(){
    ///テストコードを書く
    ///例えば結果を構造体に書き込んでいく
}

TotalResult Test::CalcTotalTest(vector<SingleResult> results){
    ///全体の集計結果を出しておく
}

int main(int argc, char* argv[]){
    Test* test = new Test;

    string outputpath(argc<=1? ".": argv[1]);

    SingleResult result;
    vector<SingleResult> results;
    TotalResult tot_result;

    result = test->DoTest();
    results.push_back(result); ///きっとテストは二つ以上あるだろうから

    tot_result = test->CalcTotalTest(results)
    test->MakeXMLResult(outputpath,results,tot_result);

    delete test;

    return 0;
}

テストコードも書いてやって、実際に出力されたXMLはこんな感じです。各々の数字に深い意味はありません。

<?xml version="1.0" ?>
<testsuite name="Sample" tests="10" errors="2" failures="7" time="10">
<testcase classname="Sample" name="101" time="1" >
<error type="1" message="Error" >
</error>
</testcase>
<testcase classname="Sample" name="201" time="1" >
<failure type="2" message="Failure" >
</failure>
</testcase>
<testcase classname="Sample" name="301" time="1" >
<error type="3" message="Error" >
</error>
</testcase>
<testcase classname="Sample" name="401" time="1" >
<failure type="4" message="failure" >
</failure>
</testcase>
<testcase classname="Sample" name="501" time="1" >
<failure type="5" message="failure" >
</failure>
</testcase>
<testcase classname="Sample" name="601" time="1" >
<failure type="6" message="Execute" >
</failure>
</testcase>
<testcase classname="Sample" name="701" time="1" >
<failure type="7" message="Execute" >
</failure>
</testcase>
<testcase classname="Sample" name="801" time="1" >
<failure type="8" message="Execute" >
</failure>
</testcase>
<testcase classname="Sample" name="802" time="1" />
<testcase classname="Sample" name="910" time="1" >
<failure type="9" message="Failure" >
</failure>
</testcase>
<system-out><![CDATA[std message]]></system-out>
<system-err><![CDATA[Some Failure or Error Tests Caused]]></system-err>
</testsuite>

これをJenkinsのビルド後の処理で「JUnitテスト結果の集計/テスト結果XML」で指定したディレクトリに出力されるようにしておけばJenkinsで見れるようになります。 注意点としては

  • xmlまでのパスはワークスペースからの相対パスで書く(私はワークスペースの直下に出力するかコピーされるようにしています)
  • ジョブ終了直前のもののみが認識される(ファイルのタイムスタンプを見ているのか生成時刻が古いと「このファイル今のジョブのじゃないんじゃないの???」的なメッセージが出て認識されません)

PythonからJenkinsのジョブを実行したい

経緯

最近ぼちぼち出張も増えてきて、出先で作業することも増えてきました。 出先ではノートPCを使って作業するのですが、手配したばかりで中は環境構築がなされていないすっからかんのPCです。 今後のためにがっつり環境を作ってもいいのですが、せっかく新品なのであまり環境を汚したくないですし、頑張ったところでまあスペックはたかが知れているようなものなので、なんとか普段使っているPCのリソースを使って重たい作業を回したいなあと考えていました。 そんな折、何をとち狂ったのかのか、「Jenkinsのジョブで実行ファイルを叩けば行けるんじゃない?ついでにFlaskでフロントを作ってWebアプリとして動かせるようにしてしまえ。」と思い立ってしまったのが事の始まりでした。 ということでなんか本来の使い方から外れているような気もしますが、タイトルの通りpython(Flask)からJenkinsのジョブを実行する環境を作ったので備忘録的に残しておきます。

Jenkins環境

サーバー環境はすでに用意してあるものとします。 まず、ユーザー名とAPIトークンをメモっておきます。 Jenkinsにログインしてユーザー名をクリックすると設定の中から確認できます。 次にリモートから実行を受け付けられるようにjobを用意します。 jobの種類は恐らくはなんでもいいのですが、今回は「フリースタイル・プロジェクトのビルド」で作ります。基本的には普通にジョブを作る時と一緒なので、リモートから実行するときに便利そうな設定だけ書いておきます。

ビルドのパラメーター化

ビルドのスクリプトを書くときにパラメーターを使いたい場合に設定します。POSTリクエストを投げた際にここで設定した名前をキーとしてパラメータを取得できます。

ビルド・トリガ

「リモートからビルド」にチェックを入れて認証トークンを設定します。 恐らくなんでもいいので任意のトークン名を入力します。 ここでJenkinsの画面に、「リモートからビルドするには次のURLを使用します~」のようなメッセージが出ていると思うので、ここにリクエストを投げればリモートからジョブを実行できます。 ビルドのパラメーター化でパラメーターを設定した場合は、「buildWithParameters?token」の方を使用します。「TOKEN_NAME」の部分には認証トークンが入ります。 ビルド Windowsバッチコマンドであれば%で囲ってあげるとビルドのパラメータを取得できます。

SET PARAM = %param% 
APP.exe %PARAM% 

シェルスクリプトの場合は$でしたっけ?ここはJenkinsではなくバッチやシェルスクリプトの書き方に沿って書いてください。 これでJenkins側の設定は終わりです。

pythonスクリプト

次にpython側のスクリプトを作ります。今回はFlask経由で実行するので若干冗長です。実行だけでよければjobの関数の部分さえあれば十分です。 render_templateredirectはWebアプリとして使いたいがためのお飾りです。 使う環境は3系を使います。

from flask import Flask ,request,redirect,render_template 
import urllib3 


app = Flask(__name__) 


@app.route('/')
def index():
 return render_template('index.html') 

@app.route('/job') 
def job():
 param = request.args.get('param')
 url = 'JENKINS_URL/job/jobname/buildWithParameters?token=TOKEN_NAME¶m='+param #¶mの部分はJenkinsのビルドのパラメーター化で設定したキーにしてください
 username = "username" #Jenskinsのユーザー名
 password = "password" #JenkinsのAPIトークン
 http = urllib3.PoolManager()
 headers = urllib3.util.make_headers(basic_auth=('%s:%s' % (username,password)))
 response = http.request('POST', url, headers=headers)
 return redirect('http://localhost:8080/') 


if __name__ == '__main__':
 app.debug = True
 app.run(host='0.0.0.0',port=8080) 

適当にhtmlも書きます。

See the Pen KxVvgQ by Sashimimochi (@sashimimochi) on CodePen.

これでlocalhost:8080にアクセスして実行ボタンを押せばJenkinsが稼働しているサーバーのリソースを使って実行ファイルを動かせるようになりました。これで出先でもある程度作業ができるようになったかな。 セキュリティー等は考慮していないガバガバ構築なのでそのあたりは悪しからず。