#!/usr/bin/env python3
"""Build a static review for shared/enemy battle action payload semantics.

This report intentionally stays on static EXE-derived artifacts.  Monster
visual local slots are not re-promoted here; the useful static path for enemy
actions is actor +0x59 -> shared action payload -> hit unit -> result family.
"""
from __future__ import annotations

import html
import json
from collections import Counter
from pathlib import Path
from typing import Any


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"

ACTION_MAPPING = OUT / "battle_action_mapping.json"
SKILL_RECORDS = OUT / "battle_skill_records.json"
RESULT_FAMILY = OUT / "battle_result_family_dispatch_review.json"
RESULT_DISPLAY = OUT / "battle_result_display_branch_review.json"
SOUND_ROLE = OUT / "battle_sound_role_review.json"
MONSTER_FRAME_PROBE = OUT / "battle_monster_action_frame_probe.json"

TARGET_SCOPE_LABELS = {
    0x01: "자기/준비",
    0x05: "아군 전체",
    0x06: "적 전체",
    0x09: "아군 1명",
    0x0A: "적 1명",
}

HIT_CLASS_LABELS = {
    0x00: "상단/공중",
    0x01: "일반",
    0x02: "하단/지면",
    0x03: "잡기/밀착",
}

STATUS_LABELS = {
    0x00: "없음",
    0x01: "넘어짐",
    0x02: "휙 날아감",
    0x03: "행동정지",
    0x04: "독",
    0x05: "마비",
    0x06: "졸림",
}

FAMILY_CLASS_LABELS = {
    "default": "기타/기본",
    "command": "명령/상태",
    "damage": "공격/상태 판정",
    "recovery": "회복/부활",
    "atahoDrunk": "아타호 취기",
    "smashuEyeCandy": "스마슈 눈요기",
    "rinshanTaunt": "린샹 도발",
    "sukyeong": "수경",
}


def load_json(path: Path, fallback: Any | None = None) -> Any:
    if not path.exists():
        return {} if fallback is None else fallback
    return json.loads(path.read_text(encoding="utf-8"))


def hex_byte(value: int) -> str:
    return f"0x{value:02x}"


def unit_bytes(unit_hex: str) -> list[int]:
    return [int(part, 16) for part in unit_hex.split()]


def family_class(family: int) -> str:
    if family in (1, 2, 3):
        return "command"
    if 0x10 <= family <= 0x19:
        return "damage"
    if 0x20 <= family <= 0x23:
        return "recovery"
    if 0x24 <= family <= 0x2C:
        return "atahoDrunk"
    if family == 0x2D:
        return "smashuEyeCandy"
    if family == 0x2E:
        return "rinshanTaunt"
    if family == 0x2F:
        return "sukyeong"
    return "default"


def family_short_label(family: int, dispatch_by_index: dict[int, dict[str, Any]]) -> str:
    cls = family_class(family)
    row = dispatch_by_index.get(family) or {}
    role = str(row.get("role") or "")
    base = FAMILY_CLASS_LABELS.get(cls, cls)
    if cls == "damage":
        return f"{base} {hex_byte(family)}"
    if cls == "default" and role:
        return role
    return base


def decode_unit(unit_hex: str, dispatch_by_index: dict[int, dict[str, Any]]) -> dict[str, Any]:
    raw = unit_bytes(unit_hex)
    if len(raw) != 8:
        return {"rawHex": unit_hex, "error": "unit length is not 8"}
    family = raw[5]
    scope = raw[4]
    hit_class = raw[6]
    status = raw[7]
    dispatch = dispatch_by_index.get(family) or {}
    return {
        "rawHex": unit_hex,
        "bytes": raw,
        "coefficients": {
            "power": raw[0],
            "criticalOrAlt": raw[1],
            "hit": raw[2],
            "reserved": raw[3],
        },
        "targetScope": scope,
        "targetScopeHex": hex_byte(scope),
        "targetScopeLabel": TARGET_SCOPE_LABELS.get(scope, f"unknown {hex_byte(scope)}"),
        "resultFamily": family,
        "resultFamilyHex": hex_byte(family),
        "resultFamilyClass": family_class(family),
        "resultFamilyLabel": family_short_label(family, dispatch_by_index),
        "resultFamilyFunctionVaHex": dispatch.get("functionVaHex", ""),
        "resultFamilyRole": dispatch.get("role", ""),
        "resultFamilyNote": dispatch.get("note", ""),
        "hitClass": hit_class,
        "hitClassHex": hex_byte(hit_class),
        "hitClassLabel": HIT_CLASS_LABELS.get(hit_class, f"unknown {hex_byte(hit_class)}"),
        "status": status,
        "statusHex": hex_byte(status),
        "statusLabel": STATUS_LABELS.get(status, f"unknown {hex_byte(status)}"),
    }


def summarize_action_units(units: list[dict[str, Any]]) -> dict[str, Any]:
    families = sorted({unit["resultFamily"] for unit in units})
    scopes = sorted({unit["targetScope"] for unit in units})
    statuses = sorted({unit["status"] for unit in units})
    hit_classes = sorted({unit["hitClass"] for unit in units})
    classes = sorted({unit["resultFamilyClass"] for unit in units})
    coefficients = [unit["coefficients"] for unit in units]
    return {
        "unitCount": len(units),
        "targetScopes": scopes,
        "targetScopeLabels": [TARGET_SCOPE_LABELS.get(value, hex_byte(value)) for value in scopes],
        "resultFamilies": families,
        "resultFamilyLabels": [family_short_label(value, {}) for value in families],
        "resultFamilyClasses": classes,
        "statuses": statuses,
        "statusLabels": [STATUS_LABELS.get(value, hex_byte(value)) for value in statuses],
        "hitClasses": hit_classes,
        "hitClassLabels": [HIT_CLASS_LABELS.get(value, hex_byte(value)) for value in hit_classes],
        "coefficientTriples": [
            [coef["power"], coef["criticalOrAlt"], coef["hit"]]
            for coef in coefficients
        ],
        "hasDamageOrStatusFormula": any(unit["resultFamilyClass"] == "damage" for unit in units),
        "hasRecovery": any(unit["resultFamilyClass"] == "recovery" for unit in units),
        "hasStatusEffect": any(unit["status"] != 0 for unit in units),
        "hasSelfSetup": any(unit["targetScope"] == 1 for unit in units),
        "hasAllTarget": any(unit["targetScope"] in (5, 6) for unit in units),
    }


def format_distribution(counter: Counter[int], labels: dict[int, str] | None = None) -> list[dict[str, Any]]:
    rows = []
    for value, count in counter.most_common():
        label = labels.get(value, "") if labels else ""
        rows.append({
            "value": value,
            "valueHex": hex_byte(value),
            "label": label,
            "count": count,
        })
    return rows


def build() -> dict[str, Any]:
    mapping = load_json(ACTION_MAPPING)
    skill_records = load_json(SKILL_RECORDS)
    result_family = load_json(RESULT_FAMILY)
    result_display = load_json(RESULT_DISPLAY)
    sound_role = load_json(SOUND_ROLE)
    frame_probe = load_json(MONSTER_FRAME_PROBE)

    dispatch_by_index: dict[int, dict[str, Any]] = {}
    for row in result_family.get("dispatchRows") or []:
        index = int(row.get("index", 0))
        dispatch_by_index[index] = row

    rows: list[dict[str, Any]] = []
    scope_counter: Counter[int] = Counter()
    family_counter: Counter[int] = Counter()
    status_counter: Counter[int] = Counter()
    hit_counter: Counter[int] = Counter()
    family_class_counter: Counter[str] = Counter()

    for row in mapping.get("sharedRows") or []:
        units = [
            decode_unit(unit_hex, dispatch_by_index)
            for unit_hex in row.get("unitsHex") or []
        ]
        for unit in units:
            if "error" in unit:
                continue
            scope_counter[unit["targetScope"]] += 1
            family_counter[unit["resultFamily"]] += 1
            status_counter[unit["status"]] += 1
            hit_counter[unit["hitClass"]] += 1
            family_class_counter[unit["resultFamilyClass"]] += 1
        summary = summarize_action_units([unit for unit in units if "error" not in unit])
        rows.append({
            "skillId": row.get("skillId"),
            "skillIdHex": row.get("skillIdHex"),
            "name": row.get("name"),
            "entryVaHex": row.get("entryVaHex"),
            "payloadVaHex": row.get("payloadVaHex"),
            "metaHex": row.get("metaHex"),
            "mpCost": row.get("mpCost"),
            "effectCount": row.get("effectCount"),
            "prefixBytesHex": row.get("prefixBytesHex"),
            "units": units,
            "summary": summary,
            "phaseSource": row.get("phaseSource"),
            "staticInterpretation": interpretation(summary),
        })

    display_summary = result_display.get("summary") or []
    sound_presentation = sound_role.get("resolvedPresentation") or {}
    slot_status = frame_probe.get("status", "missing")
    raw_actor_audit = frame_probe.get("rawActor5aOpcodePatternScan") or {}
    packed_audit = frame_probe.get("packedActor58WriteWidthAudit") or {}

    summary = {
        "sharedActionRows": len(rows),
        "sharedHitUnits": sum((row["summary"]["unitCount"] for row in rows), 0),
        "targetScopeDistribution": format_distribution(scope_counter, TARGET_SCOPE_LABELS),
        "resultFamilyDistribution": [
            {
                **item,
                "class": family_class(item["value"]),
                "classLabel": FAMILY_CLASS_LABELS.get(family_class(item["value"]), family_class(item["value"])),
                "dispatchRole": (dispatch_by_index.get(item["value"]) or {}).get("role", ""),
                "functionVaHex": (dispatch_by_index.get(item["value"]) or {}).get("functionVaHex", ""),
            }
            for item in format_distribution(family_counter)
        ],
        "resultFamilyClassDistribution": [
            {
                "class": key,
                "label": FAMILY_CLASS_LABELS.get(key, key),
                "count": value,
            }
            for key, value in family_class_counter.most_common()
        ],
        "statusDistribution": format_distribution(status_counter, STATUS_LABELS),
        "hitClassDistribution": format_distribution(hit_counter, HIT_CLASS_LABELS),
    }

    return {
        "version": 1,
        "kind": "hwanse-battle-monster-shared-action-effect-review",
        "source": [
            "out/battle_action_mapping.json",
            "out/battle_skill_records.json",
            "out/battle_result_family_dispatch_review.json",
            "out/battle_result_display_branch_review.json",
            "out/battle_sound_role_review.json",
            "out/battle_monster_action_frame_probe.json",
        ],
        "status": "static-shared-monster-action-payload-and-result-family-grounded",
        "runtimeUsed": False,
        "summary": summary,
        "staticConclusions": [
            "몬스터/공용 기술의 확정 경로는 actor +0x59 shared action id가 payload를 고르고, payload의 8-byte hit unit이 대상 범위/결과 패밀리/판정/상태를 정한다.",
            "hit unit byte[5]는 result-family id이며 0x00546970[id] 디스패치로 공격/회복/취기/도발/수경 같은 전투 결과 처리를 가른다.",
            "MISS/스침/크리티컬 같은 결과 표시는 기술 payload가 아니라 per-hit result flag(+0x62)와 target result display script에서 분기한다.",
            "몬스터 본체 애니메이션 슬롯(actor +0x5a)은 0으로 초기화되고 0이 아닌 producer가 정적으로 발견되지 않았다. 따라서 일반 몬스터 표시 슬롯은 slot0만 확정이고, slot1..4는 후보로 남긴다.",
            "이 보고서는 몬스터 AI가 어떤 skillId를 언제 고르는지는 확정하지 않는다. 그 부분은 별도 AI/행동 선택 테이블 분석 대상이다.",
        ],
        "actor59PayloadPath": {
            "field": "actor +0x59",
            "meaning": "shared/player action id used by payload and result selection",
            "groundedBy": [
                "battle_action_mapping sharedRows",
                "battle_skill_records hit unit schema",
                "battle_result_family_dispatch_review result-family dispatch",
            ],
        },
        "actor5aDisplaySlotStatus": {
            "field": "actor +0x5a",
            "status": slot_status,
            "explicitActor5aWrites": raw_actor_audit.get("explicitActor5aWriteCount"),
            "nonzeroPackedActor58Writes": packed_audit.get("nonzeroByte5aCount"),
            "meaning": "type-2 enemy display local slot selector; only zero/slot0 is statically proven",
        },
        "resultPresentation": {
            "displaySummary": display_summary,
            "soundPresentation": sound_presentation,
        },
        "fieldSchema": {
            "prefixBytes": "3-byte action-level coefficients/candidates retained for formula context",
            "hitUnit": [
                "byte0: 공격/위력 계수",
                "byte1: 크리티컬/alternate 계수 후보",
                "byte2: 명중 계수",
                "byte3: reserved/unused, current units are 0x00",
                "byte4: target scope",
                "byte5: result family dispatch id",
                "byte6: hit/avoid class",
                "byte7: status effect id",
            ],
        },
        "rows": rows,
        "externalStatus": {
            "battleSkillRecordsStatus": skill_records.get("status", ""),
            "resultFamilySummary": result_family.get("summary", [])[:6],
            "resultDisplayStatus": result_display.get("status", ""),
            "soundRoleStatus": sound_role.get("status", ""),
            "monsterFrameProbeStatus": frame_probe.get("status", ""),
        },
    }


def interpretation(summary: dict[str, Any]) -> str:
    parts: list[str] = []
    if summary.get("hasDamageOrStatusFormula"):
        parts.append("공격/상태 판정")
    if summary.get("hasRecovery"):
        parts.append("회복")
    if summary.get("hasStatusEffect"):
        statuses = [label for label in summary.get("statusLabels", []) if label != "없음"]
        if statuses:
            parts.append("부가상태 " + "/".join(statuses))
    if summary.get("hasSelfSetup"):
        parts.append("자기/준비")
    if summary.get("hasAllTarget"):
        parts.append("전체 대상")
    return ", ".join(parts) if parts else "표준/기본 결과"


def esc(value: Any) -> str:
    return html.escape(str(value), quote=True)


def md_table(rows: list[list[Any]]) -> str:
    if not rows:
        return ""
    head = "| " + " | ".join(str(value) for value in rows[0]) + " |"
    sep = "| " + " | ".join("---" for _ in rows[0]) + " |"
    body = ["| " + " | ".join(str(value) for value in row) + " |" for row in rows[1:]]
    return "\n".join([head, sep, *body])


def render_md(data: dict[str, Any]) -> str:
    summary = data["summary"]
    lines = [
        "# 몬스터/공용 전투 액션 페이로드 정적 검토",
        "",
        f"- status: `{data['status']}`",
        f"- runtime used: `{data['runtimeUsed']}`",
        f"- shared action rows: {summary['sharedActionRows']}",
        f"- shared hit units: {summary['sharedHitUnits']}",
        "",
        "## 결론",
        "",
    ]
    lines.extend(f"- {item}" for item in data["staticConclusions"])
    lines.extend(["", "## Result-family 분포", ""])
    lines.append(md_table([
        ["family", "class", "count", "function", "role"],
        *[
            [
                row["valueHex"],
                row["classLabel"],
                row["count"],
                row.get("functionVaHex", ""),
                row.get("dispatchRole", ""),
            ]
            for row in summary["resultFamilyDistribution"]
        ],
    ]))
    lines.extend(["", "## Shared action rows", ""])
    lines.append(md_table([
        ["id", "name", "target", "family", "hit", "status", "coef", "interpretation"],
        *[
            [
                row["skillIdHex"],
                row["name"],
                ", ".join(row["summary"]["targetScopeLabels"]),
                ", ".join(hex_byte(value) for value in row["summary"]["resultFamilies"]),
                ", ".join(row["summary"]["hitClassLabels"]),
                ", ".join(row["summary"]["statusLabels"]),
                " / ".join("-".join(str(part) for part in triple) for triple in row["summary"]["coefficientTriples"]),
                row["staticInterpretation"],
            ]
            for row in data["rows"]
        ],
    ]))
    lines.append("")
    return "\n".join(lines)


def render_html(data: dict[str, Any]) -> str:
    summary = data["summary"]
    rows_html = "\n".join(render_action_row(row) for row in data["rows"])
    family_rows = "\n".join(
        "<tr>"
        f"<td><code>{esc(row['valueHex'])}</code></td>"
        f"<td>{esc(row['classLabel'])}</td>"
        f"<td>{esc(row['count'])}</td>"
        f"<td><code>{esc(row.get('functionVaHex', ''))}</code></td>"
        f"<td>{esc(row.get('dispatchRole', ''))}</td>"
        "</tr>"
        for row in summary["resultFamilyDistribution"]
    )
    conclusion_items = "\n".join(f"<li>{esc(item)}</li>" for item in data["staticConclusions"])
    display_items = "\n".join(
        f"<li>{esc(item)}</li>"
        for item in data["resultPresentation"].get("displaySummary", [])
    )
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <link rel="icon" href="../favicon.ico" />
  <title>몬스터/공용 전투 액션 정적 검토</title>
  <style>
    :root {{ color-scheme: light; --bg:#f6f7f9; --fg:#17202a; --muted:#607080; --line:#d8dee6; --head:#eef2f6; --link:#185abc; --good:#0f766e; --warn:#a15c00; }}
    * {{ box-sizing:border-box; }}
    body {{ margin:0; background:var(--bg); color:var(--fg); font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; line-height:1.45; }}
    main {{ max-width:1500px; margin:0 auto; padding:18px; }}
    header {{ display:flex; justify-content:space-between; gap:16px; align-items:flex-start; margin-bottom:14px; }}
    h1 {{ margin:0 0 6px; font-size:24px; letter-spacing:0; }}
    h2 {{ margin:0; font-size:17px; letter-spacing:0; }}
    a {{ color:var(--link); text-decoration:none; }}
    a:hover {{ text-decoration:underline; }}
    nav {{ display:flex; flex-wrap:wrap; gap:8px; justify-content:flex-end; }}
    nav a {{ display:inline-flex; align-items:center; min-height:30px; padding:4px 9px; border:1px solid var(--line); border-radius:5px; background:white; font-size:13px; }}
    .sub,.muted {{ color:var(--muted); }}
    section {{ margin:14px 0; background:white; border:1px solid var(--line); border-radius:8px; overflow:hidden; }}
    .section-head {{ display:flex; justify-content:space-between; gap:12px; padding:12px 14px; border-bottom:1px solid var(--line); background:var(--head); }}
    .body {{ padding:14px; }}
    .metrics {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:10px; }}
    .metric {{ border:1px solid var(--line); border-radius:6px; background:#f8fafc; padding:10px; }}
    .metric strong {{ display:block; font-size:22px; line-height:1.1; }}
    .metric span {{ display:block; color:var(--muted); font-size:12px; margin-top:4px; }}
    .cards {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:10px; }}
    .card {{ border:1px solid var(--line); border-radius:6px; background:#fff; padding:10px; }}
    table {{ width:100%; border-collapse:collapse; }}
    th,td {{ padding:8px 10px; border-bottom:1px solid var(--line); text-align:left; vertical-align:top; font-size:13px; }}
    th {{ background:#f8fafc; color:#344050; white-space:nowrap; }}
    code {{ font:12px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }}
    .table-wrap {{ overflow:auto; }}
    .tag {{ display:inline-flex; align-items:center; min-height:22px; padding:2px 7px; border-radius:999px; background:#eef6ff; border:1px solid #c9ddff; margin:1px 2px 1px 0; white-space:nowrap; }}
    .tag.warn {{ background:#fff7ed; border-color:#fed7aa; color:#9a3412; }}
    .coef {{ white-space:nowrap; }}
    @media (max-width:760px) {{ header,.section-head {{ display:block; }} nav {{ justify-content:flex-start; margin-top:10px; }} }}
  </style>
</head>
<body>
<main>
  <header>
    <div>
      <h1>몬스터/공용 전투 액션 정적 검토</h1>
      <p class="sub">런타임 캡처 없이 EXE 정적 테이블에서 shared action payload, result-family, 결과 표시 분기를 묶은 보고서.</p>
    </div>
    <nav>
      <a href="../web/index.html">홈</a>
      <a href="../web/battle_analysis.html">전투 분석</a>
      <a href="battle_monster_action_frame_probe.html">몬스터 슬롯 검토</a>
      <a href="battle_monster_shared_action_effect_review.json">JSON</a>
      <a href="battle_monster_shared_action_effect_review.md">MD</a>
    </nav>
  </header>

  <section>
    <div class="section-head"><h2>요약</h2><span class="muted">{esc(data['status'])}</span></div>
    <div class="body metrics">
      <div class="metric"><strong>{esc(summary['sharedActionRows'])}</strong><span>shared action rows</span></div>
      <div class="metric"><strong>{esc(summary['sharedHitUnits'])}</strong><span>shared hit units</span></div>
      <div class="metric"><strong>{esc(len(summary['resultFamilyDistribution']))}</strong><span>result families</span></div>
      <div class="metric"><strong>{esc(data['actor5aDisplaySlotStatus']['status'])}</strong><span>actor +0x5a status</span></div>
    </div>
  </section>

  <section>
    <div class="section-head"><h2>정적 결론</h2><span class="muted">runtimeUsed: {esc(data['runtimeUsed'])}</span></div>
    <div class="body cards">
      <article class="card"><h2>actor +0x59</h2><p>{esc(data['actor59PayloadPath']['meaning'])}</p></article>
      <article class="card"><h2>actor +0x5a</h2><p>{esc(data['actor5aDisplaySlotStatus']['meaning'])}</p></article>
      <article class="card"><h2>결과 표시</h2><ul>{display_items}</ul></article>
    </div>
    <div class="body"><ul>{conclusion_items}</ul></div>
  </section>

  <section>
    <div class="section-head"><h2>Result-family 분포</h2><span class="muted">hit unit byte[5]</span></div>
    <div class="body table-wrap">
      <table>
        <thead><tr><th>family</th><th>분류</th><th>count</th><th>함수</th><th>dispatch role</th></tr></thead>
        <tbody>{family_rows}</tbody>
      </table>
    </div>
  </section>

  <section>
    <div class="section-head"><h2>Shared action rows</h2><span class="muted">몬스터/공용 payload</span></div>
    <div class="body table-wrap">
      <table>
        <thead><tr><th>ID</th><th>기술</th><th>대상</th><th>결과 family</th><th>판정</th><th>상태</th><th>계수</th><th>payload</th><th>해석</th></tr></thead>
        <tbody>{rows_html}</tbody>
      </table>
    </div>
  </section>
  <script>
    window.HWANSE_BATTLE_MONSTER_SHARED_ACTION_EFFECT_REVIEW = {{
      rowCount: {summary['sharedActionRows']},
      unitCount: {summary['sharedHitUnits']},
      runtimeUsed: false
    }};
  </script>
</main>
</body>
</html>
"""


def tag_list(values: list[Any], warning: bool = False) -> str:
    cls = "tag warn" if warning else "tag"
    return "".join(f"<span class=\"{cls}\">{esc(value)}</span>" for value in values) or "<span class=\"muted\">-</span>"


def render_action_row(row: dict[str, Any]) -> str:
    summary = row["summary"]
    family_tags = [
        f"{hex_byte(value)} {FAMILY_CLASS_LABELS.get(family_class(value), family_class(value))}"
        for value in summary["resultFamilies"]
    ]
    coef = " / ".join(
        f"<span class=\"coef\">{esc('-'.join(str(part) for part in triple))}</span>"
        for triple in summary["coefficientTriples"]
    )
    payload = (
        f"<code>{esc(row['payloadVaHex'])}</code><br>"
        f"<span class=\"muted\">prefix {esc(row.get('prefixBytesHex', ''))}</span>"
    )
    status_warning = any(value != 0 for value in summary["statuses"])
    return (
        "<tr>"
        f"<td><code>{esc(row['skillIdHex'])}</code></td>"
        f"<td><strong>{esc(row['name'])}</strong></td>"
        f"<td>{tag_list(summary['targetScopeLabels'])}</td>"
        f"<td>{tag_list(family_tags)}</td>"
        f"<td>{tag_list(summary['hitClassLabels'])}</td>"
        f"<td>{tag_list(summary['statusLabels'], warning=status_warning)}</td>"
        f"<td>{coef}</td>"
        f"<td>{payload}</td>"
        f"<td>{esc(row['staticInterpretation'])}</td>"
        "</tr>"
    )


def main() -> None:
    OUT.mkdir(parents=True, exist_ok=True)
    data = build()
    (OUT / "battle_monster_shared_action_effect_review.json").write_text(
        json.dumps(data, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    (OUT / "battle_monster_shared_action_effect_review.md").write_text(
        render_md(data),
        encoding="utf-8",
    )
    (OUT / "battle_monster_shared_action_effect_review.html").write_text(
        render_html(data),
        encoding="utf-8",
    )
    print("wrote battle_monster_shared_action_effect_review")


if __name__ == "__main__":
    main()
