#!/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()