背景
オンラインの麻雀アプリ雀魂(じゃんたま)には、友人戦という機能があり、フレンドや共通の部屋番号を入力したプレイヤーと対戦ができます
(https://mahjongsoul.info/private_match/ より引用)
私は、友人と日々その機能を使って麻雀を遊んでいましたが、成績管理が難しいと感じていました
オフラインで麻雀を打っている際には、成績管理専用の紙があり、対局が終わるごとにメモをつけていましたが、オンラインでは対局結果が出るのは一瞬で、振り返りはできますが、すぐ新しい対戦を行うことが多くどうも面倒でした
目的
日々の対局結果のデータを取得し、個人の成績管理を行う
具体的には、Mリーグのような、和了率や放銃率などの各種スタッツと、順位点を含めたポイントまで計算できることを目指しました
プレイヤー名 | ポイント | 和了率 | 放銃率 | ... |
---|---|---|---|---|
あああ | 200.2 | 25.8% | 11.9% | |
いいい | ||||
ううう | ||||
えええ | ||||
おおお |
やりたいこと
大きく分けて以下の3つです
-
雀魂の対局結果を取得
- できるだけ手動入力は避けたい
-
各種スタッツ、ポイントの計算
- 各対局の立直回数、和了回数、放銃回数の集計ができる
- 通算の立直率、和了率、放銃率、副露率、ポイントの集計ができる
- ポイントは、様々なルールに対応できるように
-
アクセスしやすい場所への格納
- 自分ひとりだけでなく、友人も含め簡単に見ることができる
このページでは
1.雀魂の対局結果を取得
について記述します
雀魂の対局結果を取得
まず、雀魂はアプリもありますが、ブラウザゲームで、特定のURLから任意の対局(以降牌譜と呼ぶ)を見ることができます
ここに着目し、何とかファイル化して読み込む方法はこちらのサイトを参考にしました
雀魂 API 解析にゃ! Wiki*
URLのページにはファイルとして保存する方法が記載されており、また、同サイトの別ページには読み込んだjson形式のファイルを詳細な読み方まで書いており自分のニーズを満たしていました
APIについての説明もありましたが、目的には不要であったため今回は使用しておりません
ファイルとして保存する部分は、手動ではありますが、ひとまず目的が達成できそうです
自分は各種スタッツの集計のため、最終結果だけでなく途中の動きもある程度記録として保持しておきたかったため、jsonファイルをデータとして格納する必要がありました
データから分かったこと
実際にデータから読み込むことができたのは
- プレイヤー情報
- 対局結果
- 手牌
- ツモった牌
- 捨てた牌
- 立直
- 副露
- 和了
- 点数移動
でした
成績だけを使うなら、読み込むのはファイルの最初当たりの対局結果だけでよく、容易でした
ただ、それ以外は各プレイヤーの行動について時系列的にログが入っているため、必要な情報を選んでデータとして保存する必要があります
また、困ったのは放銃データがないことです
和了のログはありますが、誰から上がったのかがわかりません
(ツモの場合もあるので項目として入れなかったのかもしれません)
点数移動があるため、ツモの場合を除き、点数が減ったプレイヤーを取得しようとしましたが正確ではありません
点数が減ったプレイヤーが放銃者ではない例
端的に言うと、リーチ者がいた場合です
リーチ棒分のポイント減少があり、局終わりの点数はマイナスになります
特に30符1翻は0本場の場合、1000点であるため、リーチ者と放銃者がそれぞれ1000点マイナスとなりデータ上まったく区別がつきません
この放銃者の対応として、最終ツモ番を保持しておくことで、解決しました
理由としてツモ以外の和了の場合、直前に捨て牌(槍槓の場合カンした牌)が和了牌になり、直前にツモったプレイヤーが放銃者になるためです
このような形でjsonファイルから、必要な情報を抜き出し最終的に
総局数、各プレイヤーの和了回数、立直回数、放銃回数というMリーグ同様のスタッツを取得できるようになりました
下のようなものです
https://mj-news.net/news/mleague-result/20241011229707 より引用
Pythonコード
以下jsonファイルから読み込むPythonコードになります
(総合成績のために副露局数も取得しています)
関数の実行部分と出力部分は書いていないので、各自で出力してください!
import json
import pandas as pd
from collections import defaultdict
import os
import csv
# --- ユーティリティ関数 ---
def load_json(file_path):
file_path = os.path.join("paifu_txt", file_path)
"""JSONファイルを読み込む"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
if not content.strip(): # ファイルが空の場合
raise ValueError(f"ファイル {file_path} は空です。")
return json.loads(content)
except json.JSONDecodeError as e:
raise ValueError(f"ファイル {file_path} のJSON形式が不正です: {e}")
def load_existing_data(file_path):
"""既存のCSVデータを読み込む"""
try:
return pd.read_csv(file_path)
except FileNotFoundError:
return pd.DataFrame() # ファイルが存在しない場合は空のデータフレームを返す
def process_furo_flags(furo_flag, user_stats):
"""副露フラグを確認して副露回数を加算し、フラグをリセットする"""
for i in range(4):
if furo_flag[i]:
user_stats[i]["furo_games"] += 1
return [False] * 4 # フラグをリセット
def process_new_round(user_stats, furo_flag):
"""局開始時の処理"""
for i in range(4):
user_stats[i]["total_games"] += 1
return process_furo_flags(furo_flag, user_stats)
def process_discard_tile(result_value, user_stats):
"""打牌時の処理"""
seat = result_value.get("data", {}).get("seat")
if result_value.get("data", {}).get("is_liqi"):
user_stats[seat]["riichi_count"] += 1
return seat
def process_hule(result_value, user_stats, last_discard_seat):
"""上がり時の処理"""
hules = result_value.get("data", {}).get("hules", [])
for hule in hules:
agari_seat = hule.get("seat")
user_stats[agari_seat]["agari_count"] += 1
if not hule.get("zimo") and last_discard_seat is not None:
user_stats[last_discard_seat]["houjuu_count"] += 1
return None # 上がり後にリセット
def seat_to_seat(seat_number):
"""座席番号を整数に変換する関数"""
seat = ['東', '南', '西', '北']
return seat[seat_number]
def extract_data(input_file, output_file):
try:
# 既存のデータを読み込む
existing_data = load_existing_data(output_file)
existing_uuids = set(existing_data['uuid']) if not existing_data.empty else set()
user_stats = defaultdict(lambda: {
"total_games": 0,
"riichi_count": 0,
"houjuu_count": 0,
"furo_games": 0,
"agari_count": 0
})
# 新しいデータを読み込む
data = load_json(input_file)
new_uuid = data.get("head", {}).get("uuid", "")
# UUIDが既存データに含まれている場合はスキップ
if new_uuid in existing_uuids:
print(f"UUID {new_uuid} は既に存在しています。処理をスキップします。")
return
accounts = data.get("head", {}).get("accounts", [])
result = data.get("head", {}).get("result", []).get("players", [])
data_section = data.get("data", {}).get("data", {}).get("actions", [])
furo_flag = [False] * 4
last_discard_seat = None
if data_section:
# データセクションの処理
for action_item in data_section:
result_value = action_item.get("result")
if result_value:
name = result_value.get("name")
if name == '.lq.RecordNewRound':
furo_flag = process_new_round(user_stats, furo_flag)
elif name == '.lq.RecordDiscardTile':
last_discard_seat = process_discard_tile(result_value, user_stats)
elif name == '.lq.RecordHule':
last_discard_seat = process_hule(result_value, user_stats, last_discard_seat)
elif name == '.lq.RecordChiPengGang':
furo_seat = result_value.get("data", {}).get("seat")
furo_flag[furo_seat] = True
furo_flag = process_furo_flags(furo_flag, user_stats)
furo_flag = [False] * 4
last_discard_seat = None
コードは一部を抜粋しましたが、読み込みはこれで可能だと思います
accounts
には、プレイヤー情報
result
には、対局結果
data_section
には、各種データを読み込んだものが入っており
user_stats
に1対局に関する集計情報を格納しています
次にこれらをまとめるのが
2. 各種スタッツ、ポイントの計算の作業になります
時間があれば書こうと思いますのでお待ちください!