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 の記事を参考にさせていただきました。ありがとうございます。こちらもいつもお世話になっております。

2024/03/29(金)開幕(vs 東北楽天 第1回戦)

2024/03/29 21:50
【東北楽天 vs 埼玉西武 第1回戦】
(2024年03月29日/楽天モバイルパーク宮城)

埼玉西武  0 0 0  0 0 0  0 1 0  1
東北楽天  0 0 0  0 0 0  0 0 0  0

[勝] 今井    1勝0敗0S
[S] アブレイユ 0勝0敗1S
[敗] 早川    0勝1敗0S
2024年の開幕戦を白星で飾りました。

今日は今井が本当に素晴らしいピッチングでした。7回を投げて2安打無失点。そのヒット2本ともとらえられた当たりではありませんでしたし、奪三振も11と、まったく文句をつけようのないピッチングだったと思います。相性のいいイーグルスが相手だったとはいえ、開幕投手にふさわしい堂々たる内容でした。

打線のほうは6回まで早川の前に抑え込まれましたが、8回表、1アウトから金子侑がセンター前ヒットで出塁すると、盗塁で二進。続く外崎のタイムリースリーベースで1点を先制します。さらに9回、1アウト一二塁、さらに2アウト満塁のチャンスを作りましたが、追加点ならず。ここで追加点をあげられるチームになると、ピッチャーのやりくりが楽になるのですが……。

8回裏は甲斐野が移籍後初登板。2アウトからヒットとフォアボールで一二塁のピンチを招きましたが、小深田を三振に打ち取ってしのぎました。欲しいところで三振が取れるのはさすがです。

9回はアブレイユ。先頭の小郷にいきなりヒットを打たれてヒヤヒヤさせてくれましたが、浅村、島内、代打鈴木大をフライアウトにしてゲームセット。1点差を逃げ切りました。

2024/03/28(木)順位予想

2024/03/28 21:07
いよいよ明日、プロ野球が開幕します。というわけで、恒例の順位予想です。
順位チーム
1オリックスバファローズ
2埼玉西武ライオンズ
3千葉ロッテマリーンズ
4福岡ソフトバンクホークス
5北海道日本ハムファイターズ
6東北楽天ゴールデンイーグルス
どんなにライオンズのポジ要素を探しても2位が限界でした。昔からの芸風とはいえ、あれだけポジれる安仁屋さんはすげーわw

投手陣は(小さい不安はあっても)あまり心配はしていないのですが、打線の課題は今年も解決しなそうです。アギラーはそれなりにやってくれそうな雰囲気はあるものの、外野が手薄な問題は今年もそのままで、しばらくは取っ替え引っ替え使う形になりそうです。対外試合が始まったころは、西川、長谷川で外野は2枠確定くらいに思っていたのですが……w

3連覇中のバファローズから山本、山崎が抜けたこともあり、世間ではホークスに対する評価が高いようですが、私は野球の神様が存在していると思っているので、きっと戒めてくれると信じています。

2024/03/27(水)3月の雀魂振り返り

2024/03/27 22:29
明日は開幕前日なので順位予想、あさってからは野球の戦評が始まるので、今日のうちに振り返らないといけないことに気がつきました。

シアンフロこ | 雀魂牌譜屋

雀豪1まで落ちて上り坂がかなり緩くなったので、仮にラスを引いても平常心で打てるようになりました。トップと2着で取り返せるので。

ここ数ヶ月のぶんを取り戻すかのような上振れを引けたので、来月は雀豪2再チャレンジになります。

2024/03/26(火)部活動

2024/03/26 25:08
今日は会社の麻雀部の活動でした。

初心者も多くてゆっくりめのペースだったので、半荘2回だけしかできませんでしたが、いずれもトップということで結果としては良かったです。いい麻雀を打てていたかどうかは自信を持って言えませんが。リアル麻雀は終わった後にNAGAに聞けないからなぁw

普段は雀魂のラス回避麻雀ばかりなので、こういうトップ取り麻雀をやるのは刺激になります。来月以降も積極的に参加したいと思います。