Slackで人狼がしたいねん

こんにちは。
初めてのAdvent Calendarの参加です。

:::note warn
記事中のコード内には独自のメソッドやクラスがありますが、いちいち説明してません。
気になる方はコメントください。
:::

#はじめに

/* 読み飛ばしてもなんの問題もありません。 */

2020年、リモートワークが始まり、慣れてきた2021年のこと。
ふと思いました。

**人狼がやりたい。**

しかし、人狼をやろうと言い出すと、言い出しっぺは大概ゲームマスター（GM）をやらなければいけない。
いやだ。
私が人狼ゲームを楽しみたいのだ。

**ならばslackbot（App）にGMをやってもらおう**

人狼ゲームは1ゲームに結構時間がかかる。
人数も揃えなければいけない。
予定の調整は誰がやるのか？
言い出しっぺだ。
いやだ。
私はやりたくない。

**ならば、リアルタイムで毎日人狼をやるのだ。**

そうして私はリアルタイムで進む人狼ゲームをSlack上で作ることを決意したのだ。

#Slack人狼のルール

今でこそ役職はそれなりに選択できるように色々バリエーションを作ったが、最初は基本的な役職のみ。人数も9人に限定した。

人狼2名、占い師1名、霊媒師1名、騎士1名、村人3名。

SlackにはSlack人狼用にチャンネルを3つ作った。

<dl>
<dt>村チャンネル</dt>
<dd>基本的にゲームはこのチャンネルで進められる。
村人同士の議論やGMから全体への通知はこのチャンネルでおこなう。</dd>
<dt>人狼チャンネル</dt>
<dd>人狼だけが参加できるチャンネル。
人狼同士の会話はこのチャンネルでおこなう。</dd>
<dt>墓チャンネル</dt>
<dd>処刑されたり襲撃された人が会話できるチャンネル。
死んだ人はこのチャンネルでのみ会話可能。</dd>
</dl>
ゲーム終了後に人狼同士の会話や、早々に処刑や襲撃されてしまった人同士の会話を見れるようにすべてパブリックチャンネルで作った。
~~プライベートチャンネルの参加人数を0人にできないので、仕方なくパブリックにした面も無きにしもあらず…~~

- ゲーム開始時には全員が村チャンネルに追加される。
- 人狼のみ人狼チャンネルに追加される。
- 墓チャンネルからは全員が追放される。
- ゲーム中に死んだ人は墓チャンネルに追加される。
- 村チャンネルや人狼チャンネルから抜けることはないが、会話は禁止。（ここは運用カバー）
（ゲームの動向はみたいもんね。）

##ゲームの流れ

###朝の開始

GM（App）より村チャンネルに襲撃結果の通達が行われる。

###朝の時間
夜になるまで、村人同士による会話を村チャンネルでおこなう。
また、誰か一人に投票を行う。
投票はAppのホーム画面からおこなう。
自分の投票履歴はホーム画面で確認することができる。

###夜の開始

GMより村ちゃんねるに投票結果の通達が行われる。
処刑対象者は墓チャンネルに移動（追加）する。
霊媒師には処刑対象者の人狼か否かが通達される。（AppよりDMで）

###夜の時間
朝になるまでに役職者はそれぞれ能力を使用する。

- 人狼：襲撃対象の決定
- 占い師：占い対象の決定
- 騎士：護衛対象の決定

能力の使用はすべてAppのホーム画面よりおこない、その結果はDMで通知される。
（過去の能力の使用結果はホーム画面に出てくる）

###そしてまた朝がやってくる…

うちの場合はこれを

- 朝の開始：11:00
- 夜の開始：19:00

として、リアルタイムでやりました:smile:
あ、もちろん休みの日はなしで！笑

#実装だ！

今回Appに実装するのは以下の機能である。

- ゲーム開始時の設定諸々
- 役職の配布
- チャンネルの移動
- ホーム画面の表示制御
- 役職者の能力行使
- 投票

ちなみに、朝・夜の開始はWeb-APIで実装し、タイマーで実行！

実装はNode.jsとBolt for JavaScriptでHerokuにぶち上げて、DBはHerokuのPostgreSQLでやっております。
またSlackに関する記事なので、人狼の仕組みの部分は省いておりますのであしからず:sorry:

##Home画面の実装

Appのホーム画面を開いた時にDBから諸々情報を引っ張ってこないといけないので、まずはそこを実装します。

```js:app.js
app.event('app_home_opened', appHome.openedAppHome);
```

`app_home_opened` をサブスクライブすることでAppのホーム画面をユーザーが開いた時にイベントを実行することができます。

```js:appHome.js
openedAppHome = async ({ body, client }) => {
  if (body.event.tab) {
    await this.refreshAppHome(client, body.event.user);
  }
};
```

**いるのかどうかは知りませんが**`body.event.tab`に値が入っているときのみ処理を実行するようにしています。
Appを開いたユーザーによってホーム画面を出し分けたいので、`body.event.user`のユーザーIDを処理に渡すようにしています。

基本的には以下のようにホーム画面に表示したい`Block`を渡せばいいだけです。

```appHome.js
client.views.publish({
  token: COMMON.ENV.SLACK_BOT_TOKEN,
  user_id: user,
  view: {
    type: 'home',
    blocks: [],
  }
});
```
※たぶん`token`はいらない

以下は色んなパターンでホーム画面を表示しています。

###ゲームが始まっていないとき

ゲームが始まっていない時はスタートボタンを表示しています。
![ゲームスタート](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193977/b42d52dc-8528-97d2-fe92-95626ea6d016.png)
<details><summary>興味がある人はコードをどうぞ</summary><div>

```js:appHome.js
refreshAppHome = async (client, user) => {
  /* 中略 */
  if (gameStart === 0) {
        // ゲームが始まっていないときはゲームスタートボタンを表示
        await client.views.publish({
          token: COMMON.ENV.SLACK_BOT_TOKEN,
          user_id: user,
          view: {
            type: 'home',
            blocks: [
              {
                type: 'actions',
                elements: [
                  {
                    type: 'button',
                    text: {
                      type: 'plain_text',
                      text: 'GAME START :video_game:',
                      emoji: true,
                    },
                    action_id: 'app_home_clicked_game_start',
                    style: 'primary',
                  },
                ],
              },
            ],
          },
        });
        return;
      }
}
```

</div></details>

###ゲームは始まっているが自分は参加者じゃないとき

SlackのユーザーIDで参加者かどうかを判断しています。

![ゲーム不参加](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193977/97a19312-8631-4aec-1b30-174e6ec9f25e.png)

###ゲーム参加者のとき

タイミングによって表示される項目は変わります。
朝の時間だと投票が、夜の時間だと役職者は能力行使のボタンが出たりします。

EX1）占い師の昼
![占い師の昼](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193977/12afa724-ded3-de6c-1e8f-c7a00e7181eb.png)

EX2）占い師の夜
![占い師の夜](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193977/5e0cc4e4-210e-1f53-b5c6-dcea99550d89.png)

<details><summary>興味がある人はコードをどうぞ</summary><div>

```appHome.js
else if (player.role_id === COMMON.ROLE.FORTUNE_TELLER.id || player.role_id === COMMON.ROLE.SAGE.id) {
  /**
  * 占い師・賢者の場合
  * 1. 日付・役職の表示
  * 2. 投票可能な場合→投票ボタン
  * 3. 能力使用可能な場合→占いボタン
  * 4. 占い結果の表示
  * 5. 生存プレイヤーの表示
  * 6. ゲーム強制終了ボタン
  */
  const isSage = player.role_id === COMMON.ROLE.SAGE.id;
  const sqlFortune = `SELECT ability.days, ability.target_user_id, player.name, role.is_jinro, role.role FROM ability INNER JOIN player ON ability.target_user_id = player.user_id INNER JOIN role ON player.role_id = role.id WHERE ability.role_id = ${player.role_id} ORDER BY ability.days;`;
  const resultFortune = await this.db.query(sqlFortune);
  const fortuneBlock = [
    COMMON.makeHeaderBlock(':crystal_ball: 占い結果 :crystal_ball:'),
    COMMON.makeMrkdwnBlock(
      resultFortune.results.length === 0
        ? 'なし'
        : resultFortune.results
          .map(
            fortune =>
              `${fortune.days === 0
                ? '初日ランダム'
                : `${fortune.days}日目`
              }：${fortune.name}：${isSage ? `${fortune.role}です` :
                fortune.is_jinro === 1
                  ? '人狼です'
                  : '人狼ではありません'
              }`
          )
          .join('\n')
    ),
    COMMON.makeDividerBlock(),
  ];
  if (
    usingAbility === 1 &&
    player.is_living === 1 &&
    resultFortune.results.every(fortune => fortune.days !== days)
  ) {
    abilityBlock.push({
      type: 'section',
      text: {
        type: 'mrkdwn',
        text: `【${isSage ? '賢者' : '占い師'}】占いたい人を選択することができます。`,
      },
      accessory: {
        type: 'button',
        text: {
          type: 'plain_text',
          text: '占い先を選択する',
          emoji: true,
        },
        value: `${days}`,
        action_id: 'app_home_clicked_fortune',
      },
    });
  }
  if (abilityBlock.length === 1) {
    abilityBlock.push(COMMON.makeMrkdwnBlock('なし'));
  }
  abilityBlock.push(COMMON.makeDividerBlock());

  await client.views.publish({
    token: COMMON.ENV.SLACK_BOT_TOKEN,
    user_id: user,
    view: {
      type: 'home',
      blocks: [
        ...daysRoleBlock,
        ...livingPlayersBlock,
        ...abilityBlock,
        ...votingBlock,
        ...fortuneBlock,
        ...shutdownBlock,
      ],
    },
  });
  return;
}
```

</div></details>

##ホーム画面にあるボタンの動きの実装

ホーム画面が実装出来たらもう勝ちです。
あとは各ボタンを押したときにサーバーにどう処理してもらうか、だけなのでね。

今回はだいたいホーム画面のボタンを押すとモーダルがあがってくるように作られています。

```javascript
{
  type: 'actions',
  elements: [
    {
      type: 'button',
      text: {
        type: 'plain_text',
        text: 'GAME START :video_game:',
        emoji: true,
      },
      action_id: 'app_home_clicked_game_start',
      style: 'primary',
    },
  ],
}
```

`button`には`action_id`を設定しており、この`action_id`によって、サーバー側で実行する処理を決めています。

```javascript:app.js
app.action('app_home_clicked_game_start', gameInit.gameStartFormOpen);
```

例えば、昼間の投票ボタンでは
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193977/ab2b3cfe-e58f-654a-d8fe-c820444b0586.png)
「投票する」ボタンを押すと
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193977/674aa592-04cf-8c0a-6ff0-92ab1e457a41.png)
このようにモーダルがあがります。

<details><summary>興味がある人はコードをどうぞ</summary><div>

```app.js
app.action('app_home_clicked_voting', voting.votingFormOpen);
```

```voting.js
const votingView = {
  type: 'modal',
  callback_id: 'home_app_voting_view',
  title: {
    type: 'plain_text',
    text: '投票',
    emoji: true,
  },
  submit: {
    type: 'plain_text',
    text: '投票する',
    emoji: true,
  },
  close: {
    type: 'plain_text',
    text: 'キャンセル',
    emoji: true,
  },
  private_metadata: `${body.actions[0].value}`,
  blocks: [
    COMMON.makeMrkdwnBlock(
      ':warning: 投票対象および投票理由は、投票結果として全プレイヤーに公開されます。'
    ),
    {
      type: 'input',
      block_id: 'select_voted_user',
      element: {
        type: 'static_select',
        placeholder: {
          type: 'plain_text',
          text: '投票する人を選択',
          emoji: true,
        },
        options: resultLiving.results.map(player => {
          return {
            text: {
              type: 'plain_text',
              text: player.name,
              emoji: true,
            },
            value: player.user_id,
          };
        }),
        action_id: 'voted_user_id',
      },
      label: {
        type: 'plain_text',
        text: '投票対象',
        emoji: true,
      },
    },
    {
      type: 'input',
      block_id: 'input_voted_reason',
      element: {
        type: 'plain_text_input',
        multiline: true,
        action_id: 'voted_reason',
      },
      label: {
        type: 'plain_text',
        text: '投票理由',
        emoji: true,
      },
    },
  ],
};
await client.views.open({
  // 適切な trigger_id を受け取ってから 3 秒以内に渡す
  trigger_id: body.trigger_id,
  // view の値をペイロードに含む
  view: votingView,
});
```
</div></details>

##モーダルの動きの実装
先ほどのモーダルの「投票する」ボタンを押したときの動きはホーム画面のボタンと似たような感じですが、今回は`callback_id`に設定しており、これをキャッチします。

```voting.js
const votingView = {
  type: 'modal',
  callback_id: 'home_app_voting_view',
  /* 以下略 */
}
```

```app.js
app.view('home_app_voting_view', voting.voting);
```

あとはサーバー側でロジックをゴリゴリ書いています。

#さいごに
Slackで人狼をやるためにGMをアプリで作ったわけですが、基本的なホーム画面からの動きを押さえておけば、ロジックさえ組んでしまえばどんなゲームでもSlack上に作れてしまうことがわかりました。

色々なボードゲームが好きな私ですので、人狼の職業をアップデートしながら、次のSlackゲームの開発に勤しみたいと思っております！

（記事の後半の尻すぼみ感が半端ないですがご勘弁ください。）

はじめてSlack Advent Calendarに参加しましたが、なんとか間に合わせることができてよかったです。

みなさんもSlackでゲームを作りましょう！！