ChallongeとGASによるトーナメント運営の自動化

これは[Supershipグループ Advent Calendar 2020](https://qiita.com/advent-calendar/2020/supership)の10日目の記事です。

# はじめに
Supershipには部活動制度が存在し、多くの社員が自主的に参加しています。私は内定者アルバイトの頃からボードゲーム部に参加しており、その活動が楽しみのひとつになっていました。しかしながら、昨今の情勢から社員がひとところに集まる機会が少なくなり、ボードゲーム部は従来の形で活動することが困難になりました。

ボードゲーム部の新たな活動の形として、例えばオンライントーナメントを開催することが考えられます。そこでこの記事ではChallongeとGoogle Apps Script (GAS)を用いたトーナメント運営の手軽な自動化について紹介します。

# Challongeの準備
[Challonge](https://challonge.com/)はトーナメントを自動作成、公開できるWebアプリケーションで、オンラインゲームでの利用例が多くあります。また、トーナメントの参加者登録、結果入力などの操作がWeb上のダッシュボードのほかにREST APIである[Challonge API](https://api.challonge.com/ja/v1)経由でも可能で、運営の自動化に向いたサービスになっています。

準備としてChallongeのアカウントを取得し、Single Elimination形式[^1]のトーナメントを作成しておきます。このとき、「基本情報 - URL」部分に表示される文字列がAPI操作時に必要になるのでメモしておきましょう。[^2]
![無題.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/920801/6f2ea253-6d1c-a6a0-5471-b25eedc6c1e4.png)
また、テスト用に作成する場合は最下部の「Advanced Options - Permissions」の「イベントを検索エンジンやChallongeの表示可能なリストから除外する」にチェックを入れておくと良いでしょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/920801/2e0c138f-06af-c2a5-2f54-6ff33262e774.png)
「保存して続行」をクリックするとトーナメントが作成されますが、参加者が登録されていないのでまだトーナメント表は表示されません。参加者はダッシュボードの「参加者」タブから手動で登録することもできますが、まだ行いません。
![a.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/920801/1d83541a-4b4c-775e-62e7-68a1bed23dd9.png)


最後に、アカウント設定の「デベロッパーAPI」からAPIキーの発行を受けます。

# 参加者登録
参加者登録用のフォームをGoogle Formで作成し、参加者登録用のフォームを作成します。フォーム送信時にスクリプトからChallonge API経由で参加者の表示名を登録し、Challonge上で用いられる参加者IDをSpreadsheetに記録します。

最初にChallonge上での表示名を記入させるフォームと回答集計用のSpreadsheetを作成しておきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/920801/55098764-0fe2-bda8-7573-f033739bebd7.png)

Spreadsheetのスクリプトエディタを開き、例えば以下のようなコードを書いて「スプレッドシートから - フォーム送信時」イベントで関数 `register` が実行されるようにしておきます。このとき、アプリケーションの承認が必要になることがあります。

このスクリプトではリクエスト `POST https://api.challonge.com/v1/tournaments/{TOURNAMENT_ID}/participants.json` を送信して参加者登録を行い、そのレスポンスから参加者IDを得ています。置き換えが必要な変数は以下の通りです。

- `CHALLONGE_API_KEY`: Challongeで発行されたAPIキーで置き換えます。
- `TOURNAMENT_ID`: 「Challongeの準備」の節でメモしたURLで置き換えます。
- `REGISTER_SPREADSHEET_ID`: 参加者登録用のSpreadsheetのIDで置き換えます。

```js:register.gs
const CHALLONGE_API_KEY = "----------------------------------------";
const TOURNAMENT_ID = "--------";
const REGISTER_SPREADSHEET_ID = "--------------------------------------------";
function register(e) {
  const register_sheet = SpreadsheetApp.openById(REGISTER_SPREADSHEET_ID).getSheets()[0];
  const challonge_name = register_sheet.getRange(register_sheet.getLastRow(), 2).getValue();
  const challonge_id = JSON.parse(UrlFetchApp.fetch("https://api.challonge.com/v1/tournaments/" + TOURNAMENT_ID + "/participants.json", {
    "muteHttpExceptions": true,
    "contentType": "application/json",
    "method": "post",
    "payload": JSON.stringify({
      "api_key": CHALLONGE_API_KEY,
      "participant": {
        "name": challonge_name
      }
    })
  }))["participant"]["id"];
  register_sheet.getRange(register_sheet.getLastRow(), 3).setValue(challonge_id);
}
```


この段階でフォームから参加者の情報を送信すると、自動的にChallongeへの参加者登録が行われ、Spreadsheetに集められた回答の表示名の隣の列にChallonge上での参加者IDが記録されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/920801/afe03688-b5e8-9da7-5793-b4bda6c0bd8b.png)
2人以上の参加者を登録するとChallonge上でトーナメント表が作成され、player1, player2, player3, player4の4人を登録するとSpreadsheetとChallonge上の表示は例えばこのようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/920801/0e84d544-daec-aac0-65b4-4c7b70deb8cc.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/920801/7025676f-7d77-5df6-d3a5-39bff15da004.png)


参加者が出そろったらダッシュボードから「トーナメントの開始」ボタンを押しましょう。これを押すまでは試合結果の入力はできません。

# 試合結果の報告

試合結果の報告もGoogle Form経由で行えるようにします。参加者登録フォームと同じように、結果報告用のフォームと回答集計用のSpreadsheetを作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/920801/462e94ec-6f9a-9702-a0e8-0fbf290ca249.png)

Spreadsheetのスクリプトエディタを開き、以下のようなコードを書いて「スプレッドシートから - フォーム送信時」イベントで関数 `submitResult` が実行されるようにしておきます。

このスクリプトでは、まずChallonge APIから試合結果の入力が可能な試合[^3]のJSON配列を得て、結果報告フォームの回答と参加者登録フォームの回答を突き合わせて得た参加者IDをもとに結果を入力すべき試合を決定し、実際に入力を行います。置き換えが必要な変数は以下の通りです。

- `CHALLONGE_API_KEY`: Challongeで発行されたAPIキーで置き換えます。
- `TOURNAMENT_ID`: 「Challongeの準備」の節でメモしたURLで置き換えます。
- `REGISTER_SPREADSHEET_ID`: 参加者登録用のSpreadsheetのIDで置き換えます。
- `RESULT_SPREADSHEET_ID`: 結果報告用のSpreadsheetのIDで置き換えます。

```js:submitResult.gs
const CHALLONGE_API_KEY = "----------------------------------------";
const TOURNAMENT_ID = "--------";
const REGISTER_SPREADSHEET_ID = "--------------------------------------------";
const RESULT_SPREADSHEET_ID = "--------------------------------------------";

function submitResult(e) {
  const sheet = SpreadsheetApp.openById(RESULT_SPREADSHEET_ID).getSheets()[0];
  const winner = sheet.getRange(sheet.getLastRow(), 2).getValue();
  const loser = sheet.getRange(sheet.getLastRow(), 3).getValue();
  const lookup_sheet = SpreadsheetApp.openById(REGISTER_SPREADSHEET_ID).getSheets()[0];
  const arr = lookup_sheet.getDataRange().getValues();
  var winner_challonge_id = 0;
  var loser_challonge_id = 0;
  for (let i = 1; i < arr.length; i++) {
    if (arr[i][1] === winner) {
      winner_challonge_id = arr[i][2];
    } else if (arr[i][1] === loser) {
      loser_challonge_id = arr[i][2];
    }
  }
  Logger.log(winner_challonge_id);
  Logger.log(loser_challonge_id);
  const challonge_matches = JSON.parse(UrlFetchApp.fetch("https://api.challonge.com/v1/tournaments/" + TOURNAMENT_ID + "/matches.json?state=open&api_key=" + CHALLONGE_API_KEY, {
    "method": "get"
  }).getContentText());
  for (let i = 0; i < challonge_matches.length; i++) {
    if (challonge_matches[i]["match"]["player1_id"] == winner_challonge_id && challonge_matches[i]["match"]["player2_id"] == loser_challonge_id) {
      var challonge_match_id = challonge_matches[i]["match"]["id"];
      sheet.getRange(sheet.getLastRow(), 4).setValue(challonge_match_id);
      const challonge_status = JSON.parse(UrlFetchApp.fetch("https://api.challonge.com/v1/tournaments/" + TOURNAMENT_ID + "/matches/" + challonge_match_id + ".json", {
        "method": "put",
        "contentType": "application/json",
        "payload": JSON.stringify({
          "api_key": CHALLONGE_API_KEY,
          "match": {
            "scores_csv": "1-0",
            "winner_id": winner_challonge_id
          }
        })
      }).getContentText());
      break;
    } else if (challonge_matches[i]["match"]["player2_id"] == winner_challonge_id && challonge_matches[i]["match"]["player1_id"] == loser_challonge_id) {
      var challonge_match_id = challonge_matches[i]["match"]["id"];
      sheet.getRange(sheet.getLastRow(), 4).setValue(challonge_match_id);
      const challonge_status = JSON.parse(UrlFetchApp.fetch("https://api.challonge.com/v1/tournaments/" + TOURNAMENT_ID + "/matches/" + challonge_match_id + ".json", {
        "method": "put",
        "contentType": "application/json",
        "payload": JSON.stringify({
          "api_key": CHALLONGE_API_KEY,
          "match": {
            "scores_csv": "0-1",
            "winner_id": winner_challonge_id
          }
        })
      }).getContentText());
      break;
    }
  }
}
```

この状態でフォームから結果を報告すると、自動的にChallongeに試合結果が入力され、Spreadsheetの敗者の隣の列にChallonge内で使われるMatch IDが記録されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/920801/0725212a-92ba-f7ca-a06c-7f735b872ea5.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/920801/687a4700-d4df-48d5-099d-ed0275621372.png)

すべての試合が完了したら、「トーナメントの終了」ボタンを押しましょう。

# 機能の拡張

この記事では詳しく書きませんが、例えば次のような拡張が考えられます。

- 予選と本選に分け、予選で好成績を残した参加者を自動的に本選に登録する。
- 試合のログが公開される場合は、GAS経由でログを自動的に取得しその内容 (勝敗、スコアなど) を反映する。



# 注意点

- 今回紹介したスクリプトには悪意のある入力に対して脆弱である、エラーハンドリングが不十分であるなど洗練されていない部分が多くあります。
- 大規模な大会を開く場合は‎GASの実行時間の制限に注意し、場合によってはGAS以外の選択肢もご検討ください。

# おわりに

この記事ではChallongeとGASを用いたトーナメント運営の自動化について紹介しました。それほど大きな手間をかけることなく運営の自動化ができるので、ゲームの種類を問わずオンラインで大会を開く場合は検討の価値があると考えられます。

【Supership】ではプロダクト開発やサービス開発に関わる人を絶賛募集しております。
ご興味がある方は以下リンクよりご確認ください。
 【[Supership株式会社 採用サイト](https://supership.jp/recruit/)】
是非ともよろしくお願いします。


[^1]: 最も一般的なトーナメント形式で、一度でも敗北した参加者は以降の試合に参加できません。
[^2]: トーナメント作成後にブラウザのアドレスバーからコピペしてもかまいません。
[^3]: 試合結果の入力ができない試合は、すでに結果が入力済みの試合と対戦カードが確定していない試合です。