import csv import html import json import logging import math import os import urllib.parse from datetime import datetime from statistics import median from string import Template import openpyxl def _safe_nickname(nickname: str) -> str: return "".join( c if c.isalnum() or c in ("_", "-", "\u4e00", "\u9fa5") else "_" for c in nickname ) def _parse_time(value) -> float: text = str(value or "").strip() if not text: return 0.0 try: if text.endswith("Z"): return datetime.fromisoformat(text[:-1]).timestamp() return datetime.strptime(text, "%Y-%m-%d %H:%M:%S").timestamp() except Exception: try: return datetime.fromisoformat(text).timestamp() except Exception: return 0.0 def read_results( nickname: str, output_format: str = "xlsx", output_root: str | None = None, ) -> list[dict]: safe_nick = _safe_nickname(nickname) if output_root is None: base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) output_root = os.path.join(base_dir, "results", "majsoul", safe_nick) filepath = os.path.join(output_root, f"results.{output_format}") if not os.path.exists(filepath): logging.warning("No results found for %s at %s", nickname, filepath) return [] records = [] if output_format == "csv": with open(filepath, "r", encoding="utf-8") as f: records.extend(csv.DictReader(f)) elif output_format == "xlsx": wb = openpyxl.load_workbook(filepath, read_only=True, data_only=True) try: ws = wb.active rows = iter(ws.iter_rows(values_only=True)) first_row = next(rows, None) if first_row is not None: headers = [str(value) if value is not None else "" for value in first_row] for row in rows: records.append( { header: row[index] if index < len(row) and row[index] is not None else "" for index, header in enumerate(headers) if header } ) finally: wb.close() else: raise ValueError(f"Unsupported output format: {output_format}") records.sort(key=lambda row: _parse_time(row.get("startTime") or row.get("timestamp"))) return records def _to_float(value, *, percent: bool = False) -> float | None: if value is None or isinstance(value, bool): return None text = str(value).strip() if not text: return None if percent: text = text.removesuffix("%").strip() try: number = float(text) except (TypeError, ValueError): return None return number if math.isfinite(number) else None def _to_int(value) -> int | None: number = _to_float(value) if number is None or not number.is_integer(): return None return int(number) def _mean(values: list[float]) -> float | None: return sum(values) / len(values) if values else None def _quantile(values: list[float], probability: float) -> float: """Return a linearly interpolated quantile for a non-empty sample.""" if not values: raise ValueError("Quantile requires at least one value.") if not 0 <= probability <= 1: raise ValueError("Quantile probability must be between 0 and 1.") ordered = sorted(values) position = (len(ordered) - 1) * probability lower_index = math.floor(position) upper_index = math.ceil(position) if lower_index == upper_index: return ordered[lower_index] weight = position - lower_index return ordered[lower_index] * (1 - weight) + ordered[upper_index] * weight def rolling_average(values: list[float | None], window: int) -> list[float | None]: """Return a full-window rolling mean, tolerating sparse optional values.""" if window <= 0: raise ValueError("Rolling window must be positive.") minimum_count = max(1, math.ceil(window * 0.6)) result = [] for index in range(len(values)): if index + 1 < window: result.append(None) continue current = [value for value in values[index - window + 1 : index + 1] if value is not None] result.append(_mean(current) if len(current) >= minimum_count else None) return result def _rolling_weighted_ai(points: list[dict], window: int) -> list[float | None]: minimum_count = max(1, math.ceil(window * 0.6)) result = [] for index in range(len(points)): if index + 1 < window: result.append(None) continue current = points[index - window + 1 : index + 1] weighted = [ (point["aiNumerator"], point["aiDenominator"]) for point in current if point["aiNumerator"] is not None and point["aiDenominator"] is not None and point["aiDenominator"] > 0 ] if len(weighted) >= minimum_count: numerator = sum(pair[0] for pair in weighted) denominator = sum(pair[1] for pair in weighted) result.append(numerator / denominator * 100 if denominator else None) continue rates = [point["aiRate"] for point in current if point["aiRate"] is not None] result.append(_mean(rates) if len(rates) >= minimum_count else None) return result def _aggregate_rate( points: list[dict], *, rate_key: str, numerator_key: str, denominator_key: str, ) -> tuple[float | None, int | None, bool]: weighted = [ (point[numerator_key], point[denominator_key]) for point in points if point[numerator_key] is not None and point[denominator_key] is not None and point[denominator_key] > 0 ] if weighted: numerator = sum(pair[0] for pair in weighted) denominator = sum(pair[1] for pair in weighted) return (numerator / denominator * 100 if denominator else None, denominator, True) rates = [point[rate_key] for point in points if point[rate_key] is not None] return _mean(rates), None, False def _build_rating_batches( points: list[dict], overall_rating_mean: float, *, batch_size: int = 20, ) -> list[dict]: """Build newest-anchored, equal-sized review batches.""" if batch_size <= 0: raise ValueError("Batch size must be positive.") ranges = [] end = len(points) while end > 0: start = max(0, end - batch_size) ranges.append((start, end)) end = start ranges.reverse() batches = [] for batch_index, (start, end) in enumerate(ranges): current = points[start:end] ratings = [point["rating"] for point in current] rating_mean = _mean(ratings) ai_rate, ai_denominator, _ = _aggregate_rate( current, rate_key="aiRate", numerator_key="aiNumerator", denominator_key="aiDenominator", ) bad_rate_5, bad_denominator_5, _ = _aggregate_rate( current, rate_key="badRate5", numerator_key="badCount5", denominator_key="badDenominator", ) bad_rate_10, bad_denominator_10, _ = _aggregate_rate( current, rate_key="badRate10", numerator_key="badCount10", denominator_key="badDenominator", ) batches.append( { "id": f"batch-{batch_index}", "startIndex": start, "endIndex": end - 1, "startLabel": points[start]["label"], "endLabel": points[end - 1]["label"], "label": f'{points[start]["label"]}–{points[end - 1]["label"]}', "count": len(current), "ratingMean": rating_mean, "ratingDelta": ( rating_mean - overall_rating_mean if rating_mean is not None else None ), "aiRate": ai_rate, "aiDenominator": ai_denominator, "badRate5": bad_rate_5, "badRate10": bad_rate_10, "badDenominator": bad_denominator_5 or bad_denominator_10, } ) return batches def _infer_source(record: dict, mode: str) -> str: source = str(record.get("source") or "").strip().lower() if source in ("majsoul", "tenhou"): return source if "p-" in mode.lower(): return "tenhou" if mode.isdigit(): return "majsoul" return "" def _rating_axis_bounds(ratings: list[float]) -> tuple[int, int]: minimum = min(ratings) maximum = max(ratings) if minimum >= 80: lower = 80 elif minimum >= 60: lower = 60 elif minimum >= 40: lower = 40 else: lower = 0 upper = 100 if maximum <= 100 else int(math.ceil(maximum / 10) * 10) return lower, upper def _rate_axis_min(values: list[float]) -> int: minimum = min(values) if minimum >= 60: return 60 if minimum >= 40: return 40 return 0 def _rate_axis_scale(values: list[float]) -> tuple[float, float]: """Return a padded percent axis with four stable, readable intervals.""" if not values: return 10.0, 2.5 observed_maximum = max(values) if observed_maximum <= 0: return 1.0, 0.25 padded_maximum = observed_maximum * 1.1 magnitude = 10 ** math.floor(math.log10(padded_maximum)) normalized = padded_maximum / magnitude multipliers = (1.0, 1.2, 1.6, 2.0, 2.4, 3.2, 4.0, 5.0, 6.0, 8.0, 10.0) multiplier = next( candidate for candidate in multipliers if normalized <= candidate ) maximum = multiplier * magnitude interval = maximum / 4 return round(maximum, 10), round(interval, 10) def _histogram(values: list[float], lower: int, upper: int, bins: int = 10) -> list[dict]: width = (upper - lower) / bins counts = [0] * bins for value in values: index = min(bins - 1, max(0, int((value - lower) / width))) counts[index] += 1 return [ { "label": f"{lower + index * width:.0f}–{lower + (index + 1) * width:.0f}", "lower": lower + index * width, "upper": lower + (index + 1) * width, "count": count, } for index, count in enumerate(counts) ] def prepare_dashboard_data(records: list[dict], plot_limit: int | None = None) -> dict | None: """Normalize result rows into a single, tested dashboard data contract.""" selected = records if plot_limit is not None and plot_limit > 0 and len(selected) > plot_limit: selected = selected[-plot_limit:] points = [] for record in selected: rating = _to_float(record.get("rating")) if rating is None: continue ai_numerator = _to_int(record.get("aiConsistencyNumerator")) ai_denominator = _to_int(record.get("aiConsistencyDenominator")) ai_rate = _to_float(record.get("aiConsistencyRate"), percent=True) if ai_numerator is not None and ai_denominator is not None and ai_denominator > 0: ai_rate = ai_numerator / ai_denominator * 100 bad_denominator = _to_int(record.get("badMoveDenominator")) bad_count_5 = _to_int(record.get("badMoveCount5")) bad_count_10 = _to_int(record.get("badMoveCount10")) bad_rate_5 = _to_float(record.get("badMoveRate5"), percent=True) bad_rate_10 = _to_float(record.get("badMoveRate10"), percent=True) if bad_denominator is not None and bad_denominator > 0: if bad_count_5 is not None: bad_rate_5 = bad_count_5 / bad_denominator * 100 if bad_count_10 is not None: bad_rate_10 = bad_count_10 / bad_denominator * 100 mode = str(record.get("mode") or "—") started_at = str(record.get("startTime") or record.get("timestamp") or "") points.append( { "index": len(points) + 1, "label": f"#{len(points) + 1}", "startedAt": started_at, "rating": rating, "aiRate": ai_rate, "aiNumerator": ai_numerator, "aiDenominator": ai_denominator, "badRate5": bad_rate_5, "badCount5": bad_count_5, "badRate10": bad_rate_10, "badCount10": bad_count_10, "badDenominator": bad_denominator, "source": _infer_source(record, mode), "mode": mode, "modelTag": str(record.get("modelTag") or ""), "uuid": str(record.get("uuid") or ""), "resultUrl": str(record.get("resultUrl") or ""), "paipuUrl": str(record.get("paipuUrl") or ""), } ) if not points: return None ratings = [point["rating"] for point in points] rating_mean = _mean(ratings) assert rating_mean is not None total_games = len(points) trend_window = 10 if total_games >= 10 else (5 if total_games >= 8 else None) rating_rolling = ( rolling_average([point["rating"] for point in points], trend_window) if trend_window else [None] * total_games ) ai_rolling = ( _rolling_weighted_ai(points, trend_window) if trend_window else [None] * total_games ) comparison_window = min(20, total_games // 2) if total_games >= 10 else 0 recent_window = comparison_window or min(20, total_games) recent_average = _mean(ratings[-recent_window:]) previous_average = ( _mean(ratings[-comparison_window * 2 : -comparison_window]) if comparison_window else None ) comparison_delta = ( recent_average - previous_average if recent_average is not None and previous_average is not None else None ) ai_rate, ai_denominator, ai_weighted = _aggregate_rate( points, rate_key="aiRate", numerator_key="aiNumerator", denominator_key="aiDenominator", ) bad_rate_5, bad_denominator_5, bad_weighted_5 = _aggregate_rate( points, rate_key="badRate5", numerator_key="badCount5", denominator_key="badDenominator", ) bad_rate_10, bad_denominator_10, bad_weighted_10 = _aggregate_rate( points, rate_key="badRate10", numerator_key="badCount10", denominator_key="badDenominator", ) rating_axis_min, rating_axis_max = _rating_axis_bounds(ratings) ai_values = [point["aiRate"] for point in points if point["aiRate"] is not None] ai_axis_min = _rate_axis_min(ai_values) if ai_values else 0 bad_rate_values = [ value for point in points for value in (point["badRate5"], point["badRate10"]) if value is not None ] bad_rate_axis_max, bad_rate_axis_interval = _rate_axis_scale(bad_rate_values) histogram = ( _histogram(ratings, rating_axis_min, rating_axis_max) if total_games >= 8 else [] ) worst_games = sorted(points, key=lambda point: (point["rating"], point["index"]))[:5] highlight_count = min(5, max(1, math.ceil(total_games * 0.05))) highlighted = { point["index"] for point in sorted(points, key=lambda point: (point["rating"], point["index"]))[ :highlight_count ] } for point in points: point["isLow"] = point["index"] in highlighted dates = [point["startedAt"] for point in points if point["startedAt"]] sources = sorted({point["source"] for point in points if point["source"]}) modes = sorted({point["mode"] for point in points if point["mode"] and point["mode"] != "—"}) model_tags = sorted({point["modelTag"] for point in points if point["modelTag"]}) return { "points": points, "totalGames": total_games, "trendWindow": trend_window, "ratingRolling": rating_rolling, "ratingMean": rating_mean, "aiRolling": ai_rolling, "ratingMedian": median(ratings), "ratingDenseLower": _quantile(ratings, 0.25), "ratingDenseUpper": _quantile(ratings, 0.75), "recentWindow": recent_window, "recentAverage": recent_average, "comparisonWindow": comparison_window, "comparisonDelta": comparison_delta, "aiRate": ai_rate, "aiDenominator": ai_denominator, "aiWeighted": ai_weighted, "badRate5": bad_rate_5, "badRate10": bad_rate_10, "badDenominator": bad_denominator_5 or bad_denominator_10, "badWeighted": bad_weighted_5 or bad_weighted_10, "ratingAxisMin": rating_axis_min, "ratingAxisMax": rating_axis_max, "aiAxisMin": ai_axis_min, "badRateAxisMax": bad_rate_axis_max, "badRateAxisInterval": bad_rate_axis_interval, "histogram": histogram, "worstGames": worst_games, "ratingBatches": _build_rating_batches(points, rating_mean), "dateStart": dates[0] if dates else "", "dateEnd": dates[-1] if dates else "", "sources": sources, "modes": modes, "modelTags": model_tags, } def _format_number(value: float | None, digits: int = 1, suffix: str = "") -> str: return "—" if value is None else f"{value:.{digits}f}{suffix}" def _display_source(sources: list[str]) -> str: labels = {"majsoul": "雀魂", "tenhou": "天凤"} return " / ".join(labels.get(source, source) for source in sources) or "数据源未标注" def _date_range(started_at: str, ended_at: str) -> str: start = started_at[:10] if started_at else "" end = ended_at[:10] if ended_at else "" if not start: return "日期未标注" return start if start == end or not end else f"{start} — {end}" def _safe_external_url(value: str) -> str | None: try: parsed = urllib.parse.urlsplit(value) except (TypeError, ValueError): return None if parsed.scheme not in ("http", "https") or not parsed.netloc: return None return value def _worst_game_rows(data: dict) -> str: rows = [] for point in data["worstGames"]: link = _safe_external_url(point["resultUrl"]) or _safe_external_url(point["paipuUrl"]) link_html = ( f'打开检讨' if link else '无链接' ) rows.append( "" f'#{point["index"]}' f'{html.escape(point["startedAt"] or "—")}' f'{html.escape(point["mode"])}' f'{point["rating"]:.2f}' f'{_format_number(point["aiRate"], 1, "%")}' f'{_format_number(point["badRate5"], 1, "%")}' f"{link_html}" "" ) return "".join(rows) def _safe_json(value) -> str: return json.dumps(value, ensure_ascii=False, separators=(",", ":")).replace(" $page_title

Mortal analysis report

$nickname

$metadata

全样本 Rating 平均值
$rating_mean
$total_games 半庄
Rating 中位数
$rating_median
全样本
$ai_label
$ai_rate
$ai_note
恶手率
5% 恶手
$bad_rate_5
10% 恶手
$bad_rate_10
$bad_sample_note

Rating 推移

Rating 中间50% $rating_dense_range
$ai_section $distribution_section

检讨候选

$worst_rows
序号开局时间模式RatingAI 一致率5% 恶手率操作
""") def generate_html( nickname: str, output_path: str, format_type: str = "xlsx", plot_limit: int | None = None, results_root: str | None = None, ) -> str | None: data = prepare_dashboard_data( read_results(nickname, format_type, output_root=results_root), plot_limit=plot_limit, ) if not data: logging.warning("No valid rating data found to plot.") return None source_label = _display_source(data["sources"]) mode_label = ", ".join(data["modes"]) or "模式未标注" model_label = ", ".join(data["modelTags"]) or "模型未标注" metadata = " · ".join( [ _date_range(data["dateStart"], data["dateEnd"]), f'{data["totalGames"]}半庄', source_label, mode_label, f"Mortal {model_label}", ] ) ai_label = "加权 AI 一致率" if data["aiWeighted"] else "平均 AI 一致率" if data["aiRate"] is None: ai_note = "当前数据未包含有效一致率" elif data["aiDenominator"]: ai_note = f'{data["aiDenominator"]} 次决策样本' else: ai_note = "按有值半庄简单平均" if data["badRate5"] is None and data["badRate10"] is None: bad_sample_note = "未采集;开启 analyze_bad_move_rate 后显示" elif data["badDenominator"]: bad_sample_note = f'{data["badDenominator"]} 次决策样本' else: bad_sample_note = "按有值半庄简单平均" ai_section = "" if data["aiRate"] is not None: ai_section = """

AI 一致率推移

""" distribution_section = "" if data["histogram"]: distribution_section = """

Rating 分布

""" rendered = REPORT_TEMPLATE.substitute( page_title=html.escape(f"{nickname} · Mortal 分析报告"), nickname=html.escape(nickname), metadata=html.escape(metadata), rating_dense_range=( f'{data["ratingDenseLower"]:.1f}–{data["ratingDenseUpper"]:.1f}' ), rating_mean=_format_number(data["ratingMean"], 2), total_games=str(data["totalGames"]), rating_median=_format_number(data["ratingMedian"], 2), ai_label=ai_label, ai_rate=_format_number(data["aiRate"], 1, "%"), ai_note=html.escape(ai_note), bad_rate_5=_format_number(data["badRate5"], 1, "%"), bad_rate_10=_format_number(data["badRate10"], 1, "%"), bad_sample_note=html.escape(bad_sample_note), ai_section=ai_section, distribution_section=distribution_section, worst_rows=_worst_game_rows(data), date_end=html.escape(data["dateEnd"][:10] or "未知日期"), payload=_safe_json(data), ) with open(output_path, "w", encoding="utf-8") as f: f.write(rendered) return output_path def save_png(html_path: str, png_path: str): from seleniumbase import SB abs_html = os.path.abspath(html_path) file_url = "file:///" + urllib.parse.quote(abs_html.replace("\\", "/")) with SB(uc=True, headless=True) as sb: sb.set_window_size(1500, 1100) sb.open(file_url) sb.wait_for_ready_state_complete() sb.wait_for_element_visible("#main") sb.sleep(1.0) report_height = sb.execute_script( "return Math.ceil(document.getElementById('main').getBoundingClientRect().height);" ) sb.set_window_size(1500, min(16000, int(report_height) + 240)) sb.sleep(0.5) sb.save_screenshot(png_path, selector="#main") def plot_results( nickname: str, plot_mode: str, output_format: str = "xlsx", plot_limit: int | None = None, output_root: str | None = None, ): if plot_mode in ["none", None]: return safe_nick = _safe_nickname(nickname) if output_root is None: base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) output_root = os.path.join(base_dir, "results", "majsoul", safe_nick) os.makedirs(output_root, exist_ok=True) html_path = os.path.join(output_root, f"report_{safe_nick}.html") png_path = os.path.join(output_root, f"report_{safe_nick}.png") logging.info( "Generating charts for %s (Mode: %s, Limit: %s)...", nickname, plot_mode, plot_limit or "all", ) result = generate_html( nickname, html_path, output_format, plot_limit=plot_limit, results_root=output_root, ) if not result: logging.warning("Skipping chart generation.") return if plot_mode in ["png", "both"]: try: save_png(html_path, png_path) logging.info("Saved PNG chart to: %s", png_path) except Exception as exc: logging.error("Failed to render PNG chart: %s", exc) if plot_mode in ["html", "both"]: logging.info("Saved HTML chart to: %s", html_path) elif plot_mode == "png": try: os.remove(html_path) except OSError: pass if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description="Generate Mortal Analysis Chart") parser.add_argument("nickname", help="Player nickname") parser.add_argument( "--plot-limit", "--plot_limit", type=int, default=None, help="Only use the latest N records for chart (default: all)", dest="plot_limit", ) args = parser.parse_args() plot_results(args.nickname, "both", "xlsx", plot_limit=args.plot_limit)