diff --git a/AGENTS.md b/AGENTS.md index eb43ae5b..bb21eff0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ Multiplayer arena game built with **Unity 6000.3.10f1** and **Unity Netcode for Design docs are maintained in Obsidian Vault: `/mnt/smb/Obsidian Vault/Colosseum` - Always use the shared Obsidian Vault at `/mnt/smb/Obsidian Vault/Colosseum` for project documentation updates. -- **Obsidian Vault 파일은 반드시 파일 도구(read/write/edit)로만 접근한다.** cmd, powershell, bash 등은 UNC 경로를 정상적으로 처리하지 못하므로 사용하지 않는다. +- 현재 환경에서 접근 가능한 셸(`bash`, `zsh` 등) 기준으로 Obsidian Vault 파일을 열고 수정해도 된다. - Unless the user explicitly names another file, `체크리스트` means `/mnt/smb/Obsidian Vault/Colosseum/개발/프로토타입 체크리스트.md`. - After completing work, always check whether the prototype checklist should be updated and reflect the new status when needed. - If the work also changes a domain-specific checklist such as boss/player/combat, sync that checklist too. @@ -337,6 +337,7 @@ public class NetworkedComponent : NetworkBehaviour - After Unity-related edits, check the Unity console for errors before proceeding. - For networked play tests, prefer a temporary non-conflicting test port when needed and restore the default port after validation. - The user has a strong project preference that play mode must be stopped before edits because network ports can remain occupied otherwise. +- When the user requests a commit, create the commit and show the message, then **ask before pushing**. Only push after explicit confirmation. - Commit messages should follow the recent project history style: use a type prefix such as `feat:`, `fix:`, or `chore:` and write the subject in Korean so the gameplay/UI/system change is clear from the log alone. - When the change is substantial, include a blank line after the subject and add Korean bullet points that summarize the work by feature/purpose, similar to commit `0889bb0` (`feat: 드로그 집행 개시 패턴 및 낙인 디버프 추가`). - Prefer the format `type: 한글 요약` for the subject, then `- 변경 사항` bullet lines for the body. Use `feat:` for feature work, `fix:` for bug fixes, and `chore:` for non-feature maintenance such as scene/prefab cleanup, asset reorganization, or other miscellaneous upkeep. The body should mention key implementation scope, affected systems/assets, and validation or rollback notes when relevant. @@ -348,3 +349,19 @@ public class NetworkedComponent : NetworkBehaviour - 프로젝트 용어로는 `필살기`보다 `고위력 기술`을 우선 사용한다. - 보호막은 단일 값이 아니라 `타입별 독립 인스턴스`로 취급한다. 같은 타입은 자기 자신만 갱신되고, 서로 다른 타입은 공존하며, 흡수 순서는 적용 순서를 따른다. - 보스 시그니처 전조는 가능한 한 정확한 진행 수치 UI보다 명확한 모션/VFX로 읽히게 한다. 차단 진행도나 정확한 누적 수치 노출은 명시 요청이 없으면 피한다. + +# Runtime Content Agent Rules + +Purpose: +Keep boss YAML compatible with game runtime expectations. + +Always: +- validate boss YAML shape before commit +- preserve `boss_id` stability +- keep runtime field names machine-readable and snake_case +- prefer additive changes over destructive changes + +Never: +- change mechanic intent to satisfy schema formatting +- silently rename IDs +- auto-fill required combat values without a design source diff --git a/Boss_Design_Playbook.md b/Boss_Design_Playbook.md new file mode 100644 index 00000000..cf26504b --- /dev/null +++ b/Boss_Design_Playbook.md @@ -0,0 +1,309 @@ +# Boss & Encounter Design Playbook + +이 문서는 **4인 파티 협동 / 자유 역할 비율 / 논타겟 액션 / 보스 러쉬 / 점진 성장형 로그라이트** 게임을 위한 사람용 설계 문서다. + +핵심 철학: +- 보스는 체력 덩어리가 아니라 **역할 수행과 패턴 해결을 시험하는 협동 퍼즐**이다. +- 전투 난이도는 운 좋은 고성능 빌드보다 **역할 분배, 포지셔닝, 기믹 처리, 개인 수행**을 우선 시험해야 한다. +- 시그니처 패턴은 형태보다 **문제와 해법이 명확해야** 한다. +- 긴 전투(약 10분)는 **3~4 페이즈 이상**과 **주기적 새 메커닉 도입**이 필요하다. + +--- + +## 1. 게임 전제 + +### 파티 구조 +- 4인 파티 고정 상정 +- 자유 빌드형 역할 분배 +- 큰 축: 탱커 / 딜러 / 지원 +- 각 플레이어는 역할 비율로 기여 가능 + - 예: Tank 0.8 / DPS 0.2 / Support 0.0 + +### 역할 의미 +- **탱커**: 최고 위협 유지, 보스 방향/위치 안정화, 이동 후 재정렬 +- **딜러**: 지속 DPS, 순간 DPS 체크, 기믹 처리 화력 +- **지원**: 힐, 보호막, 버프, 자원 회복, 상태이상 해제, 부활 + +### 전투 구조 +- 자유 이동, 3인칭, 논타겟 액션 +- 공용 회피(구르기) + 약간의 무적 시간 +- 블록은 완전 방어보다 피해 감소 성격 +- 보스 타겟 선정은 기본적으로 Aggro/Threat 기반, 일부 패턴은 랜덤/거리 기반 +- 작은 패턴의 지속 압박 + 큰 패턴의 시험 구조 +- 전투 중 부활 존재, 부활은 긴 캐스팅을 요구 +- 명시적 시간 제한은 지양하지만, 무한 전투 방지를 위해 보스는 점진 강화 가능 + +### 런 구조 +- 기본 세팅 상점 +- [보스 전투 → 랜덤/고정 보상 획득 → 상점/성장] * n +- 보스 수 3~4 +- 보스 1회 전투 약 10분 목표 +- 스테이지는 순차 증가 +- 스테이지 1개 = 기본적으로 보스 전투 1회 +- 한 번의 보스 전투는 단일 개체가 아닐 수 있음 (adds, 다중 보스, 군집형 가능) + +### 성장 구조 +- 고정 보상: 재화, 패시브 스킬 포인트 +- 랜덤 보상: 장비 드랍, 상점 품목, 선택형 강화 등 +- 빌드 변화 강도: Medium +- 역할 전환은 가능하나, 시작 역할에서 완전히 벗어나기는 어려움 +- 파워 그래프는 점진적 증가 +- 보스는 운빨이 아니라 **역할 분배와 수행 능력**을 검증해야 함 + +--- + +## 2. 설계 철학 요약 + +### 2.1 보스 = Encounter +이 게임에서 보스는 단일 개체가 아니라 **전투 상황 전체**다. + +### 2.2 Role Pressure +좋은 보스는 역할을 이름으로 요구하지 않고, **각 역할이 해결해야 하는 문제를 지속적으로 만든다.** + +- Tank Pressure: 고정 공격, 위협 유지, 위치 안정화 +- Support Pressure: 광역 피해 복구, 상태이상 해제, 지속 유지력 소모 대응 +- DPS Pressure: 지속 화력, 순간 화력, 기믹 대상 파괴 + +### 2.3 Single-Core Solve Pattern +시그니처 패턴은 여러 종류를 뒤섞기보다, +- 문제 1개 +- 해법 1개 +- 실패 이유 1개 +처럼 명확해야 한다. + +### 2.4 Sustain + Burst +좋은 보스는 둘 다 필요하다. +- **Sustain Pressure**: 쉬지 않는 작은 압박 +- **Burst Check**: 큰 패턴, 순간 DPS, 즉사급 실패 위험 + +### 2.5 Revive is Risk +부활은 무료 복구가 아니라 위험 행동이어야 한다. + +### 2.6 Soft Enrage +명시적 시간 제한 대신, +- 공격 속도 증가 +- 피해 증가 +- 패턴 밀도 증가 +같은 점진 강화로 무한 지속을 방지한다. + +--- + +## 3. Signature-first 설계 순서 + +보스 설계는 아래 순서로 진행한다. + +1. **시그니처 패턴** +2. 역할 압박 정의 +3. 페이즈 구조 +4. 지속 압박과 순간 시험 +5. 부활 상호작용 +6. 시간 경과 강화 +7. 개체 구성 (단일 / adds / 다중) +8. 난이도 목표 + +--- + +## 4. 설계 템플릿 + +```md +# BOSS DESIGN DOCUMENT + +Boss Name: +Boss ID: + +Stage: +Estimated Duration: +Entity Type: +- Single +- Boss + Adds +- Multi Boss +- Swarm + +--- + +# 1. Signature Pattern + +## Signature Name: + +## Core Concept +이 패턴이 만드는 전투 상황. + +## Player Problem +플레이어가 해결해야 하는 문제. + +## Expected Solution +정답. 역할별 책임을 가능하면 명시. + +## Failure Result +틀렸을 때 발생하는 결과. + +## Why This Pattern Matters +이 패턴이 왜 이 보스의 정체성인지. + +--- + +# 2. Role Pressure Design + +## Tank Pressure +지속적으로 요구되는 행동: + +Tank Failure Result: + +## Support Pressure +지속적으로 요구되는 행동: + +Support Failure Result: + +## DPS Pressure +지속적으로 요구되는 행동: + +DPS Failure Result: + +--- + +# 3. Phase Structure + +## Phase 1 — Learn +목표: +새 요소: + +## Phase 2 — Pressure +목표: +새 요소: + +## Phase 3 — Twist +목표: +새 요소: + +## Phase 4 — Final +목표: +새 요소: + +--- + +# 4. Sustained Pressure Design + +## Passive Damage Sources + +## Movement Pressure + +--- + +# 5. Burst Pressure Design + +## Major Mechanics + +## DPS Check +Yes / No + +--- + +# 6. Revive Interaction Design + +Revive Trigger Reaction: + +Revive Risk Level: +Low / Medium / High + +--- + +# 7. Time Escalation (Soft Enrage) + +Escalation Type: + +Escalation Interval: + +--- + +# 8. Multi-Entity Behavior + +Entity Count: + +Add Role: + +--- + +# 9. Expected Party Role Demand + +Required Tank Pressure: + +Required DPS Pressure: + +Required Support Pressure: + +--- + +# 10. Difficulty Intent + +Expected Attempts: + +Primary Failure Cause: + +Secondary Failure Cause: +``` + +--- + +## 5. 역할 압박을 만드는 방법 + +### 탱커 압박 +- 최고 위협 대상에게 지속 고정 공격 +- 보스 방향 유지 실패 시 파티 피해 증가 +- 이동 패턴 후 재고정 요구 +- 위협 유지 실패 시 후열 붕괴 + +### 지원 압박 +- 탱커 유지력 소모 +- 광역 피해 누적 +- 상태이상 해제 필요 +- 부활 시전 자체가 위험 유발 + +### 딜러 압박 +- 탱커 외 대상에게도 위협이 가해짐 +- 순간 DPS 요구 기믹 +- 적절한 시간 내 처치 요구 +- 지속 화력 부족 시 전투 피로도 급증 + +--- + +## 6. 긴 보스전 체크리스트 + +약 10분 전투라면 최소한 아래를 점검한다. + +- 3개 이상 페이즈가 있는가 +- 90~120초마다 새 메커닉이 들어오는가 +- 시그니처 패턴이 후반부에 변형되는가 +- 지속 압박과 순간 시험이 모두 있는가 +- 탱커가 계속 필요한가 +- 지원이 계속 필요한가 +- 딜러가 지속/순간 DPS를 둘 다 요구받는가 +- 부활이 안전하지 않은가 +- 시간 경과 강화가 있는가 +- adds나 다중 개체가 있다면 명확한 역할이 있는가 + +--- + +## 7. 나쁜 보스의 전형적 징후 + +- 모든 패턴을 같은 타이밍 Dodge로 해결 가능 +- 탱커가 잠깐만 필요하고 대부분 무시 가능 +- 지원이 없어도 큰 차이가 없음 +- 딜러는 평딜만 하면 되고 순간 화력 체크가 없음 +- 부활이 사실상 무료 +- 시간만 끌면 결국 깨짐 +- adds가 있지만 의미가 없음 +- 시그니처 패턴이 모호해서 실패 이유가 불명확 + +--- + +## 8. 추천 실무 루프 + +1. 시그니처 패턴 1개를 먼저 적는다 +2. 그 패턴이 어떤 역할을 필요하게 만드는지 적는다 +3. 그 패턴이 어느 페이즈에서 어떻게 변형되는지 적는다 +4. 작은 패턴으로 유지력 압박을 설계한다 +5. 부활/시간경과 리스크를 넣는다 +6. YAML로 변환한다 +7. Codex로 검증한다 +8. 보고서를 보고 문서를 다시 고친다 + diff --git a/Codex-Tools/AGENTS.md b/Codex-Tools/AGENTS.md new file mode 100644 index 00000000..afa3a109 --- /dev/null +++ b/Codex-Tools/AGENTS.md @@ -0,0 +1,18 @@ +# Codex Automation Agent Rules + +Purpose: +Run the design-to-data pipeline for boss encounters. + +Pipeline: +1. Read Markdown boss design doc from the design vault. +2. Convert it to boss YAML. +3. Validate the YAML using boss design rules. +4. Write a validation report next to the design doc. +5. Write runtime YAML into the game project. + +Rules: +- Markdown is the source of truth. +- YAML is derived data. +- Validation should never modify the source Markdown. +- Do not invent mechanics. +- If a field is missing, record that as uncertainty or an issue. diff --git a/Codex-Tools/README.md b/Codex-Tools/README.md new file mode 100644 index 00000000..2071a0fc --- /dev/null +++ b/Codex-Tools/README.md @@ -0,0 +1,22 @@ +# Codex Boss Pipeline Tools + +This directory contains helper scripts for a Markdown -> YAML -> Validation workflow. + +## Files +- `skills/generate_boss_yaml.py`: generate runtime YAML from a Markdown boss design document. +- `skills/validate_boss_yaml.py`: validate a generated boss YAML and emit a Markdown report. +- `skills/run_boss_pipeline.py`: run both steps together. +- `config/paths.example.yaml`: copy and edit into `paths.yaml` for your environment. + +## Recommended flow +1. Write `/mnt/smb/Obsidian Vault/Colosseum/보스// 기획.md` +2. Run `run_boss_pipeline.py` +3. Review `_validation.md` +4. Edit the Markdown source, then rerun. + +## Example +```bash +python skills/run_boss_pipeline.py \ + --md "/mnt/smb/Obsidian Vault/Colosseum/보스/드로그/드로그 기획.md" \ + --config config/paths.yaml +``` diff --git a/Codex-Tools/config/paths.yaml b/Codex-Tools/config/paths.yaml new file mode 100644 index 00000000..ea504d37 --- /dev/null +++ b/Codex-Tools/config/paths.yaml @@ -0,0 +1,8 @@ +# Fixed configuration keys for the boss pipeline. +# These names are now the canonical version used by all Python skills. + +design_root: "/mnt/smb/Obsidian Vault/Colosseum" +game_project_root: "/home/dal4segno/Colosseum" +schema_path: "/home/dal4segno/Colosseum/tools/schemas/boss_schema_v1.yaml" +runtime_boss_output_dir: "/home/dal4segno/Colosseum/content/bosses" +validation_report_suffix: "_validation.md" diff --git a/Codex-Tools/prompts/generate_yaml_prompt.md b/Codex-Tools/prompts/generate_yaml_prompt.md new file mode 100644 index 00000000..b44074a4 --- /dev/null +++ b/Codex-Tools/prompts/generate_yaml_prompt.md @@ -0,0 +1,11 @@ +Read the provided Markdown boss design document and convert it into YAML using the boss schema. + +Rules: +- Do not invent mechanics. +- Preserve signature mechanic intent. +- Preserve tank / support / dps role pressure intent. +- If a field is missing, leave it empty or null rather than fabricating. +- Prefer machine-readable snake_case values. +- Preserve uncertainty in `source_notes` or `notes` fields. + +Return only valid YAML. diff --git a/Codex-Tools/prompts/validate_prompt.md b/Codex-Tools/prompts/validate_prompt.md new file mode 100644 index 00000000..d854018e --- /dev/null +++ b/Codex-Tools/prompts/validate_prompt.md @@ -0,0 +1,8 @@ +Validate the provided boss YAML against AGENTS.md and the project design philosophy. + +Return a Boss Validation Report with: +- PASS / WARNING / FAIL +- scores +- detected issues +- suggested fixes +- explicit note of any missing source information diff --git a/Codex-Tools/schemas/boss_schema_v1.yaml b/Codex-Tools/schemas/boss_schema_v1.yaml new file mode 100644 index 00000000..3fa47cc3 --- /dev/null +++ b/Codex-Tools/schemas/boss_schema_v1.yaml @@ -0,0 +1,82 @@ +# Boss YAML Schema v1 +# Authoring target for Codex-readable encounter data. + +boss: + metadata: + boss_id: "example_boss_id" + boss_name: "Example Boss" + stage: 1 + expected_duration_sec: 600 + entity_type: "single" # single | boss_plus_adds | multi_boss | swarm + + signature: + name: "Example Signature" + concept: > + One-sentence summary of the signature encounter concept. + player_problem: > + What players must solve. + expected_solution: + tank: [] + support: [] + dps: [] + failure_result: + type: "major_damage" # major_damage | wipe | mechanic_repeat | positioning_loss | other + description: "What happens on failure." + + roles: + tank: + pressure_sources: [] + failure_results: [] + required_pressure: 1.0 + support: + pressure_sources: [] + failure_results: [] + required_pressure: 1.0 + dps: + pressure_sources: [] + failure_results: [] + required_pressure: 1.0 + + phases: + - phase_id: 1 + name: "Learn" + duration_estimate_sec: 120 + new_mechanics: [] + role_pressure_multiplier: + tank: 1.0 + support: 1.0 + dps: 1.0 + + pressure: + sustained: + sources: [] + burst: + mechanics: [] + dodge_types_required: [] + + revive: + trigger_response: + enabled: true + reactions: [] + revive_risk_level: "medium" # low | medium | high + + escalation: + enabled: true + escalation_interval_sec: 60 + escalation_types: [] + + entities: + total_entities: 1 + composition: + main_boss: 1 + add_behavior: + role: "none" + respawn: false + + difficulty: + expected_attempts: 3 + primary_failure_causes: [] + secondary_failure_causes: [] + design_intent: > + Short note on what the fight is trying to test. + diff --git a/Codex-Tools/skills/generate_boss_yaml.py b/Codex-Tools/skills/generate_boss_yaml.py new file mode 100755 index 00000000..471f669a --- /dev/null +++ b/Codex-Tools/skills/generate_boss_yaml.py @@ -0,0 +1,371 @@ +#!/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() diff --git a/Codex-Tools/skills/run_boss_pipeline.py b/Codex-Tools/skills/run_boss_pipeline.py new file mode 100755 index 00000000..f1eaf722 --- /dev/null +++ b/Codex-Tools/skills/run_boss_pipeline.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Run the full Markdown -> YAML -> Validation pipeline. + +Canonical config keys used everywhere: +- design_root +- game_project_root +- schema_path +- runtime_boss_output_dir +- validation_report_suffix +""" +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path +from typing import Any, Dict + +import yaml + +HERE = Path(__file__).resolve().parent + +REQUIRED_CONFIG_KEYS = [ + "design_root", + "game_project_root", + "schema_path", + "runtime_boss_output_dir", + "validation_report_suffix", +] + + +def load_config(path: str) -> Dict[str, Any]: + with open(path, "r", encoding="utf-8") as file: + data = yaml.safe_load(file) or {} + missing = [key for key in REQUIRED_CONFIG_KEYS if key not in data] + if missing: + raise SystemExit(f"Missing config keys in {path}: {', '.join(missing)}") + return data + + +def infer_boss_id(md_path: str) -> str: + stem = Path(md_path).stem + stem = stem.replace(" 기획", "").replace("_기획", "") + stem = stem.strip() + return stem.lower().replace(" ", "_") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run boss pipeline") + parser.add_argument("--md", required=True, help="Path to boss Markdown document") + parser.add_argument("--config", required=True, help="Path to paths.yaml config") + parser.add_argument("--boss-id", default=None, help="Optional runtime boss_id / yaml filename override") + args = parser.parse_args() + + config = load_config(args.config) + md_path = os.path.abspath(args.md) + if not os.path.exists(md_path): + raise SystemExit(f"Markdown file not found: {md_path}") + + boss_id = args.boss_id or infer_boss_id(md_path) + yaml_output = os.path.join(config["runtime_boss_output_dir"], f"{boss_id}.yaml") + report_output = os.path.join( + os.path.dirname(md_path), + f"{boss_id}{config['validation_report_suffix']}", + ) + + schema_path = config["schema_path"] + if not os.path.exists(schema_path): + print(f"Warning: schema_path does not exist yet: {schema_path}") + + gen_script = str(HERE / "generate_boss_yaml.py") + val_script = str(HERE / "validate_boss_yaml.py") + + subprocess.run( + [sys.executable, gen_script, "--md", md_path, "--output", yaml_output, "--boss-id", boss_id], + check=True, + ) + subprocess.run( + [sys.executable, val_script, "--yaml", yaml_output, "--output", report_output], + check=True, + ) + + print("Pipeline complete") + print(f"Markdown: {md_path}") + print(f"YAML: {yaml_output}") + print(f"Report: {report_output}") + + +if __name__ == "__main__": + main() diff --git a/Codex-Tools/skills/validate_boss_yaml.py b/Codex-Tools/skills/validate_boss_yaml.py new file mode 100755 index 00000000..b2f006d9 --- /dev/null +++ b/Codex-Tools/skills/validate_boss_yaml.py @@ -0,0 +1,184 @@ +#!/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() diff --git a/Docs/DESIGN_PHILOSOPHY.md b/Docs/DESIGN_PHILOSOPHY.md new file mode 100644 index 00000000..3a2d50cc --- /dev/null +++ b/Docs/DESIGN_PHILOSOPHY.md @@ -0,0 +1,79 @@ +# Multiplayer Boss Design Philosophy + +This project focuses on 4-player cooperative boss encounters. + +Bosses are not just enemies. +Bosses are cooperative execution challenges. + +## Core Design Philosophy + +Boss encounters must test: +- role execution +- positioning +- pattern recognition +- sustained coordination + +Boss encounters should not primarily test: +- raw stat checks +- random power spikes +- build luck + +## Role Model + +Roles are soft ratios. + +Players are not locked into hard classes. +Each player contributes a mixture of: +- Tank ratio +- DPS ratio +- Support ratio + +Bosses should require total role pressure fulfillment, not specific class presence. + +## Combat Identity + +Combat is: +- non-target action +- directional dodge-based +- positioning-focused + +Survival should depend more on execution than on numerical advantage. + +## Signature Pattern Philosophy + +Each boss must contain at least one defining signature mechanic. +That mechanic must: +- create a clear player problem +- have a clear expected solution +- produce a clear failure result + +Ambiguous solutions are discouraged. + +## Revive Philosophy + +Revive is allowed, but revive must be risky. +Bosses should react to revive attempts. +Revive should increase encounter tension, not reduce it. + +## Sustain Philosophy + +There is no hard time limit by default. +Instead, bosses should escalate over time. +Encounters must become harder if players fail to execute properly. + +## Build Philosophy + +Build strength should support execution. +It should not replace execution. +Players should adapt builds, not brute-force encounters. + +## Encounter Identity + +Boss equals encounter. + +Bosses may include: +- multiple entities +- supporting units +- positional hazards + +Adds must serve meaningful gameplay roles, never filler.