misskeyでリバーシbotを作ったのでリバーシAPIまとめ

最近リアルが忙しい六角レンチです
地球寒すぎませんかね？
それはさておきぶろみね君にリバーシ機能を追加したのでリバーシのAPIがどんな感じなのかを記事にしたいと思います

# 事の発端
misskeyでリバーシが復活。

それに合わせて藍ちゃんとのリバーシ対戦も復活したという情報を聞く
(どうやらioの藍ちゃんはアップデートしてないからまだできないらしい)

藍ちゃんはbotで、リバーシ対戦ができる。ということはリバーシのAPIがあるのでは...?

その謎を解明するため、六角レンチはアマゾンの奥地へと向かった――。
<br></br><br></br>
はい。

# 作ったbot
アカウント
https://misskey.io/@bromine35
リポジトリ
https://github.com/35enidoi/bromine35bot

# 参考にした物
https://misskey-hub.net/ja/docs/for-developers/api/streaming/
misskey hubのストリーミングAPIのドキュメント

チャンネルへの接続やメッセージの送信をする時のデータの形が書いてあるので絶対読め(1敗)

https://misskey.io/api-doc
misskey.io(最新バージョン)のapiドキュメント

misskeyの最新バージョン(どちらかというとリバーシ機能がついているバージョン)に追従しているサーバーのドキュメントなら多分どれでもいい
リバーシのエンドポイント等が見れる

https://meisskey.one/docs/ja-JP/reversi-bot
昔のバージョンのmisskey(多分v13以前？)にあるドキュメント

最新バージョンにはないけど昔のmisskeyを使ってるインスタンスで`docs/ja-JP`にアクセスすると見れる(misskey.devでも見れた)

なんとここにはリバーシbotの作成手順が残っている(しかもしゅいろママの直筆)
どうやら最新バージョンで追加されたリバーシはこの時代のリバーシを復活させた感じっぽく、APIがかなり似ており、マップのデータの計算方法等は完全に同じなのですごい参考にできる

https://github.com/syuilo/ai/tree/master/src/modules/reversi
藍ちゃんの中身のリバーシ実装部分

藍ちゃんはオープンソースなので実装部分を見れる
~~履いてるパンツの色はどこにも書いてませんでした(どうして)~~

どのチャンネルに接続しているのかとかを見た

# ゲームの情報
APIにはゲームの情報が返ってくることがあります

<details><summary>データ構造はこんな感じ(長いので格納)</summary>


```
{id: ゲームのID(gameId),
createdAt : マッチした時の時間(ISO 8601),
startedAt : None,
endedAt : None,
isStarted : False,
isEnded : False,
form1 : None,
form2 : None,
user1Ready : False,
user2Ready : False,
user1Id : user1のuserID,
user2Id : user2のuserID,
user1 : user1の情報,
user2 : user2の情報,
winnerId : None,
winner : None,
surrenderedUserId : None,
timeoutUserId : None,
black : None,
bw : 'random',
isLlotheo : False,
canPutEverywhere : False,
loopedBoard : False,
timeLimitForEachTurn : 90,
noIrregularRules : False,
logs : [],
map : ['--------', '--------', '--------', '---wb---', '---bw---', '--------', '--------', '--------']}
```
</details>
以降ゲームの情報って言ってたらこれのことです

# API
## reversi/match
アクセストークンが必要です
このエンドポイントにパラメータ`userId`を設定することで招待できます
マッチしない場合（あるいは招待した場合）`204`が返ってきます
瞬時にマッチした場合、(あるいは招待に招待し返した場合)ゲームの情報が返ってきます。

## reversi/show-game
アクセストークン無しでもできます
パラメータ`gameId`が必要です
成功したらゲームの情報が返ってきます

`gameId`はゲームの識別用のIDです
例えばこのゲームのURL
https://misskey.io/reversi/g/9p0nag8f9muy0g4x
これの`9p0nag8f9muy0g4x`がゲームIDです

# websocket
リバーシはwebsocketを使って通信します
昔のmisskeyドキュメントと似たような形で通信します
## reversi
リバーシチャンネルです

昔のmisskeyドキュメントに書いてある`games/reversi`がこのチャンネルになります
### invited
他人から招待されると`invited`メッセージが流れてきます
先ほどのAPI`reversi/match`でそのユーザーを招待するまで何回も送られてきます
<details><summary>データはこんな感じ</summary>

```
{id : チャンネル識別用のid,
type : 'invited',
body : {user : 招待してきたユーザーの情報}}
```
</details>

### matched
APIの`reversi/match`で204が返ってきた後、マッチした場合`matched`メッセージが送られてきます
<details><summary>データはこんな感じ</summary>

```
{id : チャンネル識別用のid,
type : 'matched',
body : {game : ゲームの情報}}
```
</details>




## reversiGame
リバーシゲームのチャンネルです
接続するときにパラメータ`gameId`が必要なので注意

### 準備段階の時に送られてくるメッセージ
#### canceled
マッチがキャンセルされると送られます
<details><summary>データ構造</summary>

```
{id : チャンネル識別用のid,
type : 'canceled',
body : {userId: 相手のユーザーID}}
```
</details>

#### updateSettings
相手が設定を変更したときに送られてきます
keyで変わった場所、valueが内容です
<details><summary>データ構造</summary>

```
{id : チャンネル識別用のid,
type : 'updateSettings',
body : {userId: 相手のユーザーID,
        key: キーワード
        value: 値}}
```
</details>

##### map
マップ変更時のキーワードです

valueは昔のmisskeyドキュメントの**マップ情報**のところに書いてあるマップデータです。
(例えば4x4の場合、`['----', '-wb-', '-bw-', '----']`です。)

key : map
value : マップデータ(list)
##### bw
開始時にどちらが黒か(黒が先行)のキーワードです

valueは1, 2, random
1でuser1、2でuser2が先行
randomでランダムです
ただ正直この場所で自分が先行か確かめるよりも後述する`stated`メッセージで確かめる方がrandomの時にも対応できるのでそこまで重要じゃないです

key : bw
value : random | 1 | 2 (str | int)
##### timeLimitForEachTurn
一ターンの制限時間の秒数です
ただ時間切れの処理はクライアント側っぽい？
valueはintで5~3600
(一応全部書くと 5, 10, 30, 60, 90, 120, 180, 3600)

key : timeLimitForEachTurn
value : 5~3600　(int)
##### isLlotheo
ロセオ(石が少ない方が勝ち)であるかどうかです

key : isLlotheo
value : False | True (bool)
##### loopedBoard
ループボードであるかどうかです

ちなみにぶろみね君(作ったbot)はループボードの実装が難しすぎてループボード非対応です(悲しい)

key : loopedBoard
value : False | True (bool)
##### canPutEverywhere
どこにでも置けるかどうかです

key : canPutEverywhere
value : False | True (bool)
#### changeReadyStates
プレイヤーの準備ができたか(準備完了ボタンが押されたか)が**変わった**時に送られます

bot側で準備完了を送信するにはストリームにメッセージで`type="ready",body=True`を送ります
<details><summary>データで表すとこれ</summary>

```
{id: チャンネル識別用のID,
type: 'ready',
body: True}
```
もちろん準備完了を取り消す場合はbody=Falseにして送信
</details>

変わった時に送られるので相手の準備完了ボタンがまた押されたとき(準備に戻った時)は当然、自分が準備完了を送信したときにも送られてくるので注意(無限ループに陥る)

`user1`と`user2`がどちらも`True`になってしばらくたつと`stated`が送られてくる

<details><summary>データ構造</summary>

```
{id: チャンネル識別用のID,
type: 'changeReadyStates',
body: {user1 : bool,
       user2 : bool}}
```
</details>

### 対戦中に送られるメッセージ
#### started
開始時に送られてきます
一緒にゲームの情報も送られてきます

前述した自分が先行か確かめる方法ですが、ゲームの情報の中に`black`というパラメータがあり、この`black`の数字でどちらが先行か決まるのでここで決めた方がいいです。(`black`の取る値は1か2でuser1、user2どちらが黒かを指している)

また、ゲームの情報の`startedAt`が始まった時間。`isStarted`が`True`にそれぞれ変わっています。
あと設定されたゲームの設定もゲームの情報の中に入ります。
<details><summary>データ構造</summary>

```
{id : チャンネル識別用のid,
type : 'started',
body : {game : ゲームの情報}}
```
bodyがゲームの情報ではなく、bodyの中にあるgameがゲームの情報なので注意
</details>


#### ended
終了時に送られてきます
`started`と同じくゲームの情報も送られてきます

`started`と同様に`endedAt`が終わった時間に。`isEnded`が`True`に変わります。
また、勝者がいる場合`winnerId`が勝者のユーザーIDに、`winner`が勝者の情報に変わります。

投了による終了の場合、上の勝者がいる場合に加えて`surrenderedUserId`が投了したユーザーIDに変わります。(投了による終了も判別できる)

時間切れによる終了の場合、投了による終了のように`timeoutUserId`が時間切れしたユーザーIDに変わります。
<details><summary>データ構造</summary>

```
{id : チャンネル識別用のid,
type : 'ended',
body : {game : ゲームの情報}}
```
</details>

#### log
石が置かれたときに送られてきます
置かれた時なので自分が置いても送られてきます

`player`はbool値でTrueで黒、Falseで白を表します。

`pos`は置かれた場所です。
posの計算方法は昔のドキュメントの**位置の計算法**に書いてあります(y*mapwidth+x=pos)


<details><summary>データ構造</summary>

```
{id : チャンネル識別用のid,
type : 'log',
body : {time : 打つのにかかった時間[ms] (int),
        player : 黒か白か(bool)},
        operation : 'put'(固定、このパラメータの意味は不明),
        pos : 置かれた場所(int),
        id : ID(str)}
```
</details>

## 石を置く方法
:::note alert
無効な場所(例えばもうすでに置かれている場所や自由に置けるモードじゃない時に一つも取れない場所等)に置こうとした場合、何も起きないので注意
:::

メッセージで`type='putStone'、body={pos : 置く場所(pos)}`として送信することで置くことができます
(置く場所はposで表します)
<details><summary>データで表すとこれ</summary>

```
{id : チャンネル識別用のID,
type : 'putStone',
body : {pos : 置く場所(pos)}
```
</details>

# リバーシbotの流れ
0. `reversi`チャンネルへ接続
0. メッセージを受け取るまで待つ
0. `matched`を受け取った場合、`reversiGame`チャンネルへ`gameId`を入れて接続する。
`invited`を受け取った場合、`reversi/match`へ招待してきた`userId`を使ってPOSTする。
うまくいくとレスポンスでゲームの情報が返ってくるので`reversiGame`チャンネルへ`gameId`を入れて接続する。
0. 相手の設定が変更されると`updateSettings`が送られてくるのでそれを見て必要なら何かやる
ここでもし`canceled`が送られてきたらチャンネルから切断し、2に戻る
0. `changeReadyStates`で相手の準備ができた場合、自分も準備ができたらメッセージ`ready`を送る
0. `started`で始まったら情報から自分が先行か等を見て駒を置いたりする
0. `log`で相手が置いたら置く場所を考えて石を置く
0. `ended`で終了したらチャンネルから切断し、2に戻る。
# botに役に立つかもしれない情報
## 接続のダブり
`reversi`チャンネルで`invited`が来たら`reversi/match`にPOSTして`reversiGame`チャンネルへ接続するのですが、`invited`の説明でも言った通りこのメッセージは何回も送られてきます。
その結果同じゲームに何個も`reversiGame`を接続してしまうことがあります。この場合、何回も石を置く作業をしてしまったりして(botの内部で使う盤面が変になったりする等で)ゲームが止まってしまうことがあります。
この対策として`invited`にきたユーザーのリストを作っておき、同じユーザーの`invited`が何個も来ても最初の`invited`以外ををはじくことで同時に同じゲームに接続してしまうことを回避できます
## 途中でwebsocketが切れた時の対策
たまにですが、対戦中にwebsocketが切れてしまうことがあります。
この場合、接続しなおしますが、その間に相手が石を置いた場合`log`が流れてこないのでリバーシが止まってしまいます。
この対策として接続しなおしたら`reversi/show-game`でゲームの情報を取得し、`logs`の中にはそれまでの`log`が溜まっているのでbotの内部で使う盤面を`map`で初期化し、`logs`を使って最後の一手前まで再現。
最後の手が自分だった場合、落ちている間に石は打たれていなかったということなのでそのまま再現。
最後の手が相手だった場合、落ちている間に石が打たれたということなのでその一手から置く場所を考えて石を置く。
という感じにして落ちても大丈夫にさせられます。

# 作った感想
藍ちゃんを除いてmisskey games版リバーシに対応した初めて(多分)のbotなので作ってて自分は今開拓してるんだなぁってなって楽しかったです

ちなみにぶろみね君のリバーシ対応で躓いたのは`reversiGame`への接続で`gameId`パラメータを載せる必要があるという物です。
これで2日くらい潰れました(ちゃんとストリーミングAPIのドキュメントちゃんと読んでいればこんなことには...)

また、リバーシ対応に当たって一番難しかったのはチャンネルにメッセージを送ることでした
従来のbotの運用でチャンネルにメッセージを送るというのが無かったのでかなり苦労しましたね...
(今までのぶろみね君はLTL(localTimeline)と通知(main)に接続して流れてくる情報を処理するだけだった)

逆にドキュメントが無い問題は藍ちゃんの中身が読みやすいのと昔のドキュメントに書いてあった内容が今のリバーシとかなり似ていたのもあってあまり問題にはならなかったです
(TypeScriptどころかJavaScriptも知らないけど読めるからすごい)

ちなみにこの記事を書いている理由は自分以外にもリバーシbot作りたい人が絶対いるだろうからもうドキュメントみたいな感じでAPIまとめを書いちゃおうっていうのです

みんなもリバーシbot作ろうね