Home

麻雀サーバーにMjaiボットを接続する

麻雀サーバー の ver. 1.7.0 にMjaiプロトコル完全互換のボットを召喚するブリッジ機能を追加しました。 電脳麻将のAIをボットとして召喚する機能 はすでにリリースしていますが、このMjaiブリッジで mjai-manue のような Mjaiプロトコル に準拠したボットも麻雀サーバーに召喚できるようになりました。


使い方

Mjaiブリッジは Node.js で動作するので、あらかじめインストールが必要です。

インストール

npm でインストールします。

$ npm i -g @kobalab/majiang-server

ルームの作成

ブラウザで 電脳麻将: ネット対戦 からルームを作成します。

麻雀サーバへ接続

mjai-bridge コマンドでMjaiボットを召喚します。 mjai-manue なら以下で呼び出せます。

$ mjai-bridge -r S1582 -n '麻雀ロボ' https://kobalab.net/majiang/server/ mjai-manue

-r でルームを、-n でボットの名前を指定します。 ローカルに起動した麻雀サーバーに接続するときにはURLの指定を変更してください。

ボットが入室しました。

Mjai標準の mjsonp://host:port/room 形式のURLを処理できないボットの場合は --noexec オプションを使って表示されたポートに接続するか、Mjai形式のURLを受け付けるラッパーを使うとよいでしょう。 Akochan の接続には成功しています*1。

実装

Mjaiブリッジは麻雀サーバーとMjaiボットの間に入ってプロトコル変換します。

プロトコル

接続の確立

Mjaiブリッジは起動すると 以下の手順 で上記の接続を確立します。

  1. 麻雀サーバーにログインする(login())
  2. Mjaiボットを起動する(exec_bot())
  3. ボットが接続してきたら、麻雀サーバーに接続し、ルームに入室する(connect())

プロトコル変換

接続が確立したらプロトコル変換を行います。 サーバー⇔ブリッジ間は電脳麻将プロトコル、ブリッジ⇔ボット間はMjaiプロトコルでの通信となります。

具体例を見てみましょう。 席順0(id = 0)の南家*2が m5 をツモって、s2 を切り、それを西家がチーした場合のシーケンスは以下となります。

南家(id = 0)でのシーケンス:

サーバー ⇔ ブリッジ ブリッジ⇔ ボット
{zimo: {l:1, p:"m5"}} →
→ {type:"tsumo", actor:0, pai:"5m"}
← {type:"dahai", actor:0, :"2s", tsumogiri:false}
{dapai:"s2"} ←
{dapai: {l:1, p:"s2"}} →
→ {type:"dahai", actor:0, pai:"2s", tsumogiri:false}
← {type:"none"}
{} ←
{fulou: {l:2, m:"s2-34"}} →
→ {type:"chi", actor:1, target:0, pai:"2s", consumed:["3s","4s"]}
← {type:"none"}
{} ←

西家(id = 1)でのシーケンス:

サーバー ⇔ ブリッジ ブリッジ⇔ ボット
{zimo: {l:1, p:"" }} →
→ {type:"tsumo", actor:0, pai:"?"}
← {type:"none"}
{} ←
{dapai: {l:1, p:"s2"}} →
→ {type:"dahai", actor:0, pai:"2s", tsumogiri:false}
← {type:"chi", actor:1, target:0, pai:"2s", consumed:["3s","4s"]}
{fulou:"s2-34"} ←
{fulou: {l:2, m:"s2-34"}} →
→ {type:"chi", actor:1, target:0, pai:"2s", consumed:["3s","4s"]}
← {type:"none"}
{} ←

リーチのシーケンスは複雑です。 電脳麻将には独立した「リーチ宣言」、「リーチ成立」のイベントはありませんが、Mjaiにはそれがあるため、シーケンスにそれを挟み込む必要があるのです。

先ほどのフローで、s2 切りがリーチの場合は以下となります(赤字がリーチで追加になる部分)。

南家(id = 0)のシーケンス:

サーバー ⇔ ブリッジ ブリッジ⇔ ボット
{zimo: {l:1, p:"m5"}} →
→ {type:"tsumo", actor:0, pai:"5m"}
← {type:"reach", actor:0}
→ {type:"reach", actor:0}
← {type:"dahai", actor:0, pai:"2s", tsumogiri:false}
{dapai:"s2*"} ←
{dapai: {l:1, p:"s2*"}} →
→ {type:"dahai", actor:0, pai:"2s", tsumogiri:false}
← {type:"none"}
{} ←
{fulou: {l:2, m:"s2-34"}} →
→ {type:"reach_accepted", actor:0, ... }
← {type:"none"}
→ {type:"chi", actor:1, target:0, pai:"2s", consumed:["3s","4s"]}
← {type:"none"}
{} ←

西家(id = 1)のシーケンス:

サーバー ⇔ ブリッジ ブリッジ⇔ ボット
{zimo: {l:1, p:""}} →
→ {type:"tsumo", actor:0, pai:"?"}
← {type:"none"}
{} ←
{dapai: {l:1, p:"s2*"}} →
→ {type:"reach", actor:0}
← {type:"none"}
→ {type:"dahai", actor:0, pai:"2s", tsumogiri:false}
← {type:"chi", actor:1, target:0, pai:"2s", consumed:["3s","4s"]}
{fulou:"s2-34"} ←
{fulou: {l:2, m:"s2-34"}} →
→ {type:"reach_accepted", actor:0, ... }
← {type:"none" }
→ {type:"chi", actor:1, target:0, pai:"2s", consumed:["3s","4s"]}
← {type:"none"}
{} ←

イベントのPromise化

上記のリーチのように、電脳麻将とMjaiで一対一対応とならないイベントがあるため、一対一対応を前提としたイベント駆動のモデルでボットとの通信を処理するとプログラムが複雑になり過ぎます。 ボットからの応答受信をPromise化し、await で同期的に扱えるようにすることで、これを解決しました。

    function recv() {
        return new Promise(resolve =>{
            line.once('line',  (res)=>{
                res = JSON.parse(res);
                if (argv.verbose) console.log('->', res);
                resolve(res);
            });
        });
    }

シーケンスを以下のように制御します。

    /* 麻雀サーバーからの通知メッセージ受信時に起動して処理を行う */
    sock.on('GAME', async (msg)=>{

        if (msg.qipai) {                // 配牌のとき初期化を行う
            convreply = converter();        // 応答変換関数を再生成
            lizhi = null;                   // リーチ処理中でない
        }

        /* 通知: サーバー → ブリッジ */
        let req = convmsg(msg);         // 通知をMjai形式に変換する
        if (! req) return;              // 変換できない時は処理不要

        if (msg.dapai && msg.dapai.p.slice(-1) == '*' && lizhi == null) {
                                        // 他者のリーチ宣言のとき
            lizhi = req.actor;              // 誰のリーチ処理中か記憶する
            send({ type: 'reach', actor: req.actor });
                                            // リーチ宣言をボットに通知
            await recv();                   // ボットからの応答を待つ
        }
        else if (lizhi != null && (msg.zimo || msg.fulou)) {
                                        // リーチ処理中にツモか副露があったとき
            send(reach_accepted(lizhi));    // リーチ成立ををボットに通知
            lizhi = null;                   // リーチ処理中でない
            await recv();                   // ボットからの応答を待つ
        }

        /* 通知: ブリッジ → ボット */
        send(req);                      // Mjai形式の通知をボットに送信

        if (msg.jieju) {                // 終局のとき
            line.removeAllListeners('close');   // 切断イベントを無視する
            let reply = {};                 // 空応答を生成
            reply.seq = msg.seq;            // 通知と連番を一致させる
            sock.emit('GAME', reply);       // サーバーに応答を返す
            return;
        }

        /* 応答: ブリッジ ← ボット */
        let reply = convreply(await recv());    // ボットから応答を受信し変換する

        if (reply.mjai && reply.mjai.type == 'reach') {
                                            // リーチ宣言のとき
            lizhi = reply.mjai.actor;           // 誰のリーチ処理中か記憶する
            send(reply.mjai);                   // リーチ受領をボットに通知
            reply = convreply(await recv());    // ボットから応答を受信し変換する
        }

        /* 応答: サーバー ← ブリッジ */
        if (msg.seq) {                      // 応答不要な場合は処理しない
            reply.seq = msg.seq;                // 通知と連番を一致させる
            sock.emit('GAME', reply);           // サーバーに応答を返す
        }

        if (msg.hule || msg.pingju) {       // 和了か流局のとき
            send({ type: 'end_kyoku' });        // ボットに局の終了を通知
            await recv();                       // ボットからの応答を待つ
        }
    });

基本的には、

  1. サーバーからの通知を受信し、プロトコル変換する
  2. 変換した通知をボットに送信する
  3. ボットからの応答を受信し、プロトコル変換する
  4. 変換した応答をサーバーに返す

という流れですが、その中でMjaiプロトコルにしかない

のシーケンスを追加する処理になっています。 また、終局を表す end_game にボットは応答しないため、ブリッジはボットの応答を待たずにサーバーに応答を返しています。

  1. ^ Akochanのインストールはやや面倒ですが、ChatGPTに助けてもらいました
  2. ^ 電脳麻将では行為者をその局の親から順に 0 → 3 と表しますが、Mjaiでは席順で 0 → 3 とし対局を通して同じ番号を使用します