
電脳麻将 で Mortal と対戦できるようになりました。
と言っても、皆さんが想像する Mortal とは少し異なります。 Mortal は深層強化学習を利用した麻雀AIのオープンソースプロジェクトです。 そこで開発されたAIもまた Mortal と呼ばれ、天鳳八~九段相当とも評される実力を持つことで知られています。
Mortal はオープンソースとして GitHub に公開されていますが、公開されているのはニューラルネットワークの構造や、盤面から特徴量を抽出するプログラム、学習用のコードなどです。 一方「モデル」と呼ばれる学習結果を反映したデータ、いわば Mortal の頭脳にあたる部分は公開されていません。
ここでは、有志によって学習された、かなり強いとされるモデル*1を用いて電脳麻将のネット対戦に 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プロトコルと差異があります。
Akagi-MjaiBot-Mortal のMjaiボットは以下の点でMortalのボットと異なります。
これらの差異を吸収するために、新たに 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ボットを起動しています。