麻雀大会の点数管理とマッチングを自動化するDiscordBotを作成した

こんにちは。
普段は新人インフラエンジニアとして保守のお仕事をしつつ日々勉強させて頂いているのですが、お仕事外のことで1つ成果物として挙げられるものが完成したので記事にしてみました。アウトプットの練習を兼ねつつ、お仕事で接する方にどのような取り組みを行ったか知っていただける機会になればいいなと思っております。

## 事の経緯

ゲームから知り合い、普段から付き合いのあるコミュニティで半年に1度ほど16人程度の規模で麻雀大会を開催していました。昨年11月の開催で3回目となったのですが、大会から数日後に主催メンバーと話していた際、**"大会の現行のマッチングシステムの欠陥"** についての話題になりました。現行のシステムがどういったものか纏めると、


:::note 
1. 完全にランダムに選手を振り分け、1回戦(振り分け戦)を行う 
2. 振り分け戦の結果を元に同順位同士で1-4位卓に分け、2回戦(入れ替え戦)を行う(16人だったので都合がよかった)
3. 入れ替え戦の結果を元に、1位は1つ上の卓へ、4位は1つ下の卓に移動する(1位/4位卓は最下位/1位のみ移動)
4. 同様に入れ替え戦を更に2回繰り返す
5. 4の後に座っていた卓で最終順位決定戦を行う 
:::
というもので、この方式の問題点として

* 振り分け戦後の4位卓からでも優勝は可能だが、4回連続で1位にならなければならない(振り分け戦のウェイトが大きすぎる)
* 開催意図として参加者同士の交流があるが、入れ替わるのが4人中2人(1、4位卓は1人)なので多くの人との対戦ができない

の2点を挙げました。どうしても手動で運営を行うとなると仕方のないことではありますが…

当時、自分はお仕事としてコーディングをしたいと考えており、「この問題、プログラムで制御してやれば解決するのでは？」と思い勉強の一環として自分からツール作成を提案しました。

## 環境
* Discord.js v11.6.4
* 雀魂(対戦に使った麻雀ソフト)
## 要件定義
今回は、
**多くの人同士が対戦でき、かつ試合のウェイトが均一になるマッチングの仕組み**

が必要であると考えます。また、進行に用いるツールとして既にDiscordを使用していたため、参加者にツール導入などの負担を強いたくなかったこともありDiscord上で動作するBotとして作成することに決定しました。

前述した要件の実現の先駆けとして、大会進行のシステムを以下のように刷新しました。

:::note
1. 完全にランダムに選手を振り分け、1回戦を行う
2. 試合結果に応じて対戦卓を組み替え、2回戦を行う
3. ②を更に2回繰り返す
4. ③の後に勝ち点制の順位付けを行い、4人毎に最終順位決定戦を行う(1-4位、5-8位といった具合)
:::

2の **"試合結果に応じて対戦卓を組み替える"** という部分に関して必要とされる条件は

* 直前の試合で対戦を行った相手と再度同卓にならないようにする
* 卓の間での実力差が開きすぎないようにする(勝ち点の重みを均一化)

の2点であるとし、今回は予め箱(配列)を作成しておき順位毎に代入するという方法で解決を試みました。

上記条件に沿って箱を3つ作成しました。各卓から1人ずつ選び、更に同順位の人とはマッチングしないように組まれています。 **※同列が同じ対戦卓とし、A-Dは前回の対戦卓で何位だったかを示す。**

1.
 <table>
    <tr>
      <td>A1位</td>
      <td>B2位</td>
      <td>C3位</td>
      <td>D4位</td>
    </tr>
    <tr>
      <td>A2位</td>
      <td>B3位</td>
      <td>C4位</td>
      <td>D1位</td>
    </tr>
    <tr>
      <td>A3位</td>
      <td>B4位</td>
      <td>C1位</td>
      <td>D2位</td>
    </tr>
    <tr>
      <td>A4位</td>
      <td>B1位</td>
      <td>C2位</td>
      <td>D3位</td>
    </tr>
 </table>
2.
 <table>
    <tr>
      <td>A1位</td>
      <td>B4位</td>
      <td>C2位</td>
      <td>D3位</td>
    </tr>
    <tr>
      <td>A2位</td>
      <td>B1位</td>
      <td>C3位</td>
      <td>D4位</td>
    </tr>
    <tr>
      <td>A3位</td>
      <td>B2位</td>
      <td>C4位</td>
      <td>D1位</td>
    </tr>
    <tr>
      <td>A4位</td>
      <td>B3位</td>
      <td>C1位</td>
      <td>D2位</td>
    </tr>
 </table>
3.
 <table>
    <tr>
      <td>A1位</td>
      <td>B3位</td>
      <td>C4位</td>
      <td>D2位</td>
    </tr>
    <tr>
      <td>A2位</td>
      <td>B4位</td>
      <td>C1位</td>
      <td>D3位</td>
    </tr>
    <tr>
      <td>A3位</td>
      <td>B1位</td>
      <td>C2位</td>
      <td>D4位</td>
    </tr>
    <tr>
      <td>A4位</td>
      <td>B2位</td>
      <td>C3位</td>
      <td>D1位</td>
    </tr>
 </table>
ルールを満たすパターンとしてはもっと沢山挙げることができるはずですが、この3パターンでもルールを満たす相手全員と当たる可能性が均等である点、これ以上増やすと記述が大変になる点を考慮して3つでも構わないという判断をしました。

また、大会のシステムを刷新するにあたって最終戦までの順位決定方法が勝ち点制になるため、途中の順位の可視化も図りたいと考えました。今自分が何位なのかわからないと試合に集中できませんしね。

## ツールの構造を考える

実現したい要件が定まったので、次にどういった構造で実現するのかについて考えます。DiscordBotを作成するにあたって、次の記事を大いに参考にさせていただきました。(というか基幹の部分はそのまま使わせていただきました)

https://note.com/exteoi/n/nf1c37cb26c41

サーバーは記事と同様にGlitchを使用し、GASのトリガーを用いて5分毎に叩き起こしています。
点数計算や卓のシャッフル等メインとなる処理はGASを記述しスプレッドシートと連携することで実現しました。Glitch上では特にメインとなる処理は行っておらず、Discordの入力を認識してGASに変数を渡す、GASから帰ってきたデータを整理してテキストチャンネルに発言するという役割を担わせています。

初期段階ではDiscordのテキストチャンネル欄で対話形式で参加者の登録、点数の入力まで行う予定でしたが、前者は大会前に予め入力作業を行うことができ、また後者はテンプレートの指定が必要なため誤作動のリスクを考慮して比較的早い段階でスプレッドシートへの直接入力へ切り替えました。(後々調べてみるとGlitchとGAS間のデータのやり取りが複雑だったので結果的にこの形にして正解でした)

簡単に図式化すると、
![mjglaph.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2253460/8ce347b7-7086-69e9-498f-1c94dc1dc5e5.png)
こんな感じになります。
GlitchはDiscordのテキストチャンネルを監視しコマンドを検出したらGASにPost、GASはPostされた値に応じてスプレッドシートからデータを取得し、Glitchに返します。同時にシートの編集も行っています。
GASから帰ってきた値はGlitchでテキスト化されDiscordのテキストチャンネルに送信されます。

## 実際に作成したスクリプト
Glitch側はスクリプトを大きく引用したためここに記載することはできませんが、GASは自筆のため以下に掲載します。
また、使用したスプレッドシートのコピーはこちら

https://docs.google.com/spreadsheets/d/1pEMlMig-3cjKXCmw3DZOmiLVg4xHLT6SYNhzyl1mI3I/edit?usp=sharing

```JavaScript:doPost.js
function doPost(e) //Glitchとのやり取りを行う関数
{
  let parameter = {}; // post値を受け取った時
  try{
    parameter = JSON.parse(e.postData.contents);
  }catch(e){
    // エラー処理
  }
  // 通常処理
  const obj = {};
  //parameter = "rn"
  if (parameter == 1)
  {
    const table1 = Round1();
    return ContentService.createTextOutput(JSON.stringify(table1));
  }
  else if(parameter >= 2 && parameter <= 4)
  {
    const tableN = RoundN(parameter);
    return ContentService.createTextOutput(JSON.stringify(tableN));
  }
  else if(parameter == 99)
  {
    const leaderBoard = GetLeader();
    return ContentService.createTextOutput(JSON.stringify(leaderBoard));
  }
}
```
Glitchからコマンド毎に変数をPostし、doPost関数で受け取った値に応じてスクリプト内の関数を動かしています。
GlitchからPostした変数ですが、 **現在が何試合目であるか** を表しています。1試合目で合った場合は参加者をランダムにシャッフルするRound1関数を、2‐4であった場合は1試合前の結果を参照して組分けを行うRoundN関数を呼び出します。
```JavaScript:Round1.js
function Round1(){
  const SpreadSheet = SpreadsheetApp.getActiveSpreadsheet(); //スプレッドシートを定義
  var namesheet = SpreadSheet.getSheets()[2];                //使用するシート
  var namerange = namesheet.getRange("A2:A17");              //データを取り込む範囲
  var namelist = namerange.getValues();                      //データを配列として取得

  Logger.log(namelist);

  for(i = namelist.length - 1; i > 0; i--) //namelistのシャッフルを実行
  {
    var j = Math.floor(Math.random() * (i + 1));
    var tmp = namelist[i];
    namelist[i] = namelist[j];
    namelist[j] = tmp;
  }
  Logger.log(namelist);

  var tablesheet = SpreadSheet.getSheets()[1];
  var tablerange = tablesheet.getRange("B2:B17");
  tablesheet.getRange(2,2,namelist.length, namelist[0].length).setValues(namelist); //シャッフル後の値を対戦表に出力

  var owntable = namesheet.getRange("B2:B17");
  var tablelist = owntable.getValues();
  var pointsheet = SpreadSheet.getSheets()[0];
  pointsheet.getRange(2,13,tablelist.length, tablelist[0].length).setValues(tablelist); //参加者名簿に対戦卓リストを出力
  round = 1;
  return namelist;
}
```

```JavaScript:RonudN.js
function RoundN(round)
{
  const SpreadSheet = SpreadsheetApp.getActiveSpreadsheet();
  var calcsheet = SpreadSheet.getSheets()[3];
  var Tsheet = SpreadSheet.getSheets()[1];
  for(let i = 2;i<=17;i++) //指定範囲に空白のセルがないか確認
  {
    var bCheck = Tsheet.getRange(i,round * 3 - 2,1,1);
    if(bCheck.isBlank== true) break; //空白が存在した場合trueを代入して処理を中断
  }

  if(bCheck.isBlank() == false) //テーブルのシャッフルを実行
  {
    var A1,A2,A3,A4,B1,B2,B3,B4,C1,C2,C3,C4;
    A1 = calcsheet.getRange(2,(round - 1) * 2,1,1).getValues(); //getRange(開始列,開始行,列範囲,行範囲)
    A2 = calcsheet.getRange(3,(round - 1) * 2,1,1).getValues();
    A3 = calcsheet.getRange(4,(round - 1) * 2,1,1).getValues();
    A4 = calcsheet.getRange(5,(round - 1) * 2,1,1).getValues();
    B1 = calcsheet.getRange(6,(round - 1) * 2,1,1).getValues();
    B2 = calcsheet.getRange(7,(round - 1) * 2,1,1).getValues();
    B3 = calcsheet.getRange(8,(round - 1) * 2,1,1).getValues();
    B4 = calcsheet.getRange(9,(round - 1) * 2,1,1).getValues();
    C1 = calcsheet.getRange(10,(round - 1) * 2,1,1).getValues();
    C2 = calcsheet.getRange(11,(round - 1) * 2,1,1).getValues();
    C3 = calcsheet.getRange(12,(round - 1) * 2,1,1).getValues();
    C4 = calcsheet.getRange(13,(round - 1) * 2,1,1).getValues();
    D1 = calcsheet.getRange(14,(round - 1) * 2,1,1).getValues();
    D2 = calcsheet.getRange(15,(round - 1) * 2,1,1).getValues();
    D3 = calcsheet.getRange(16,(round - 1) * 2,1,1).getValues();
    D4 = calcsheet.getRange(17,(round - 1) * 2,1,1).getValues();

    Logger.log(round);

    var MatchTable1 = [A1,B2,C3,D4,A2,B3,C4,D1,A3,B4,C1,D2,A4,B1,C2,D3];
    var MatchTable2 = [A1,B4,C2,D3,A2,B1,C3,D4,A3,B2,C4,D1,A4,B3,C1,D2];
    var MatchTable3 = [A1,B3,C4,D2,A2,B4,C1,D3,A3,B1,C2,D4,A4,B2,C3,D1];

    var x = 1;
    var y = 3;
    var table = Math.floor( Math.random() * (y - x) + x ); //1-3の乱数を生成
  
    var NextTable;
    if (table == 1)
    {
      NextTable = MatchTable1
    }
    else if(table == 2)
    {
      NextTable = MatchTable2
    }
    else if(table == 3)
    {
      NextTable = MatchTable3
    }
    else
    {
      Logger.log("MathrandomErr")
    }

    Logger.log("table=" + table + "," + NextTable);
  
  
    Tsheet.getRange(2,round * 3 - 1,NextTable.length, NextTable[0].length).setValues(NextTable);
    var nameRange = Tsheet.getRange(2,round * 3 -1,16,1);
    var nextTable = nameRange.getValues();
  
    return nextTable;
  }
  else
  {
    return  "err";
  }
};
```
RoundN関数はかなり冗長になってしまいました…間違いなくもっと読みやすくできるので、次回への課題としておきます。

Postされた変数が99の場合はGetLeader関数でシートを参照し順位表を出力する動作を割り当てました。
```JavaScript:GetLeader.js
function GetLeader() //順位表を取得する関数
{
  const SpreadSheet = SpreadsheetApp.getActiveSpreadsheet();
  var Lsheet = SpreadSheet.getSheets()[0];
  var Lrange = Lsheet.getRange("P2:P17");
  var Llist = Lrange.getValues();
  return Llist;
}
```
後述していますが、下記の[!順位決定戦]コマンドに関してのみ[!順位表]コマンドと同様にGetLeader関数を呼び出し、Glitch上で値を組み替えて出力しています。

最低限動作するクオリティまで仕上がった段階で埋め込みテキストを使用したいと感じ、実装することにしました。Embedなどとも言われているようですが、ブログ等を書いたことがある方ならリンクの埋め込みをする際に使用したことがあるかもしれませんね。Discordだとこういった感じになります。
![mj1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2253460/c9a99ecd-b9ee-3a94-c00a-9836a799338b.png)
この表示形式はBotでしか使用できないらしく、それまでは処理結果をただのテキストとして長ったらしく出力していたためスマートに表記できる埋め込みテキストに挑戦してみました。

そんなに難しくないこともあってあっさり実装することができました。埋め込みテキストは表示がスマートなだけでなく可読性も非常によいため大満足しています。今回はデータの出力のみでしたが、多くの情報を埋め込むこともできるようでまた活用してみたいなと思っています。

## 機能の紹介

基本的に、dirscordでテキストチャンネルに送信されたメッセージを参照して起動し、処理結果を吐き出すという動作になっています。
[参加者一覧]シートに参加者を予め入力しておき、対局が終了したら[対戦表]シートに点数を手動で入力する想定で作成しています。

* [!新規対局]
参加者一覧を取得し、参加者をランダムにシャッフルして対戦卓を生成します。
入力した瞬間に卓がシャッフルされちゃうので暫定的に自分の権限でしか使用できないように設定しています。

* [!次対局]
前試合の順位をもとに既定のテーブルを用いて新たな対戦卓を生成します。前試合の結果の入力が確定していない場合(シートの点数入力部に空欄がある)とエラーを吐くようにしてあります。

* [!順位決定戦]
順位決定戦の対戦卓を生成します。このコマンドのみ、順位表を取得して生成します。
以上3種は埋め込みテキスト例のような形で出力されます。

* [!順位表]
現在の暫定順位を表示します。
以下のような形で出力されます。
![mj2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2253460/74d0ef70-7fad-6f2e-dde2-5f4a73103b28.png)


## 実際に運用してみて
初期段階では「麻雀大会計算機」という名前で運用していましたがなんだか味気ないので某キャラクターをもじった名前にしたところ若干ウケてちょっと嬉しかったです。
そんな話はさておき、本番ではとりあえず問題なく動作しました。点数周りの計算が狂うと順位に影響があるため大会運営メンバーとの動作確認の際は実際の試合結果を用いて念入りに確認していました。
本番に発生したトラブルとして、対戦組み合わせ決定(!新規対局)を行った後にBotの設定変更を行ったため保持していたステータスがリセットされており、[!次対局]コマンドが反応しませんでした。対戦卓と1試合目のデータを別の場所に退避させ[!新規対局]コマンドを実行し、退避させたデータを戻すことで解決することができました。

## 反省点

* とにかく時間が掛かりすぎた。就活引っ越し資格勉強等あったが伸ばしすぎたせいで実際ダレていたのでよくなかったと感じる。
* 自動化ツールではあるものの手作業に頼らなければならない部分を残してしまっていること。
* コードの引用をする際途中までコピペをしてしまっていたためあまり身につかなかった。修正時に括弧の数が合わなくなるなど非効率だった。
* (主にGlitch側の)ソースコードが冗長だった。各機能自体は似ていたりするためソースコードにも同じ部分が多く、無駄に長くなってしまったのでもっと関数など活用しつつ、読みやすく無駄の少ないコーディングができるようになりたい。
* 3戦目に4人全員が初戦と同じだった卓が発生した。初戦は完全にランダムに生成しているためテーブルの少なさが影響しているかは不明。箱の数に応じてどのような影響があるか比較検討したい。
* 麻雀ソフトの仕様上、同点になった場合、親に近い側の順位が高くなるが入力時にその使用を反映できなかった(獲得点数に1を加えて処理し、最終順位で1桁の点差がないか確認)
* ~~Botは機能したが肝心の麻雀大会が最下位だった。~~

## 感想
もともと何かコーディングをしてみたいとは思っていたものの、なかなかアイディアが浮かばなかったのでこのような機会があって本当にラッキーだなと感じています。GlitchとGASの連携、スプレッドシートを活用して実現できることなど多くの学びがありました。Discordは使用頻度も多いのでBot作成を通じて勉強しながらDiscordを便利に活用する機会があればまた挑戦してみたいなと感じています。

拙いコード、文章ですが最後までお読みいただきありがとうございました。質問、意見等ありましたらお気軽にお寄せください。

## 参考文献

https://note.com/exteoi/n/nf1c37cb26c41

https://qiita.com/nedew/items/4e0c20c1a89e983a6992