Home

麻雀ボットの実装

麻雀サーバーver.1.3.0 にボットを召喚する機能を追加しました。 電脳麻将ネット対戦 ではメンバーが4人そろわなくてもツモ切りプレーヤーを追加してゲームができますが、ボットを召喚すれば電脳麻将の最新の AI が参戦します。


使い方

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

インストール

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

$ npm i -g @kobalab/majiang-server

ルームの作成

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

麻雀サーバへ接続

majiang-bot コマンドでボットを召喚します。

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

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

ボットが入室しました。

実装

麻雀ボットは Socket.io のクライアント側のライブラリである socket.io-client を使用して実装しています。 ブラウザで Socket.io のサーバに接続するとサーバ上の /socket.io/ からクライアント用の JavaScript をダウンロードしますが、socket.io-client はこのときの JavaScript をCLIから使えるようにしたものです。

WebSocketでの通信

function init(url, room) {

    const server = url.replace(/^(https?:\/\/[^\/]*)\/.*$/,'$1');
    const path   = url.replace(/^https?:\/\/[^\/]*/,'').replace(/\/$/,'');
    const sock = io(server, {
                        path: `${path}/socket.io/`,
                        extraHeaders: {
                            'User-Agent': agent,
                            Cookie: `MAJIANG=${cookie}`,
                        }
                    });

    if (argv.verbose) sock.onAny(console.log);
    sock.on('ERROR', error);
    sock.on('END',   logout);
    sock.on('ROOM',  ()=>{ sock.on('HELLO', logout)});
    sock.on('GAME',  (msg)=>{
        if (msg.seq) {
            player.action(msg, (reply = {})=>{
                reply.seq = msg.seq;
                sock.emit('GAME', reply);
            });
        }
        else {
            player.action(msg);
        }
    });

    process.on('SIGTERM', logout);
    process.on('SIGINT',  logout);

    sock.emit('ROOM', room);
}

ブラウザから Socket.io を使用するときには現在のページのURLに基づいて io() の呼び出しを行いますが、CLIでは明確にサーバを指定する必要があります。 麻雀サーバーは Cookie を使用してユーザを識別するので Cookie の指定も必要 です。 Cookie はログイン時に取得しますが、取得方法は後述します。

WebSocket の接続が確立できたらイベントハンドラを設定します。

ERROR
エラーが発生したときに発火するイベントです。 引数はエラーメッセージなので、error() を呼び出してメッセージ出力後、ログアウトします。
END
対局が終了したときに発火するイベントです。 引数は 牌譜 ですが使用せず、logout() を呼び出してログアウトします。
ROOM
自分を含め誰かが入室/退出したときに発火するイベントです。 引数はメンバー一覧ですが使用しません。 入室後の HELLO イベントは「強制退出」を意味するので、ログアウトします。
GAME
対局中の摸打を通知するイベントです。 引数 msg通知メッセージ です。 変数 player に設定したAIに処理を任せますが、応答が必要なメッセージにはメッセージの連番を示すプロパティ seq がある*1ので、sock.emit() で連番を指定して 応答メッセージ を応答するようコールバック関数も指定します。

Ctrl + C などでプログラムが中断したときにもログアウト処理を行うよう、シグナルハンドラを設定しています。 ログアウトの際にセッション情報をクリアするので、適切にログアウトを行わないとサーバ側にセッション情報が蓄積してしまうためです。

ハンドラ設定後、sock.emit() を呼び出してサーバ側に入室を依頼します。 これ以降はイベントドリブンで処理が行われます。

ログインの実装

WebScket での通信に先立ち、ログインを行います。 ログインは通常のHTTP通信で行うので、fetch API で実装しています。

function login(url, name, room) {

    fetch(url + '/auth/', {
        method:   'POST',
        headers:  { 'User-Agent': agent },
        body:     new URLSearchParams({ name: name, passwd: '*'}),
        redirect: 'manual'
    }).then(res=>{
        for (let c of (res.headers.get('Set-Cookie')||'').split(/,\s*/)) {
            if (! c.match(/^MAJIANG=/)) continue;
            cookie = c.replace(/^MAJIANG=/,'').replace(/; .*$/,'');
            init(url, room);
            break;
        }
        if (! cookie) console.log('ログインエラー:', url);
    }).catch(err=>{
        console.log('接続エラー:', url);
    });
}

麻雀サーバー上のパス /auth/ にパラメータ namepasswd をPOSTすることでログインします。 name にはコマンドオプションの -n で指定された対局者名を使用します。 passwd は使用しないので任意の値でよいですが省略はできません*2

ログインするとサーバ側からの応答ヘッダ Set-Cookie で Cookie が通知されますが、リダイレクトが発生する場合、fetch はリダイレクトに追随して最終的な結果だけ返すので Cookie の値が取得できません。 これを避けるために redirect: 'manual' を指定し、リダイレクトへの追随を抑止しています。

サーバ上のアプリの干渉などで Cookie が複数返されることも想定し、キーが MAJIANG のものを採用します。 Cookie が取得できたら先ほど説明した init() を呼び出し、WebSocket の接続とイベントハンドラの設定に進みます。

麻雀サーバーへの接続自体が失敗したときは「接続エラー」、Cookie が取得できないときは「ログインエラー」とします。

ログアウトの実装

サーバ上のセッション情報を破棄させせるためにログアウトを実装します。

function logout() {

    fetch(url + '/logout', {
        method:   'POST',
        headers:  { 'User-Agent': agent,
                    'Cookie':     `MAJIANG=${cookie}`},
    }).then(res=>{
        process.exit();
    });
}

麻雀サーバー上のパス /logout に Cookie をともなってPOSTすることでログアウトします。 ブラウザでは Cookie は自動的に送信されますが、fetch では明示的に指定する必要があります。

ログアウトしたらプロセスを終了します。

  1. ^ これにより通知/応答の対応を確認します
  2. ^ ログインの実装に使用している passport-local がエラーとするため