2021/12/12(日)雀魂の牌譜をNAGAに解析させる-完全版-

2021/12/12 22:29 ゲーム::雀魂技術::JavaScript
以前、 雀魂の牌譜をNAGAに解析させる というエントリを書きました。ありがたいことに、このエントリは今でも多くのアクセスを集めています。このたび、それよりもパワーアップした、完全版と呼んでも差し支えない仕組みができました。ぜひご覧ください。

0. はじめに

NAGA のカスタム牌譜解析サービスがスタートし、より多くの人が利用するようになって以降、NAGAが深層学習AIだということを無視した言動が散見されるようになりました。

NAGAは 天鳳 の特上卓で十段に到達しました。しかし、特上卓で十段に達した人間のプレイヤーと同等の実力があるとは言えません。特上卓で十段になれるような選択を繰り返すことができるだけなのです。これは似ているようで大きく違います。

雀魂 の牌譜を解析させた場合、ラスを避けたいルールという共通点があるので、それほどおかしな結果を出すことはないと思います。しかし、ポイント配分や素点の影響有無の差がある以上、必ずしも雀魂でのベストな解を教えてくれるとは限りません。「天鳳の特上卓で打っていると仮定した場合、この選択を繰り返せば十段になれる」ということを前提に、自分の打牌を振り返るきっかけとしましょう。

本エントリの内容が、あなたの良き雀魂ライフのお手伝いとなれば幸いです。

1. Tampermonkeyをインストールする

ブラウザの拡張機能である Tampermonkey をインストールします。私はGoogle Chromeでしか試していませんが、メジャーどころのブラウザにはほとんど対応しているようです。

2. Tampermonkeyにdownloadlogsを登録する

雀魂の牌譜データをダウンロードするスクリプトをTampermonkeyに登録します。

まず、Tampermonkeyのエディターを起動しましょう。ブラウザによって多少操作は異なると思いますが、Chromeの場合は拡張機能のアイコンをクリックし、表示されるメニューから[Tampermonkey]→[新規スクリプトを追加...]を選択してください。

起動したエディターに downloadlogs.js の内容を貼り付けます。このとき、15行目の const NAMEPREF = 1const NAMEPREF = 0 に書き換えてください。

メニューの[ファイル]→[保存]を選択すれば登録は完了です。

(謝辞) 本節の手順は、 Akochan Reviewer による解析の仕組みを流用させていただきました。Akochan Reviewerを開発されている Equimさん に感謝いたします。ありがとうございました。

3. Tampermonkeyにsoul2nagaを登録する(2022-11-24更新)

2と同様の手順で、Tampermonkeyに以下のスクリプトを登録してください。
// ==UserScript==
// @name         soul2naga
// @namespace    lions.blue
// @icon         http://1.gravatar.com/avatar/6fa3836d10d691125749472297cf516a
// @version      1.0.0
// @description  downloadlogsで取得する牌譜をNAGAで解析可能な形式に変換する
// @include      https://mahjongsoul.game.yo-star.com/*
// @include      https://game.mahjongsoul.com/*
// @include      https://majsoul.union-game.com/0/*
// ==/UserScript==

(function() {
    // ダウンロードリンクのhref属性において、牌譜データに先行する部分の文字列。
    const DOWNLOAD_HREF_PREFIX = "data:text/plain;charset=utf-8,";

    // 天鳳牌譜エディタのURLにおいて、牌譜データに先行する部分の文字列。
    const EDITOR_URL_PREFIX = "https://tenhou.net/6/#json=";

    // ダウンロードイベントの前に割り込んで牌譜データを書き換える。
    document.addEventListener("click", function(e) {
        const links = document.body.getElementsByTagName("a");
        for (let i = 0; i < links.length; i++) {
            if (isDownloadLink(links[i])) {
                const soulJson = fetchSoulJson(links[i]);
                const urls = createViewerUrls(soulJson);
                links[i].href = buildDownloadHref(urls);
                links[i].download = buildFileName(links[i].download);
                return;
            }
        }
    }, {capture: true});

    /**
     * オブジェクトをディープコピーする。
     * 
     * @param {Object} src コピー対象のオブジェクトを指定する。
     * @returns {Object} 複製したオブジェクトを返す。
     */
    function deepCopy(src) {
        // JSON文字列化してからオブジェクトに戻すことでディープコピーを実現する。
        return JSON.parse(JSON.stringify(src));
    }

    /**
     * ダウンロードリンクであるか判定する。
     * 
     * @param {HTMLElement} element 判定対象の要素を指定する。
     * @returns {Boolean} ダウンロードリンクの場合はtrue、それ以外の場合はfalseを返す。
     */
    function isDownloadLink(element) {
        // href属性の先頭部分で判定する。
        return element.href.startsWith(DOWNLOAD_HREF_PREFIX);
    }

    /**
     * ダウンロードリンクから雀魂の牌譜データを抽出する。
     * 
     * @param {HTMLElement} element ダウンロードリンクを指定する。
     * @returns {String} 雀魂の牌譜データを返す。
     */
    function fetchSoulJson(element) {
        // href属性から牌譜データを抽出する。
        return decodeURIComponent(element.href.replace(DOWNLOAD_HREF_PREFIX, ""));
    }

    /**
     * ダウンロードリンクのhref属性を組み立てる。
     * 
     * @param {Array<String>} urls 牌譜エディタのURL群を指定する。
     * @returns {String} ダウンロードリンクのhref属性を返す。
     */
    function buildDownloadHref(urls) {
        // ダウンロードリンクのhref属性を組み立てる。
        return DOWNLOAD_HREF_PREFIX + encodeURIComponent(urls.join("\n"));
    }

    /**
     * ダウンロードファイル名を組み立てる。
     * 
     * @param {String} baseFileName もともとのダウンロードファイル名を指定する。
     * @returns {String} 組み立てたダウンロードファイル名を返す。
     */
    function buildFileName(baseFileName) {
        // 卓名を雀魂っぽく変換し、拡張子を.txtに変更する。
        return toSoulTable(baseFileName).replace(".json", ".txt");
    }

    /**
     * 雀魂の牌譜JSONを牌譜エディタのURL群に変換する。
     * 
     * @param {String} soulJson 雀魂の牌譜JSONを指定する。
     * @returns {Array<String>} 牌譜エディタのURL群を返す。
     */
    function createViewerUrls(soulJson) {
        // 雀魂の牌譜JSONをオブジェクトに変換する。
        const soulPaifu = JSON.parse(soulJson);

        // title内の卓名を雀魂っぽく変換する。
        // 
        // 変換前のtitle:
        //     "title": [ "玉の間南喰赤", "2021/10/20 20:48:01" ]
        // 変換後のtitle:
        //     "title": [ "玉の間四人南", "2021/10/20 20:48:01" ]
        const title = deepCopy(soulPaifu.title);
        title[0] = toSoulTable(title[0]);

        const name = deepCopy(soulPaifu.name);
        const encodedName = name.map(function(v) {
            return encodeURIComponent(v);
        });

        // rule内の卓名を雀魂っぽく変換する。
        // 
        // 変換前のrule:
        //     "rule": { "disp": "玉の間南喰赤", "aka53": 1, "aka52": 1, "aka51": 1 }
        // 変換後のrule:
        //     "rule": { "disp": "玉の間四人南", "aka53": 1, "aka52": 1, "aka51": 1 }
        const rule = deepCopy(soulPaifu.rule);
        rule.disp = toSoulTable(rule.disp);

        // logを局ごとのデータに分割し、牌譜エディタのURL群として返す。
        return soulPaifu.log.map(function(v) {
            return EDITOR_URL_PREFIX + JSON.stringify({
                "title": title,
                "name": encodedName,
                "rule": rule,
                "log": [toNagaLog(v)],
            });
        });
    }

    /**
     * 卓名を雀魂っぽく変換する。
     * 
     * @param {String} tenhouTable 天鳳っぽい卓名を指定する。
     * @returns {String} 雀魂っぽい卓名を返す。
     */
    function toSoulTable(tenhouTable) {
        // 表記の好みの問題なので、必ずしも必要となる処理ではない。
        return tenhouTable.replace("南喰赤", "四人南");
    }

    /**
     * logをNAGAが解析可能な形式に変換する。
     * 
     * @param {Array<Array>} soulLog 雀魂形式のlogを指定する。
     * @returns {Array<Array>} NAGAで解析可能な形式のlogを返す。
     */
    function toNagaLog(soulLog) {
        // 流局のデータは変換の必要がない。
        if (soulLog[16].length < 3) {
            return soulLog;
        }
        const nagaLog = deepCopy(soulLog);

        // 当該局の場風を算出する。
        // 
        // 局を表す数字と意味:
        //     0 => 東1局, 1 => 東2局, ...
        const prevalent = ["東", "南", "西", "北"][Math.floor(nagaLog[0][0] / 4)];

        // 役名をNAGAが解析可能な表記に変換する。
        // ダブロン・トリロンに対応するため複数回繰り返す。
        for (let i = 1; i < nagaLog[16].length; i += 2) {
            // 当該局における和了者の自風を設定する。
            // 
            // 算出方法:
            //     (和了者のプレイヤー番号 - 親の位置 + 4) % 4
            const seat = ["東", "南", "西", "北"][
                (nagaLog[16][i].indexOf(Math.max(...nagaLog[16][i])) - (nagaLog[0][0] % 4) + 4) % 4
            ];

            // 役名をNAGAが解析可能な表記に変換する。
            nagaLog[16][i + 1] = nagaLog[16][i + 1].slice(0, 4).concat(
                nagaLog[16][i + 1].slice(4).map(function(v) {
                    return toNagaHand(v, prevalent, seat);
                }
            ));
        }

        // 変換後のlogを返す。
        return nagaLog;
    }

    /**
     * 役名をNAGAが解析可能な表記に変換する。
     * 
     * @param {String} hand 和了役を指定する。
     * @param {String} prevalent 場風を指定する。
     * @param {String} seat 和了者の自風を指定する。
     * @returns {String} NAGAで解析可能な表記の役名を返す。
     */
    function toNagaHand(hand, prevalent, seat) {
        // 対応が必要な役が判明次第、随時追加する。
        switch(hand) {
            case "役牌:場風牌(1飜)":
                return `場風 ${prevalent}(1飜)`;
            case "役牌:自風牌(1飜)":
                return `自風 ${seat}(1飜)`;
            case "ダブル立直(2飜)":
                return "両立直(2飜)";
            default:
                return hand;
        }
    }
})();

4. 牌譜データをダウンロードする

雀魂にログインし、対象の牌譜画面でキーボードの S キーを押してください。設定が正しく行われていれば、牌譜エディタのURLが並んだテキストファイルをダウンロードできるはずです。

5. NAGAに解析させる

4でダウンロードしたファイルは1行が1局の構成になっています。解析したい局を「カスタム牌譜解析」のページから読み込ませてください。すべての行をまとめて読み込ませれば、半荘を単一のレポートとしてまとめることもできます。

2021/10/23(土)雀魂の牌譜をNAGAに解析させる

2021/10/23 19:12 ゲーム::雀魂技術::Python
(2021-12-12追記) 本エントリの内容は現在でも有効なものですが、より簡単に実践できる新しい仕組みを作りましたので、今後はその手順で解析することをお勧めします。詳しくは 雀魂の牌譜をNAGAに解析させる-完全版- をご覧ください。

0. はじめに

10月20日のアップデートにより、麻雀AIの NAGA にオリジナルの牌譜を解析させる機能が追加されました。これまでは 天鳳 の牌譜を対象としたサービスでしたが、これからはどんな牌譜でも解析させられるのです。

私もさっそく 雀魂 の牌譜を解析させようと思ったものの、かといって 天鳳牌譜エディタ にポチポチ入力するのは現実的でありません。というわけで、もっと簡単に解析対象のデータを作成する仕組みを考えてみました。

1. 牌譜データをダウンロードする

前提として、 NAGAで牌譜の解析をさせるには、天鳳牌譜エディタ形式のURLが必要です 。牌譜エディタのURLは https://tenhou.net/6/#json=牌譜データ という形式なので、この 牌譜データ の部分に雀魂の牌譜を突っ込むことが最終目標になります。

とはいえ、この牌譜データを手で作るのはあまりにも大変です。というより、それならば素直に牌譜エディタを使うべきです。では、ほかに何かいい方法はないでしょうか。

少し話は変わりますが、麻雀AIの Akochan に天鳳や雀魂の牌譜を解析させる Akochan Reviewer というシステムがあります。そして、Akochan Reviewerで雀魂の牌譜を解析させるには、事前に牌譜データをダウンロードしなければいけません。

そうです。 牌譜データをダウンロードしなければいけない のです。そして、ダウンロードするための仕組みはすでに用意されています。せっかくですから、ありがたく乗っからせてもらいましょう。以下の手順でダウンロードの準備をしてください。
このとき、 downloadlogs.jsNAMEPREF の値を 0 に設定しておきましょう。ダウンロードする牌譜データ内の役名が日本語になります。

さて、以上でダウンロードの準備が完了しました。雀魂にログインし、対象の牌譜画面でキーボードの S キーを押してください。これまでの手順に誤りがなければ、牌譜データのダウンロードが始まるはずです。

(謝辞) 本文中にもあるとおり、本節の手順はAkochan Reviewerの仕組みを流用させていただきました。Akochan Reviewerを開発されている Equimさん に感謝いたします。ありがとうございました。

2. 牌譜データをNAGAが解析できる形式に変換する

前節の手順で牌譜データをダウンロードできるようになりましたが、残念ながらこのままではNAGAに読み込ませることができません。2点ほど手を加える必要があります。

2-1. 局ごとのデータに分割する

先ほどダウンロードした牌譜データは、JSONの中に半荘すべてのデータが含まれています。
// ダウンロードした牌譜データ
{
    "title": [...],
    "name": [...],
    "rule": [...],
    "log": [
        [東1局の牌譜],
        [東2局の牌譜],
        ...
    ],
    ...
}
NAGAでは局単位の牌譜データを読み込むため、以下のように局数分のJSONに分割してあげる必要があります。
// 東1局
{
    "title": [...],
    "name": [...],
    "rule": [...],
    "log": [
        [東1局の牌譜]
    ]
}

// 東2局
{
    "title": [...],
    "name": [...],
    "rule": [...],
    "log": [
        [東2局の牌譜]
    ]
}

...
titlenamerulelog 以外の項目は削除しても問題ありません。

2-2. 役名を変換する

和了役に風牌が含まれる場合、そのままではNAGAに読み込ませることができません。雀魂では「場風牌」「自風牌」という表記であるのに対し、NAGA(というよりも天鳳)では「場風 東」「自風 南」のように牌の種類まで含まれているからです。役の名前は牌譜データの中に直接文字列で埋め込まれているので、天鳳の形式に書き換えてしまいましょう。

(2021-10-29追記) ダブルリーチについても、雀魂では「ダブル立直」、NAGAでは「両立直」と表記が異なります。こちらもNAGAの形式に書き換えましょう。

2-3. 退屈なことはPythonにやらせよう(2021-10-29更新)

以上の対応で https://tenhou.net/6/#json="title":[...],"name":[...],"rule":[...],"log":[[...]] のようなURLを組み立てられるようになりました。これでNAGAに牌譜を解析させることができますね。お疲れ様でした!

……で、終わりにしてしまうのも芸がないので、ダウンロードした牌譜データを編集するPythonスクリプトを作成しました。このスクリプトは牌譜データを標準入力から受け取り、牌譜エディタのURLを標準出力に出力します。
import json
import sys

def create_viewer_urls(soul_json):
    """
    雀魂の牌譜JSONを牌譜エディタURL群に変換する。
    
    Args:
        soul_json (str): 雀魂の牌譜JSONを指定する。
    Returns:
        list[str]: 牌譜エディタURL群を返す。
    """
    # 雀魂の牌譜JSONを辞書に変換する。
    soul_paifu = json.loads(soul_json)
    
    # title内の卓名を雀魂っぽく変換する。
    # 
    # 変換前のtitle:
    #     "title": [ "玉の間南喰赤", "2021/10/20 20:48:01" ]
    # 変換後のtitle:
    #     "title": [ "玉の間四人南", "2021/10/20 20:48:01" ]
    title = soul_paifu['title'].copy()
    title[0] = to_soul_table(title[0])
    
    # rule内の卓名を雀魂っぽく変換する。
    # 
    # 変換前のrule:
    #     "rule": { "disp": "玉の間南喰赤", "aka53": 1, "aka52": 1, "aka51": 1 }
    # 変換後のrule:
    #     "rule": { "disp": "玉の間四人南", "aka53": 1, "aka52": 1, "aka51": 1 }
    rule = soul_paifu['rule'].copy()
    rule['disp'] = to_soul_table(rule['disp'])
    
    # logを局ごとのデータに分割し、牌譜エディタURL群として返す。
    return ['https://tenhou.net/6/#json=' + json.dumps({
        'title': title,
        'name': soul_paifu['name'],
        'rule': rule,
        'log': [to_naga_log(log)],
    }, ensure_ascii=False, separators=(',', ':')) for log in soul_paifu['log']]

def to_soul_table(tenhou_table):
    """
    卓名を雀魂っぽく変換する。
    
    Args:
        tenhou_table (str): 天鳳っぽい卓名を指定する。
    Returns:
        str: 雀魂っぽい卓名を返す。
    """
    # 表記の好みの問題なので、必ずしも必要となる処理ではない。
    return tenhou_table.replace('南喰赤', '四人南')

def to_naga_log(soul_log):
    """
    局データをNAGAが解析可能な形式に変換する。
    
    Args:
        soul_log (list[list]): 雀魂形式のlogを指定する。
    Returns:
        list[list]: NAGAで解析可能な形式のlogを返す。
    """
    # 流局時のデータは変換の必要がない。
    if len(soul_log[16]) < 3:
        return soul_log
    naga_log = soul_log.copy()
    
    # 当該局の場風を設定する。
    # 
    # 局を表す数字と意味:
    #     0 => 東1局, 1 => 東2局, ...
    prevalent = ['東', '南', '西', '北'][naga_log[0][0] // 4]
    
    # 当該局の和了者の自風を設定する。
    # 
    # 算出方法:
    #     (和了者のプレイヤー番号 - 親の位置) % 4
    seat = ['東', '南', '西', '北'][
        (max(enumerate(naga_log[16][1]), key=lambda x: x[1])[0] - (naga_log[0][0] % 4)) % 4
    ]
    
    # 役名をNAGAが解析可能な表記に変換する。
    naga_log[16][2][4:] = [to_naga_hand(hand, prevalent, seat) for hand in naga_log[16][2][4:]]
    
    # 変換後のlogを返す。
    return naga_log

def to_naga_hand(hand, prevalent, seat):
    """
    役名をNAGAが解析可能な表記に変換する。
    
    Args:
        hand (str): 和了役を指定する。
        prevalent (str): 場風を指定する。
        seat (str): 和了者の自風を指定する。
    Returns:
        str: NAGAで解析可能な表記の役名を返す。
    """
    # 対応が必要な役が判明次第、随時追加する。
    if hand == '役牌:場風牌(1飜)':
        return f"場風 {prevalent}(1飜)"
    elif hand == '役牌:自風牌(1飜)':
        return f"自風 {seat}(1飜)"
    elif hand == 'ダブル立直(2飜)':
        return '両立直(2飜)'
    else:
        return hand

if __name__ == '__main__':
    for url in create_viewer_urls(''.join(sys.stdin.readlines())):
        print(url)

2-4. プログラミングはわからにゃいけどNAGAを使いたいにゃ!(2021-10-24追記)

Pythonを動かすための環境を用意できない場合は、Web上でプログラムを実行するサービスを利用しても良いでしょう。

まず、先ほどのスクリプトを少し変更します。最後の3行を以下のように書き換えた上で、 ※ここに牌譜データを貼り付けるにゃ! の部分に牌譜データを埋め込んでください。
if __name__ == '__main__':
    soul_json = """
    ※ここに牌譜データを貼り付けるにゃ!
    """
    for url in create_viewer_urls(soul_json):
        print(url)
続いて、 Paiza.io にアクセスします。入力欄(背景色が黒い領域)に変更後のスクリプトを貼り付け、実行ボタンを押してください。スクリプトに誤りがなければ、画面下部のコンソールに牌譜エディタのURLが表示されるはずです。

また、 雀魂の牌譜をNAGA解析する方法|アトリエ@凛凛、凛世|note ではGoogleスプレッドシートを使う仕組みが紹介されています。本エントリの内容はすっぱり忘れて、そちらの手順で進めるのもひとつの方法でしょう。

3. 遊びのはずなのに仕事のような障害報告をしている件(2022-01-21更新)

何度か解析を行う中で、NAGA側の不具合と思われる事象にも遭遇しました。以下の問題はすべてサポートに連絡しています。

自分で言うのもアレですが、連絡に際してはかなり質の高いレポートを送っているつもりです。 一応プロなので
  • アガリ時の点数申告画面で赤ドラと裏ドラの表記が逆になっている
    • 2021年10月20日に報告
    • 2021年10月22日に解消の報告を受領
  • カンが含まれる牌譜を読み込めない
    • 2021年10月22日に報告
    • 2021年10月25日に解消の報告を受領
  • 同一プレイヤーが2巡続けて同一の牌を切り、その両方を別々のプレイヤーが鳴いた局の牌譜を読み込めない
    • 2022年1月5日に報告
    • 2022年1月6日に原因の報告を受領
    • 2022年1月21日に解消の報告を受領
いずれも迅速にご対応いただけました。ありがとうございました。

3つ目の事象は 牌譜ビューア で見たほうがわかりやすいかもしれません。Dさんのリーチ宣言牌と次巡の捨て牌がいずれも七萬で、宣言牌はBさんがポン、次の牌はAさんがチーしています。このようなケースで読み込みに失敗するという事象でした。

4. おわりに

すべての牌譜をNAGAに読み込ませてしまうと、九種九牌や四風連打の局でも20ポイント消費することになるので、対象の局はしっかりと取捨選択しましょう。