Home

電脳麻将でMortalと対戦する

Mortalと対戦

電脳麻将 で Mortal と対戦できるようになりました。

と言っても、皆さんが想像する Mortal とは少し異なります。 Mortal は深層強化学習を利用した麻雀AIのオープンソースプロジェクトです。 そこで開発されたAIもまた Mortal と呼ばれ、天鳳八~九段相当とも評される実力を持つことで知られています。

Mortal はオープンソースとして GitHub に公開されていますが、公開されているのはニューラルネットワークの構造や、盤面から特徴量を抽出するプログラム、学習用のコードなどです。 一方「モデル」と呼ばれる学習結果を反映したデータ、いわば Mortal の頭脳にあたる部分は公開されていません。

ここでは、有志によって学習された、かなり強いとされるモデル*1を用いて電脳麻将のネット対戦に Mortal を登場させる方法を説明します。


使い方

Mortalのセットアップ

まず Mortal の環境をセットアップし、同梱のMjaiボットが起動できることを確認します。 Mortal には学習済みのデータであるモデルが同梱されていませんので、Hugging Face に公開されている有志によるモデル mortal-582500 を使用することにします。 細かい説明は省きますが、Rustのインストール(rustupを使う)、Pythonのインストール(uvを使う)を行い、libriichi と呼ばれるRust製のライブラリや、Python製の関連ライブラリをビルドします*2。 セットアップが完了したらconfig.toml でモデルを指定します。

[control]
state_file = 'mortal_582500.pth'

以下で起動確認します。

$ uv run python mortal.py 0

あるいは Akagi-MjaiBot-Mortal を使う方法もあります*3。 こちらのプロジェクトにはビルド済みの libriichi が同梱されているので、uv sync で簡単に環境をセットアップできます。 mortal.pth というモデルも同梱されていますが、これは評価用であり、あまり強くありません。

ラッパーのインストール

Mortalに同梱されているMjaiボットは標準入出力で外部プロセスと通信する方式になっており、Mjaiサーバーには接続できません。 また、通信プロトコルも本家 Mjaiプロトコル とは微妙に異なる「方言」となっています。 このため、MortalのMjaiボットをMjaiプロトコル完全互換にするためのプログラムが必要になります。 この役割を果たすために今回開発した mortal-wrapper をインストールします。 mortal-wrapper は @kobalab/mjai-bot に同梱しています。

$ npm i -g @kobalab/mjai-bot

追記: 麻雀サーバーに mortal-wrapper を接続するためにはさらに mjai-bridge が必要です*4。 以下でインストールしておいてください。

$ npm i -g @kobalab/majiang-server

ルームの作成

麻雀サーバーにMjaiボットを接続する と同様にルームを作成します。

麻雀サーバへ接続

麻雀サーバーにMjaiボットを接続する と同様に麻雀サーバーに接続しますが、ボットの代わりにラッパーを指定します。 Git/Mortal の部分はMortalのGitHubリポジトリを展開したディレクトリを指定してください。

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

Mortalが入室しました。

実装

プロトコルの差異

MortalのMjaiボットの通信は以下の点でMjaiプロトコルと差異があります。

  1. ソケットではなく標準入出力を使う
  2. ハンドシェイクを省略し、席順が決まってから起動する
  3. type: "none" しかあり得ないときは応答を返さない
  4. type: "start_kyoku" の通知に scores: があることを期待する
  5. type: "hora" の応答に pai: がない
  6. type: "ryukyoku" の応答に actor:、reason: がない
  7. 応答に機械学習用の情報 meta: がある

Akagi-MjaiBot-Mortal のMjaiボットは以下の点でMortalのボットと異なります。

  1. 起動時に席順は要求しないが、ハンドシェイクは省略する
  2. 通知が配列であることを期待する
  3. type: "none" でも応答は必ず返す

これらの差異を吸収するために、新たに Mortalラッパー を作成します。 ラッパーではまず Mortal に対応し、それに Akagi 対応のオプションを追加します。

プログラム構成

「1. ソケットではなく標準入出力を使う」を吸収するために、以下の構成で通信を行います。

プログラム構成

MortalラッパーはまずMjaiブリッジにソケットで接続します。 ブリッジとの通信はMjai完全互換のプロトコルで行います。 次に標準入出力をパイプで繋いだ状態でMortalボットを起動します。 ボットとの通信はボットのプロトコルにしたがい、ラッパーでプロトコル変換を行います。

プロトコル変換

プロトコル変換上もっとも大きな課題は「3. type: "none" しかあり得ないときは応答を返さない」です。 応答がないときにボットが思考中なのか、type: "none" の応答を省略したのかをラッパーで判断するためには、現在の局面を理解する必要があり、ラッパーの提供する機能としてはあまりにも大袈裟です。 幸いにも環境変数 MORTAL_REVIEW_MODE を 1 にすることで応答を必ず返すモードに変わることが分かったので、それを利用することにします*5。

それ以外の変換は比較的簡単です。

    /* 通知・応答で引き継ぐ情報 */
    let bot, id, pai, scores = [ 25000, 25000, 25000, 25000 ];

    /*
     *  通知変換関数 
     */
    function fixmsg(data) {
        let msg = JSON.parse(data);

        if (msg.type == 'hello') {              // type: "hello" のとき
            if (argv.verbose) console.log('<-', util.inspect(msg,
                                            { depth: null, colors: true }));
            /* type: "join" の応答をラッパーで返す */
            let rep = { type: 'join', name: name, room: room };
            if (argv.verbose) console.log('->', util.inspect(rep,
                                                { depth: null, colors: true }));
            sock.write(JSON.stringify(rep) + '\n');
            return;
        }
        else if (msg.type == 'start_game') {    // type: "start_game" のとき
            id = msg.id;                // id を保存する
            if (! argv.akagi) {         // Mortalのボットの場合
                if (argv.verbose) console.log('<-', util.inspect(msg,
                                                { depth: null, colors: true }));
                /* id を指定してボットを起動する */
                bot = exec_mortal(id);
                readline.createInterface(bot.stdout).on('line', fixrep);
                /* type: "none" の応答をラッパーで返す*/
                let rep = { type: 'none' };
                if (argv.verbose) console.log('->', util.inspect(rep,
                                                { depth: null, colors: true }));
                sock.write(JSON.stringify(rep) + '\n');
                return;
            }
        }
        else if (msg.type == 'start_kyoku') {   // type: "start_kyoku" のとき
            if (! msg.scores) msg.scores = scores;
                                    // scores: がない場合は保存済みのもので補う
        }
        else if (msg.type == 'error') {         // type: "error" のとき
            console.error(msg.message);     // エラーを出力し
            process.exit(-1);               // プロセスを終了する
        }

        /* 変換後の通知をボットに送信する
           --akagi の場合は配列形式とする */
        if (argv.verbose) console.log('<-', util.inspect(msg,
                                            { depth: null, colors: true }));
        if (argv.akagi) bot.stdin.write(JSON.stringify([ msg ]) + '\n');
        else            bot.stdin.write(JSON.stringify(msg) + '\n');

        if (msg.scores) scores = msg.scores;    // scores: を保存する
        if (msg.pai)    pai    = msg.pai;       // pai: を保存する

        if (msg.type == 'end_game') {           // type: "end_game" のあと
            if (! argv.akagi) bot.kill('SIGINT');   // Mortalのボットを停止させる
        }
    }

    /*
     *  応答変換関数
     */
    function fixrep(data) {
        let rep = JSON.parse(data);

        if (rep.type == 'hora') {               // type: "hora" のとき
            rep.pai = pai;                  // pai: を保存済みのもので補う
        }
        else if (rep.type == 'ryukyoku') {      // type: "ryukyoku" のとき
            rep.actor = id;                 // actor: を id で補う
            rep.reason = 'kyushukyuhai';    // reason: を補う
        }
        delete rep.meta;                    // meta: を削除する

        /* ブリッジに応答を返す */
        if (argv.verbose) console.log('->', util.inspect(rep,
                                            { depth: null, colors: true }));
        sock.write(JSON.stringify(rep) + '\n');
    }

    /* --akagi の場合は、ブリッジに接続したらすぐにAkagiのボットを起動する */
    if (argv.akagi) {
        bot = exec_akagi();
        readline.createInterface(bot.stdout).on('line', fixrep);
    }

--akagi を指定したときは、Akagi-MjaiBot-Mortal のMjaiボットを起動しています。

  1. ^ Mahjong AI Utilities で検証するとレーティング95程度
  2. ^ ChatGPTのようなAIに聞けば環境に応じたセットアップの方法を教えてくれると思います
  3. ^ Akagi 本体は天鳳や雀魂のアシストツールであり、私はその趣旨に賛同していません
  4. ^ @kobalab/majiang-server 1.7.4 以降
  5. ^ ただしこのモードの対戦後の処理がエラーとなるため、対戦が終わったタイミングでボットを kill しています