桜花決闘を支える技術～『桜降る代に決闘を』イベントシステムの裏側～

皆さんは、『桜降る代に決闘を（略して”ふるよに”）』[^1]をご存知でしょうか。ショップの売上ランキング常連だったりする（らしい）とても人気なボードゲームで、全国各地で行われている大会や交流会の多さがその熱量の高さを窺わせます。

私は、そのふるよにの公式ブログ上で週刊連載されている小説[^2]を担当しているのですが、同時に公認イベント管理システムの運営/開発としても携わらせていただいています。

本記事は、ふるよに公認イベント管理システムが実際にどういう技術で動いているのかについて解説するものです。もし、画面の前のあなたが技術に明るくないミコトであるならば、各節の冒頭を読んで「ｽｺﾞｲﾅｰ」と思っていただければ幸いです。

# 登場人物
- 私（五十嵐月夜）
- Bakafire氏
 - ふるよに原作者

[^1]: https://main-bakafire.ssl-lolipop.jp/furuyoni/na/
[^2]: 『桜降る代の神語り』 http://bfpblog.bakafire.main.jp/?eid=12 

# イベントについて
まず、システムと、それが扱っている大会というものについて改めて整理しておきましょう。

ふるよにでは、レギュレーションを満たした対戦会を**『公認大会』**として扱い、優勝者は公式から提供されたグッズがもらえたりします。また、単にみんなで集まって遊ぼう！という旨の会も、申請して認められれば公認**『交流会』**として扱われます。

そこに加え、そもそも公式サイドが主催する**『公式』**イベント、公式サイドに特に認可された**『準公式』**イベントの計四種が存在し、以上のイベントは全て公式サイトの大会ページに情報が掲載されます。公式イベントについては事前参加登録も受け付けており、参加可能人数を超えて登録してきた場合にはリザーバーとしての登録が成されます。

https://main-bakafire.ssl-lolipop.jp/furuyoni/na/event.html

サイトをご覧いただければ分かると思いますが、執筆現在でイベントの数は **50** です。これらのイベントは、

- 申請
- 承認
- サイトに掲載
- 参加者管理（公式イベントのみ）

という多くのステップ踏みます。この後には「主催との連絡（景品発送など）」など物理的な作業も挟まります。明らかに一人の人間が手作業で捌く量ではありません。しかし過去、このフローを手作業で行っていた人がいました。原作者Bakafire氏です。

当時は一度に掲載されるイベントは多くて20件程度ではありましたが、**サイトへの掲載はhtmlタグ直打ち**、**リザーバーは本人が全員管理してメールでやりとり**という漢仕様だったことも相まって、連載の打ち合わせ前に死にそうになっている光景を何度もお見かけしていました。システムは、そんな私の「その時間を小説について充ててくれ駆動開発」によって行われました[^3]。

[^3]: もちろん、エンジニアとして「なんか色々試してみたかった駆動開発」もしているので、題材が転がってきたという見方はできなくもない。

# システム構成
ざっくり書いたものが以下の図になります。

![構成図](https://qiita-image-store.s3.amazonaws.com/0/55516/ed0bf2ab-7bff-8c5e-05f9-d8643a4ee22c.png)

私が開発に着手したのは2017年の夏頃、最初に運用が開始されたのがその10月となります。技術や構成についての選定は、基本的には次のような基準で行いました。

## 金がかからないこと
サーバー代などで首が回らなくなったら本末転倒ですね。

## なるべく私だけで作業が完結すること
修正を都度適用してもらうのも本末（ｒｙ

# 各システムについて
## API
このシステムの肝です。以下の機能を担います。

- イベント、参加者情報を返したり修正したり
- イベント開催申請を受け付ける
- 公式イベントへの参加申請を受け付ける
 - リザーバーNo発行
- 各申請への自動メール返信、Discordへの通知(webhook)

今までに扱ってきたイベント数は**のべ900**、参加者は**のべ1600人**[^4]になります。１タイトルのオフラインイベントとしてはとてもすごいことなのではないでしょうか。

公式大会に参加申請を出したことがある方は、リザーバーNoなるものをメールで受け取ったことがあるかもしれませんが、ここで自動的につけられています。

[^4]: 前述の通り、システムで扱っている参加者は公式イベントのみですので、ふるよにイベント全体の参加者は数倍に及ぶでしょう。

### 中身
とにかくお金をかけないため、 **API gateway + Lambda** というAWS構成を選択しました。物理的に見れば多い数字でも、アクセスやデータ量で見れば全く大したことはないため、現在でも小学生のお小遣いレベルの費用で済んでいます。

フレームワークには **Serverless framework** を使用し[^5]、 **Babel** で書いています。触ってみたかった駆動ですが、構成管理に手間がかからないので大変重宝しています。本当は本業で kotlin を触っているのもあってそこまでトライしてみたかったものの、環境変数周りで挫折してしまい、普段jsは書かないしなあ、という理由でオーソドックスそうなスタイルを取りました。 Serverless はなにより簡単な記述で内部向けAPIに認証をかけられるのがいいですね。

きちんとREST APIとして定義できていた以上、本当ならDBは慣れているMySQLが使えるRDSを使いたかったんですが、費用の面から **DynamoDB** へ。色々と NoSQL の洗礼を受けつつ、自動返信メールには安定の **Simple Email Service** を使用しています。

Dynamoのインデックスのキャパシティを適当に設定しすぎて死ぬ、とかいうドジをやらかしたりしましたが、必要最低限の箇所に手を入れるだけで済むので概ね満足しています。

[^5]: 運用の最初期は生でAPI GatewayとLambdaを触るという修行みたいなことをしていました。二度とやらない。

## 公式サイト
いわゆるフロント。ここは今回、私があまり手を出せない領域です。

既存のページにある大会情報を動的にする、という要件を守ると、私が更新の主導権を握るわけにはいかなくなります。大会景品などシステム非依存の部分がある上、Bakafire氏のレンタルサーバー上で動いてるサイトをまるごと引き取ることは、将来的な運用を考えれば現実的ではありません。

以上の理由から、サーバーを用意して Rails なんかでバックエンドからまるっと作る、という案が費用面以外でも却下され、あっさりとした jQuery でAPIリクエストと表示を済ませるjsを置くことにしました。

ちなみに、かといって更新する際にいちいち最新の差分は～とかやっていられないので、githubのアカウントだけとってもらって、私が出したPRを元に作業する、という方式をとっています[^6]。

[^6]: 双方にたっぷり時間があれば、バージョン管理から色々教えるのですが、身体は1つしかない。

## 管理ツール
Bakafire氏が、申請されてきたイベントや参加者に対してあれこれするところです。以下のような機能があります。

- イベント一覧、詳細閲覧
- イベント情報更新
 - 承認とか、参加申請の自動返信メールのテンプレートもここ
- その公式イベントの参加者一覧
 - リザーバーの管理つき

![イベント一覧](https://qiita-image-store.s3.amazonaws.com/0/55516/cca7ee2d-5e8f-f1b3-ff3e-475a5aef4ce9.png)
![参加者一覧](https://qiita-image-store.s3.amazonaws.com/0/55516/48da082d-c10c-d56a-d17c-06c541ee4e5a.png)

なんか`APROOVED`とかいうtypoが見えるのは気の所為です。みなさんは運用開始からしばらくしてから、わりとめんどくさい場所にtypoを発見して崩れ落ちないように、スペルチェッカとか見ましょうね！

### 中身
管理ツールは運営しか使わないので、**機能性重視**かつ**デザインに手間はかけない**という方針で技術選定を行いました。追加として、認証がかけられる、というのもあります。もちろん、お金はかけません。

そこでまずは、 **Vue.js** でSPAを作って静的配信をする、というところからはじめました。SPAと言っても、html一枚とjsでゴリゴリやっていくスタイルなので、生のjsで書いています。S3などで静的配信できれば最高でしたが、認証の問題が解決しきれなかったので、最初は私個人のサーバーを間借りし、後にnodeで立てたサーバーで認証をかけつつ、無料枠で収まるのでherokuに移しました。

また、デザイン面はフロントエンドとデザイン苦手マンたる私の鬼門でしたので、 Vue.js を使っていることもあって、 **[bootstrap-vue](https://bootstrap-vue.js.org/)** を採用しています。定義されたコンポーネントのタグを書いていくだけでするする必要十分なデザインができあがることに加え、最初からMVVMとして分かりやすく書ける恩恵を高く評価しています。

しかし、流石に勉強しながらゴリゴリ書き進めたままであることと、認証周りの将来的な不安から、今後は **Nuxt.js** あたりでも使ってきちんと書き直そうかと検討中です。

## honoka(bot)
![honokachan](https://qiita-image-store.s3.amazonaws.com/0/55516/a8344688-96e7-c475-bd3f-5bdddef69523.png)

イベントの運営はBakafire氏だけではありません。実際に運営するスタッフが参加者を把握できていなければ意味がありません。氏が管理ツールから情報引っ張ってきて渡す、なんてフローがあったのですが、無駄だったのでふるよに開発チームがサイボウズから Discord に移住したことをきっかけにBotを導入しました。なお、内部向け情報があるのでpublicにinviteできるようにする予定はありません。

![commandlist](https://qiita-image-store.s3.amazonaws.com/0/55516/b8e175ad-5c1d-4e0c-7e83-1503351ff7a0.png)

機能的にはこんな感じです。公式サイトで大会情報を見るとURL末尾に文字列がくっついてますが、あのidを使ってホノカちゃんとお話します。参加者一覧はリザーバー番号と名前しか返さないようにしているので、万が一誤爆してもメールアドレスとかは漏れないようにはなっています。大会情報についても同様です。

余談ですが、一番下の謎のコマンドは、落書きをするキャラ決定用にランダム数字が欲しいと言われて適当に追加したものです。一体何雨先生[^7]なんだ……。

[^7]: ちなみに、このbotのアイコンは時なんとか先生が突然描き下ろして投げてきたものです。ミコトの皆さん向けには、この絵はスタンプになって巣立っていったという情報が。

### 中身
[discord.js](https://discord.js.org/#/) を使ってさくさくと生jsで書いて、私のサーバーで **forever** で適当に立ち上げています。チャットコマンドのいい感じのパーサーが見つからなかったので、拡張性が全然担保されていないのが辛いところです。

そこで動いていた自分のサイトを firebase に移して同居人がいなくなったというものあり、どこかbotをいい感じに動かす場所も求めています。

## amane for Android
上記のhonokaでも面倒くさいところがありました。長ったらしいチャットコマンドはスマホ向けのUIではないのです。そこで、本業Android開発者として、honokaの代わりができるネイティブアプリを作ってみることにしました。それがamane for Android[^6]です。

![amane](https://qiita-image-store.s3.amazonaws.com/0/55516/ecffe356-d7b2-2e6a-b469-fc985a2bae8c.png)

未開催のイベント一覧表示と参加者の出欠チェック機能だけがある、シンプルな内容になっています。

[^8]: 残念ながらiOSアプリは、まずはmacを買うところから始めなければならない程度に書けないので、for iOSが出ることはきっとないはず……。

### 中身
kotlin かつ MVVM、というところは業務でもやっているので基本として抑えつつ、 ArchitectureComponents の ViewModel と Dagger でのDIの組み合わせや、 AndroidStudio3.2 からいじれるようになった **Navigation** で遷移を記述したりと、近年追加された新しい機能を色々と試しています。

また、botと違ってアプリは棚卸しの問題がつきまとうので、その点は **Firebase Authentication** での認証機構を組み込むことによって、ユーザーの棚卸し問題に帰着させて解決しています。未認証状態だとpublicな情報しか見えません（それでも、honoka同様アドレスなどは載せていない）。

# おわりに
個人規模開発のゲームのオフイベント運用に、技術的解決が必要になることはよほど稀でしょう。だいたいtwiplaとかで済むはずです。ですが、その稀有な例を手助けしてあげられていることに、ふるよにで遊んでいる１プレイヤーとして嬉しく思います。

……結局、Bakafire氏の作業は効率化したのかって？浮いた時間を埋めるように、彼は元気に忙殺されていますよ、HAHAHA。