麻雀サーバー の 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ブリッジは起動すると 以下の手順 で上記の接続を確立します。
login())exec_bot())connect())接続が確立したらプロトコル変換を行います。 サーバー⇔ブリッジ間は電脳麻将プロトコル、ブリッジ⇔ボット間はMjaiプロトコルでの通信となります。
具体例を見てみましょう。
席順0(id = 0)の南家*2が
をツモって、
を切り、それを西家がチーした場合のシーケンスは以下となります。
南家(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にはそれがあるため、シーケンスにそれを挟み込む必要があるのです。
先ほどのフローで、
切りがリーチの場合は以下となります(赤字がリーチで追加になる部分)。
南家(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"} | |
{} ← |
上記のリーチのように、電脳麻将と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(); // ボットからの応答を待つ
}
});
基本的には、
という流れですが、その中でMjaiプロトコルにしかない
のシーケンスを追加する処理になっています。 また、終局を表す end_game にボットは応答しないため、ブリッジはボットの応答を待たずにサーバーに応答を返しています。