- 프로젝트 AGENTS 지침에 커밋 후 푸시 확인 규칙과 런타임 콘텐츠 에이전트 규칙을 보강 - 보스 설계 플레이북과 멀티플레이 보스 설계 철학 문서를 추가해 사람용 설계 기준을 정리 - Codex-Tools 디렉터리에 Markdown→YAML→검증 파이프라인 스크립트와 프롬프트, 스키마, 설정 파일을 추가
372 lines
14 KiB
Python
Executable File
372 lines
14 KiB
Python
Executable File
#!/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()
|