- 프로젝트 AGENTS 지침에 커밋 후 푸시 확인 규칙과 런타임 콘텐츠 에이전트 규칙을 보강 - 보스 설계 플레이북과 멀티플레이 보스 설계 철학 문서를 추가해 사람용 설계 기준을 정리 - Codex-Tools 디렉터리에 Markdown→YAML→검증 파이프라인 스크립트와 프롬프트, 스키마, 설정 파일을 추가
185 lines
7.2 KiB
Python
Executable File
185 lines
7.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Validate boss YAML using the project's multiplayer boss rules."""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
from typing import Any, Dict, List, Tuple
|
|
|
|
import yaml
|
|
|
|
|
|
def load_yaml(path: str) -> Dict[str, Any]:
|
|
with open(path, "r", encoding="utf-8") as file:
|
|
return yaml.safe_load(file) or {}
|
|
|
|
|
|
def score_to_label(issues: List[Tuple[str, str]]) -> str:
|
|
severities = [severity for severity, _ in issues]
|
|
if "ERROR" in severities:
|
|
return "FAIL"
|
|
if "WARNING" in severities:
|
|
return "WARNING"
|
|
return "PASS"
|
|
|
|
|
|
def validate(data: Dict[str, Any]) -> Tuple[Dict[str, int], List[Tuple[str, str]], List[str]]:
|
|
boss = data.get("boss", {})
|
|
issues: List[Tuple[str, str]] = []
|
|
fixes: List[str] = []
|
|
|
|
scores = {
|
|
"Role Integrity Score": 100,
|
|
"Pattern Diversity Score": 100,
|
|
"Execution Fairness Score": 100,
|
|
"Revive Risk Score": 100,
|
|
"Time Pressure Score": 100,
|
|
}
|
|
|
|
roles = boss.get("roles", {})
|
|
for role_name in ["tank", "support", "dps"]:
|
|
role = roles.get(role_name)
|
|
if not role:
|
|
issues.append(("ERROR", f"Missing role definition: {role_name}"))
|
|
scores["Role Integrity Score"] -= 25
|
|
fixes.append(f"Add `{role_name}` role pressure definition.")
|
|
continue
|
|
if not role.get("notes") and not role.get("pressure_sources"):
|
|
issues.append(("WARNING", f"No pressure details defined for {role_name}"))
|
|
scores["Role Integrity Score"] -= 12
|
|
fixes.append(f"Describe how the boss pressures {role_name} players.")
|
|
|
|
signature = boss.get("signature", {})
|
|
if not signature.get("name"):
|
|
issues.append(("ERROR", "Missing signature mechanic name"))
|
|
scores["Pattern Diversity Score"] -= 30
|
|
fixes.append("Add a named signature mechanic.")
|
|
if not signature.get("player_problem"):
|
|
issues.append(("WARNING", "Signature mechanic has no clear player problem"))
|
|
scores["Pattern Diversity Score"] -= 15
|
|
fixes.append("Describe the player problem the signature mechanic creates.")
|
|
if not signature.get("expected_solution"):
|
|
issues.append(("WARNING", "Signature mechanic has no clear expected solution"))
|
|
scores["Pattern Diversity Score"] -= 15
|
|
fixes.append("Describe the expected solution for the signature mechanic.")
|
|
if not (signature.get("failure_result") or {}).get("description"):
|
|
issues.append(("WARNING", "Signature mechanic has no failure result"))
|
|
scores["Execution Fairness Score"] -= 12
|
|
fixes.append("Define what happens when the signature mechanic is failed.")
|
|
|
|
phases = boss.get("phases") or []
|
|
if len(phases) < 3:
|
|
issues.append(("ERROR", f"Insufficient phases: {len(phases)} found, expected at least 3"))
|
|
scores["Pattern Diversity Score"] -= 30
|
|
fixes.append("Add at least 3 phases, ideally 4 for a ~10 minute fight.")
|
|
elif len(phases) < 4:
|
|
issues.append(("WARNING", "Only 3 phases defined; 4 is recommended for ~10 minute encounters"))
|
|
scores["Pattern Diversity Score"] -= 8
|
|
|
|
for phase in phases:
|
|
if not phase.get("new_mechanics") and not phase.get("notes"):
|
|
issues.append(("WARNING", f"Phase {phase.get('phase_id')} has no new mechanics listed"))
|
|
scores["Pattern Diversity Score"] -= 5
|
|
fixes.append(f"List at least one new mechanic for phase {phase.get('phase_id')}.")
|
|
|
|
pressure = boss.get("pressure", {})
|
|
dodge_types = pressure.get("dodge_types_required") or []
|
|
if dodge_types and len(dodge_types) < 3:
|
|
issues.append(("WARNING", "Fewer than 3 dodge response types defined"))
|
|
scores["Execution Fairness Score"] -= 10
|
|
fixes.append("List at least 3 dodge response types, such as timing, directional, and chained.")
|
|
|
|
revive = boss.get("revive", {})
|
|
revive_enabled = (revive.get("trigger_response") or {}).get("enabled")
|
|
if revive_enabled:
|
|
if not (revive.get("trigger_response") or {}).get("reactions"):
|
|
issues.append(("WARNING", "Revive response enabled but no reactions defined"))
|
|
scores["Revive Risk Score"] -= 20
|
|
fixes.append("Define at least one revive-triggered boss reaction.")
|
|
else:
|
|
issues.append(("WARNING", "No revive-triggered boss response defined"))
|
|
scores["Revive Risk Score"] -= 25
|
|
fixes.append("Add a risky revive interaction if revive exists in combat.")
|
|
|
|
escalation = boss.get("escalation", {})
|
|
if not escalation.get("enabled"):
|
|
issues.append(("ERROR", "No time escalation defined"))
|
|
scores["Time Pressure Score"] -= 35
|
|
fixes.append("Add soft-enrage or time-based escalation to prevent infinite sustain.")
|
|
else:
|
|
if not escalation.get("escalation_types"):
|
|
issues.append(("WARNING", "Escalation enabled but escalation types are empty"))
|
|
scores["Time Pressure Score"] -= 10
|
|
fixes.append("Specify how the boss escalates over time.")
|
|
if not escalation.get("escalation_interval_sec"):
|
|
issues.append(("WARNING", "Escalation interval is not defined"))
|
|
scores["Time Pressure Score"] -= 8
|
|
fixes.append("Specify an escalation interval in seconds.")
|
|
|
|
for score_name in list(scores.keys()):
|
|
scores[score_name] = max(0, scores[score_name])
|
|
|
|
return scores, issues, fixes
|
|
|
|
|
|
def render_report(yaml_path: str, data: Dict[str, Any], scores: Dict[str, int], issues: List[Tuple[str, str]], fixes: List[str]) -> str:
|
|
boss = data.get("boss", {})
|
|
metadata = boss.get("metadata", {})
|
|
result = score_to_label(issues)
|
|
lines = [
|
|
"# Boss Validation Report",
|
|
"",
|
|
f"Boss ID: {metadata.get('boss_id')}",
|
|
f"Boss Name: {metadata.get('boss_name')}",
|
|
f"Source YAML: `{yaml_path}`",
|
|
"",
|
|
f"Result: **{result}**",
|
|
"",
|
|
]
|
|
for score_name, value in scores.items():
|
|
lines.append(f"- {score_name}: {value}")
|
|
lines.extend(["", "## Detected Issues"])
|
|
if issues:
|
|
for severity, message in issues:
|
|
lines.append(f"- **{severity}**: {message}")
|
|
else:
|
|
lines.append("- None")
|
|
lines.extend(["", "## Suggested Fixes"])
|
|
if fixes:
|
|
seen = set()
|
|
for item in fixes:
|
|
if item not in seen:
|
|
lines.append(f"- {item}")
|
|
seen.add(item)
|
|
else:
|
|
lines.append("- No changes required.")
|
|
lines.extend([
|
|
"",
|
|
"## Notes",
|
|
"- Markdown source remains the source of truth.",
|
|
"- Regenerate YAML after editing the Markdown design document.",
|
|
"",
|
|
])
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Validate boss YAML")
|
|
parser.add_argument("--yaml", required=True, help="Path to boss YAML")
|
|
parser.add_argument("--output", required=True, help="Path to output Markdown report")
|
|
args = parser.parse_args()
|
|
|
|
data = load_yaml(args.yaml)
|
|
scores, issues, fixes = validate(data)
|
|
report = render_report(args.yaml, data, scores, issues, fixes)
|
|
|
|
os.makedirs(os.path.dirname(args.output), exist_ok=True)
|
|
with open(args.output, "w", encoding="utf-8") as file:
|
|
file.write(report)
|
|
|
|
print(args.output)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|