#!/usr/bin/env python3 """Generate boss YAML from a Markdown design document. Canonical assumptions: - Markdown is the source of truth. - YAML is derived runtime data. - Missing data should remain empty rather than invented. This parser is intentionally conservative and template-friendly. """ from __future__ import annotations import argparse import os import re from dataclasses import dataclass from typing import Dict, List, Optional import yaml HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*$", re.MULTILINE) KV_LINE_RE = re.compile(r"^([A-Za-z0-9_가-힣 /()\-]+):\s*(.*?)\s*$") SIGNATURE_SECTION_ALIASES = ["signature_pattern", "시그니처_패턴"] CORE_CONCEPT_ALIASES = ["core_concept", "concept", "컨셉", "핵심_개념", "핵심_컨셉"] PLAYER_PROBLEM_ALIASES = ["player_problem", "문제", "플레이어_문제", "플레이어_과제"] FAILURE_RESULT_ALIASES = ["failure_result", "실패_결과"] EXPECTED_SOLUTION_ALIASES = ["expected_solution", "해결_방법", "정답", "기대_해법", "예상_해법"] ROLE_ALIASES = { "tank": ["tank", "탱커"], "support": ["support", "지원", "서포트"], "dps": ["dps", "딜러"], } SUSTAINED_PRESSURE_SECTION_ALIASES = ["sustained_pressure_design", "지속_압박_설계"] BURST_PRESSURE_SECTION_ALIASES = ["burst_pressure_design", "순간_압박_설계", "고점_압박_설계"] TIME_ESCALATION_SECTION_ALIASES = ["time_escalation", "시간_압박", "시간_경과_강화"] REVIVE_SECTION_ALIASES = ["revive_interaction_design", "부활_상호작용", "부활_상호작용_설계"] DIFFICULTY_SECTION_ALIASES = ["difficulty_intent", "난이도_의도"] EXPECTED_ATTEMPTS_LABELS = ["Expected Attempts", "예상 시도 횟수"] PRIMARY_FAILURE_CAUSE_LABELS = ["Primary Failure Cause", "주 실패 원인"] SECONDARY_FAILURE_CAUSE_LABELS = ["Secondary Failure Cause", "보조 실패 원인"] @dataclass class Section: level: int title: str content: str def slugify(text: str) -> str: text = text.strip().lower() text = re.sub(r"\s+", "_", text) text = re.sub(r"[^a-z0-9_가-힣]", "", text) return text or "boss" def normalize_title(title: str) -> str: t = title.strip().lower().replace("—", "-") t = re.sub(r"[^a-z0-9가-힣]+", "_", t) return t.strip("_") def maybe_int(text: Optional[str]) -> Optional[int]: if not text: return None m = re.search(r"(\d+)", text) return int(m.group(1)) if m else None def parse_sections(markdown: str) -> List[Section]: matches = list(HEADING_RE.finditer(markdown)) if not matches: return [Section(level=1, title="document", content=markdown.strip())] sections: List[Section] = [] for i, match in enumerate(matches): start = match.end() end = matches[i + 1].start() if i + 1 < len(matches) else len(markdown) sections.append( Section( level=len(match.group(1)), title=match.group(2).strip(), content=markdown[start:end].strip(), ) ) return sections def section_map(sections: List[Section]) -> Dict[str, Section]: mapping: Dict[str, Section] = {} for section in sections: key = normalize_title(section.title) if key not in mapping: mapping[key] = section return mapping def find_line_value(markdown: str, labels: List[str]) -> Optional[str]: normalized = {normalize_title(label) for label in labels} for raw in markdown.splitlines(): line = raw.strip().lstrip("- ") if not line or ":" not in line: continue left, right = line.split(":", 1) if normalize_title(left) in normalized: value = right.strip() return value or None return None def first_section(smap: Dict[str, Section], aliases: List[str]) -> Optional[Section]: return next((smap[alias] for alias in aliases if alias in smap), None) def first_content_value(section: Optional[Section]) -> Optional[str]: if not section: return None for raw in section.content.splitlines(): line = raw.strip().lstrip("-* ").strip() if line: return line return None def collect_child_sections(sections: List[Section], parent_aliases: List[str]) -> List[Section]: normalized_aliases = set(parent_aliases) for index, section in enumerate(sections): if normalize_title(section.title) not in normalized_aliases: continue children: List[Section] = [] parent_level = section.level for child in sections[index + 1 :]: if child.level <= parent_level: break children.append(child) return children return [] def parse_inline_list(section: Optional[Section]) -> List[str]: if not section: return [] items: List[str] = [] for raw in section.content.splitlines(): line = raw.strip() if not line: continue if line.startswith(("-", "*")): items.append(slugify(line.strip("-* "))[:64]) return items def extract_role_pressure(smap: Dict[str, Section]) -> Dict[str, dict]: config = { "tank": ["tank_pressure", "탱커_압박", "tank"], "support": ["support_pressure", "지원_압박", "support"], "dps": ["dps_pressure", "딜러_압박", "dps"], } output: Dict[str, dict] = {} for role_name, aliases in config.items(): section = next((smap[a] for a in aliases if a in smap), None) output[role_name] = { "required_pressure": None, "pressure_sources": parse_inline_list(section), "failure_results": [], "notes": section.content.strip() if section else None, } return output def extract_signature(markdown: str, sections: List[Section], smap: Dict[str, Section]) -> dict: signature_section = first_section(smap, SIGNATURE_SECTION_ALIASES) signature_name = find_line_value(markdown, ["Signature Name", "Signature Pattern", "시그니처 패턴", "시그니처 이름"]) core_concept = None player_problem = None failure_description = None expected_solution: Dict[str, List[str]] = {} if signature_section: sub_sections = collect_child_sections(sections, SIGNATURE_SECTION_ALIASES) sub_map = section_map(sub_sections) signature_name = signature_name or first_content_value( first_section(sub_map, ["signature_name", "시그니처_이름"]) ) core_concept = first_section(sub_map, CORE_CONCEPT_ALIASES) player_problem = first_section(sub_map, PLAYER_PROBLEM_ALIASES) failure_result = first_section(sub_map, FAILURE_RESULT_ALIASES) expected = first_section(sub_map, EXPECTED_SOLUTION_ALIASES) core_concept = core_concept.content.strip() if core_concept else None player_problem = player_problem.content.strip() if player_problem else None failure_description = failure_result.content.strip() if failure_result else None if expected: current_role: Optional[str] = None role_items: Dict[str, List[str]] = {"tank": [], "support": [], "dps": []} for raw in expected.content.splitlines(): line = raw.strip() if not line: continue role_key = normalize_title(line.rstrip(":")) for canonical_role, aliases in ROLE_ALIASES.items(): if role_key in {normalize_title(alias) for alias in aliases}: current_role = canonical_role break else: current_role = current_role if current_role and role_key in { normalize_title(alias) for aliases in ROLE_ALIASES.values() for alias in aliases }: continue if current_role and line.startswith(("-", "*")): role_items[current_role].append(line.strip("-* ")) elif current_role: role_items[current_role].append(line) expected_solution = {k: v for k, v in role_items.items() if v} return { "name": signature_name, "concept": core_concept, "player_problem": player_problem, "expected_solution": expected_solution, "failure_result": { "type": None, "description": failure_description, }, } def extract_phases(sections: List[Section]) -> List[dict]: phases: List[dict] = [] for section in sections: normalized = normalize_title(section.title) if normalized.startswith("phase") or normalized.startswith("페이즈"): phase_id = maybe_int(section.title) or len(phases) + 1 mechanics = parse_inline_list(Section(section.level, section.title, section.content)) phases.append( { "phase_id": phase_id, "name": section.title, "duration_estimate_sec": None, "new_mechanics": mechanics, "role_pressure_multiplier": {"tank": None, "support": None, "dps": None}, "notes": section.content.strip() or None, } ) return phases def extract_escalation(markdown: str, smap: Dict[str, Section]) -> dict: section = first_section(smap, TIME_ESCALATION_SECTION_ALIASES) interval = maybe_int(find_line_value(markdown, ["Escalation Interval Sec", "Escalation Interval", "강화 간격", "시간 압박 간격"])) types = parse_inline_list(section) return { "enabled": bool(section), "escalation_interval_sec": interval, "escalation_types": types, "notes": section.content.strip() if section else None, } def extract_revive(markdown: str, smap: Dict[str, Section]) -> dict: section = first_section(smap, REVIVE_SECTION_ALIASES) level = find_line_value(markdown, ["Revive Risk Level", "부활 위험도"]) return { "trigger_response": { "enabled": bool(section), "reactions": parse_inline_list(section), }, "revive_risk_level": level, "notes": section.content.strip() if section else None, } def generate_boss_yaml(markdown: str, md_path: str, boss_id_override: Optional[str] = None) -> dict: sections = parse_sections(markdown) smap = section_map(sections) boss_name = find_line_value(markdown, ["Boss Name", "보스 이름"]) or os.path.splitext(os.path.basename(md_path))[0] boss_id = boss_id_override or find_line_value(markdown, ["Boss ID", "boss_id", "보스 ID", "보스 아이디"]) or slugify(boss_name) stage = maybe_int(find_line_value(markdown, ["Stage", "스테이지"])) duration_sec = maybe_int(find_line_value(markdown, ["Estimated Duration Sec", "Estimated Duration", "전투 시간"])) entity_type = find_line_value(markdown, ["Entity Type", "개체 타입"]) sustained_section = first_section(smap, SUSTAINED_PRESSURE_SECTION_ALIASES) burst_section = first_section(smap, BURST_PRESSURE_SECTION_ALIASES) difficulty_section = first_section(smap, DIFFICULTY_SECTION_ALIASES) data = { "boss": { "metadata": { "boss_id": boss_id, "boss_name": boss_name, "stage": stage, "expected_duration_sec": duration_sec, "entity_type": entity_type, "source_document": md_path, }, "signature": extract_signature(markdown, sections, smap), "roles": extract_role_pressure(smap), "phases": extract_phases(sections), "pressure": { "sustained": { "sources": parse_inline_list(sustained_section), "notes": sustained_section.content.strip() if sustained_section else None, }, "burst": { "mechanics": parse_inline_list(burst_section), "notes": burst_section.content.strip() if burst_section else None, }, "dodge_types_required": [], }, "revive": extract_revive(markdown, smap), "escalation": extract_escalation(markdown, smap), "entities": { "total_entities": None, "composition": {}, "add_behavior": {"role": None, "respawn": None}, }, "difficulty": { "expected_attempts": maybe_int(find_line_value(markdown, EXPECTED_ATTEMPTS_LABELS)), "primary_failure_causes": [ value for value in [find_line_value(markdown, PRIMARY_FAILURE_CAUSE_LABELS)] if value ], "secondary_failure_causes": [ value for value in [find_line_value(markdown, SECONDARY_FAILURE_CAUSE_LABELS)] if value ], "notes": difficulty_section.content.strip() if difficulty_section else None, }, "source_notes": { "unparsed_sections": [ {"title": section.title, "content": section.content[:500]} for section in sections if section.level <= 3 ] }, } } return data def main() -> None: parser = argparse.ArgumentParser(description="Generate boss YAML from Markdown") parser.add_argument("--md", required=True, help="Path to boss markdown design document") parser.add_argument("--output", required=True, help="Path to output YAML") parser.add_argument("--boss-id", default=None, help="Optional boss_id override") args = parser.parse_args() with open(args.md, "r", encoding="utf-8") as file: markdown = file.read() data = generate_boss_yaml(markdown, args.md, boss_id_override=args.boss_id) os.makedirs(os.path.dirname(args.output), exist_ok=True) with open(args.output, "w", encoding="utf-8") as file: yaml.safe_dump(data, file, allow_unicode=True, sort_keys=False, width=100) print(args.output) if __name__ == "__main__": main()