2024/03/30(土)雀魂の牌譜をNAGAに解析させる -段位pt期待値対応版-

2024/03/30 20:41
以前、 雀魂の牌譜をNAGAに解析させる-完全版- というエントリを書きました。当時は「完全版」と銘打ってしまったのですが、 NAGA側の仕様変更で段位pt期待値変動の機能が追加され 、完全なものとは言えなくなってしまいました。このたび、その段位pt期待値変動に対応するためにスクリプトを大幅に更新しました。ぜひご覧ください。

0. はじめに

本エントリで紹介しているプログラムは、 Apache License, Version 2.0 で公開します。

突然ライセンスという話が出てきましたが、これは、とあるツールが私のコードを組み込んだ上でApache License, Version 2.0で公開しているのを見つけてしまったためです。これまでのバージョンで特にライセンスを明示しなかった私の落ち度ではありますが、先方にオリジナルを主張されるリスクがあるため、過去のソースは破棄して改めてライセンスを明示することにしました(とはいえ、処理が変わっていない部分は同じようなコードになってしまっているのですが……)。

ライセンスと言われると、プログラマーでない方は身構えてしまうかもしれませんが、単純に利用するぶんにはこれまでのバージョンと何か変わることはありません。本エントリの内容が、あなたの良き雀魂ライフのお手伝いとなれば幸いです。

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

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

2. Tampermonkeyにdownloadlogsを登録する

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

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

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

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

3. Tampermonkeyにsoul2nagaを登録する

2と同様の手順で、Tampermonkeyに以下のスクリプトを登録してください。

パラメーターの都合上、そのままでは魂天は雀聖3として扱われます。もしあなたが魂天である場合、36行目をコメントアウト(行頭に // を追加する)し、37~40行目のうち対象の行(たとえば、頂上決戦でない半荘戦は37行目)をアンコメント(行頭の // を削除する)すると、魂珠の増減数を解析できます。

雀聖以下の場合は特に変更の必要はありません。
// Copyright 2021-2024 HASEBA Junya
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//    http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// ==UserScript==
// @name         soul2naga
// @namespace    lions.blue
// @icon         http://1.gravatar.com/avatar/6fa3836d10d691125749472297cf516a
// @version      2.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() {
    /**
     * @type {string} 順位点配分・素点倍率を固定値で指定する際のパラメーター。
     *
     * nullを指定した場合は、段位と卓のレベルに応じた順位点配分・素点倍率を使用する。
     * このとき、パラメーターの都合上、魂天は雀聖3として扱う。
     *
     * パラメーターの記述ルールは公式の説明( https://twitter.com/NAGA025/status/1768508358179615190 )を参照のこと。
     * 魂天視点で解析する場合は、36行目をコメントアウトし、37~40行目の該当行を有効化すると魂珠の増減数がパラメーターになる。
     */
    const CUSTOM_PARAMETER = null;
    // const CUSTOM_PARAMETER = "[0.5, 0.2, -0.2, -0.5], [0.5, 0.2, -0.2, -0.5], [0.5, 0.2, -0.2, -0.5], [0.5, 0.2, -0.2, -0.5], 0";    // 魂天・半荘の場合
    // const CUSTOM_PARAMETER = "[1.0, 0.4, -0.4, -1.0], [1.0, 0.4, -0.4, -1.0], [1.0, 0.4, -0.4, -1.0], [1.0, 0.4, -0.4, -1.0], 0";    // 魂天・半荘・頂上決戦の場合
    // const CUSTOM_PARAMETER = "[0.3, 0.1, -0.1, -0.3], [0.3, 0.1, -0.1, -0.3], [0.3, 0.1, -0.1, -0.3], [0.3, 0.1, -0.1, -0.3], 0";    // 魂天・東風の場合
    // const CUSTOM_PARAMETER = "[0.6, 0.2, -0.2, -0.6], [0.6, 0.2, -0.2, -0.6], [0.6, 0.2, -0.2, -0.6], [0.6, 0.2, -0.2, -0.6], 0";    // 魂天・東風・頂上決戦の場合

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

    /** @type {string} 天鳳牌譜エディタの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 = convertToViewerUrls(soulJson);
                links[i].href = buildDownloadHref(urls);
                links[i].download = buildFileName(links[i].download);
                break;
            }
        }
    }, {capture: true});

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

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

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

        // title内の卓名を雀魂っぽく変換し、順位点配分・素点倍率を付与する。
        const title = structuredClone(soulPaifu.title);
        title[0] = toSoulRoom(title[0]);
        title[1] = buildPtEVParameter(title[0], soulPaifu.dan);

        // プレイヤー名をエンコードする。
        const name = structuredClone(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 = structuredClone(soulPaifu.rule);
        rule.disp = toSoulRoom(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} tenhouRoom 天鳳っぽい卓名を指定する。
     * @returns {string} 雀魂っぽい卓名を返す。
     */
    function toSoulRoom(tenhouRoom) {
        // 卓名を雀魂っぽく変換する。
        //
        // 半荘: ○の間四人南
        // 東風: ○の間四人東
        return tenhouRoom.replace(/(.)喰赤/, "四人$1");
    }

    /**
     * 順位点配分・素点倍率を組み立てる。
     *
     * @param {string} room 卓名を指定する。
     * @param {Array<string>} dan プレイヤーの段位を指定する。
     * @returns {string} 順位点配分・素点倍率を返す。
     */
    function buildPtEVParameter(room, dan) {
        // カスタムパラメーターが指定されている場合はそのまま利用する。
        if (CUSTOM_PARAMETER !== null) {
            return CUSTOM_PARAMETER;
        }

        // 順位点配分・素点倍率を組み立てる。
        const pointTable = fetchPointTable(room);
        if (pointTable !== null) {
            return JSON.stringify(dan.map(function(v) {
                const rank = v.startsWith("魂天") ? "雀聖★3" : v;
                return pointTable[0].concat(pointTable[1][rank]);
            }).concat(1)).match(/^\[(.+)\]$/)[1];
        } else {
            return "";
        }
    }

    /**
     * 卓に応じた順位点配分テーブルを取得する。
     *
     * @param {string} room 卓名を指定する。
     * @returns {Array<Array<number>, object>} 順位配分点テーブルを返す。
     */
    function fetchPointTable(room) {
        // 卓に応じた順位点配分テーブルを返す。
        //
        // 参考URL:
        //     https://mahjongsoul.info/how_to_enjoy1/
        switch (room) {
            case "銅の間四人南":
                return [
                    [35, 15, -5], {
                        "初心★1": -15,
                        "初心★2": -15,
                        "初心★3": -15,
                        "雀士★1": -35,
                        "雀士★2": -55,
                        "雀士★3": -75,
                    },
                ];
            case "銀の間四人南":
                return [
                    [55, 25, -5], {
                        "雀士★1": -35,
                        "雀士★2": -55,
                        "雀士★3": -75,
                        "雀傑★1": -95,
                        "雀傑★2": -115,
                        "雀傑★3": -135,
                    },
                ];
            case "金の間四人南":
                return [
                    [95, 45, -5], {
                        "雀傑★1": -95,
                        "雀傑★2": -115,
                        "雀傑★3": -135,
                        "雀豪★1": -180,
                        "雀豪★2": -195,
                        "雀豪★3": -210,
                    },
                ]
            case "玉の間四人南":
                return [
                    [125, 60, -5], {
                        "雀豪★1": -180,
                        "雀豪★2": -195,
                        "雀豪★3": -210,
                        "雀聖★1": -225,
                        "雀聖★2": -240,
                        "雀聖★3": -255,
                    },
                ];
            case "王座の間四人南":
                return [
                    [135, 65, -5], {
                        "雀聖★1": -225,
                        "雀聖★2": -240,
                        "雀聖★3": -255,
                    },
                ];
            case "銅の間四人東":
                return [
                    [25, 10, -5], {
                        "初心★1": -15,
                        "初心★2": -15,
                        "初心★3": -15,
                        "雀士★1": -25,
                        "雀士★2": -35,
                        "雀士★3": -45,
                    },
                ];
            case "銀の間四人東":
                return [
                    [35, 15, -5], {
                        "雀士★1": -25,
                        "雀士★2": -35,
                        "雀士★3": -45,
                        "雀傑★1": -55,
                        "雀傑★2": -65,
                        "雀傑★3": -75,
                    },
                ];
            case "金の間四人東":
                return [
                    [55, 25, -5], {
                        "雀傑★1": -55,
                        "雀傑★2": -65,
                        "雀傑★3": -75,
                        "雀豪★1": -95,
                        "雀豪★2": -105,
                        "雀豪★3": -115,
                    },
                ];
            case "玉の間四人東":
                return [
                    [70, 35, -5], {
                        "雀豪★1": -95,
                        "雀豪★2": -105,
                        "雀豪★3": -115,
                        "雀聖★1": -125,
                        "雀聖★2": -135,
                        "雀聖★3": -145,
                    },
                ];
            case "王座の間四人東":
                return [
                    [75, 35, -5], {
                        "雀聖★1": -125,
                        "雀聖★2": -135,
                        "雀聖★3": -145,
                    },
                ];
            default:
                return null;
        }
    }

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

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

        // 役名をNAGAが解析可能な表記に変換する。
        // ダブロン・トリロンに対応するため複数回繰り返す。
        for (let i = 1; i < nagaLog[16].length; i += 2) {
            // 和了者の自風牌を算出する。
            //
            // 算出方法:
            //     (和了者のプレイヤー番号 - 親の位置 + 4) % 4
            const seatWind = ["東", "南", "西", "北"][
                (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, prevalentWind, seatWind);
                }
            ));
        }

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

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

    /**
     * ダウンロードリンクの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 toSoulRoom(baseFileName).replace(".json", ".txt");
    }
})();

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

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

5. NAGAに解析させる

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

6. 謝辞

「2. Tampermonkeyにdownloadlogsを登録する」で利用しているdownloadlogs.jsを作成された Equimさん に感謝いたします。ありがとうございます。本エントリはNAGAに関するものですが、Equimさんは Mortal の開発者でもあります。いつもお世話になっております。

パラメーターを作る際の順位点の配分は 雀魂.info の記事を参考にさせていただきました。ありがとうございます。こちらもいつもお世話になっております。