Spaces:
Running
Running
| """์ฝํ ์ธ ๊ฒ์ฆ CLI โ `python -m engine.schemas.validate content/`. | |
| ํต๊ณผํ์ง ์๋ YAML์ ์์ง์ ์ ์ฌํ์ง ์๋๋ค (CLAUDE.md). | |
| ์คํค๋ง ์๋ฐ์ ์์ ํ์ง ์๊ณ ๋ฆฌํฌํธ๋ก ์ถ๋ ฅํ๋ค (๋ํ๋ ์ ๋ฌ์ฉ). | |
| ๊ฒ์ฌ ํญ๋ชฉ: | |
| 1. 3๋ ์ด์ด ์คํค๋ง ๊ฒ์ฆ (beatcard / chapter / character) + R์นด๋ | |
| 2. ๋งํฌ ๋ฌด๊ฒฐ์ฑ: next/next_options/choice.next/preset_branch โ ์ค์กด ์นด๋ ๋๋ ์ํผ์๋ ์ฐธ์กฐ | |
| 3. ๋๋ฌ์ฑ: entry_beat์์ ๋ชจ๋ ์นด๋ ๋๋ฌ ๊ฐ๋ฅ ์ฌ๋ถ + ์ฑํฐ ๋ฐ(E07)์ผ๋ก์ ์ถ๊ตฌ ์กด์ฌ | |
| 4. R์นด๋ ์ฐธ์กฐ: ์นด๋๊ฐ ๋ถ๋ฅด๋ R์นด๋๊ฐ ์ฑํฐ ๋ฑ๋กยทYAML ์์ชฝ์ ์กด์ฌ | |
| """ | |
| from __future__ import annotations | |
| import re | |
| import sys | |
| from collections import deque | |
| from pathlib import Path | |
| import yaml | |
| from engine.schemas.beatcard import BeatCard, EpisodeFile, RCard | |
| from engine.schemas.chapter import ChapterFile | |
| from engine.schemas.character import CharactersFile | |
| RE_EPISODE_REF = re.compile(r"^E\d{2}$") | |
| def load_content(root: Path): | |
| """content/ ์ ์ฒด๋ฅผ ์คํค๋ง ๊ฒ์ฆํ๋ฉฐ ๋ก๋. (episodes, r_cards, chapter, characters, errors)""" | |
| errors: list[str] = [] | |
| episodes: list[EpisodeFile] = [] | |
| r_cards: dict[str, RCard] = {} | |
| chapter = None | |
| characters = None | |
| for f in sorted((root / "beatcards").glob("E*.yaml")): | |
| try: | |
| episodes.append(EpisodeFile.model_validate(yaml.safe_load(f.read_text(encoding="utf-8")))) | |
| except Exception as e: # noqa: BLE001 โ ๋ฆฌํฌํธ ๋ชฉ์ | |
| errors.append(f"{f.name}: {e}") | |
| for f in sorted((root / "beatcards").glob("R-*.yaml")): | |
| try: | |
| rc = RCard.model_validate(yaml.safe_load(f.read_text(encoding="utf-8"))) | |
| r_cards[rc.id] = rc | |
| except Exception as e: # noqa: BLE001 | |
| errors.append(f"{f.name}: {e}") | |
| for f in sorted((root / "chapters").glob("*.yaml")): | |
| try: | |
| chapter = ChapterFile.model_validate(yaml.safe_load(f.read_text(encoding="utf-8"))).chapter | |
| except Exception as e: # noqa: BLE001 | |
| errors.append(f"{f.name}: {e}") | |
| cf = root / "characters.yaml" | |
| if cf.exists(): | |
| try: | |
| characters = CharactersFile.model_validate(yaml.safe_load(cf.read_text(encoding="utf-8"))) | |
| except Exception as e: # noqa: BLE001 | |
| errors.append(f"characters.yaml: {e}") | |
| else: | |
| errors.append("characters.yaml ์์") | |
| return episodes, r_cards, chapter, characters, errors | |
| def check_links(episodes: list[EpisodeFile], chapter, r_cards) -> tuple[list[str], list[str]]: | |
| """๋งํฌ ๋ฌด๊ฒฐ์ฑ + ๋๋ฌ์ฑ. (errors, warnings)""" | |
| errors: list[str] = [] | |
| warnings: list[str] = [] | |
| cards: dict[str, BeatCard] = {c.id: c for ep in episodes for c in ep.cards} | |
| episode_ids = {ep.episode.id for ep in episodes} | |
| def ok(target: str) -> bool: | |
| if target in cards: | |
| return True | |
| if RE_EPISODE_REF.match(target): | |
| return True # ์ํผ์๋ ์ฐธ์กฐ โ ์ฑํฐ ๋ฒ์ ๋ฐ(E07)์ด๋ฉด ์ถ๊ตฌ๋ก ์ทจ๊ธ | |
| return False | |
| edges: dict[str, list[str]] = {cid: [] for cid in cards} | |
| for c in cards.values(): | |
| targets = ([c.next] if c.next else []) + c.next_options | |
| for ch in c.choices: | |
| targets += [t for t in (ch.next, ch.preset_branch) if t] | |
| for t in targets: | |
| if not ok(t): | |
| errors.append(f"{c.id} โ '{t}' ๋งํฌ ๋๊น") | |
| elif t in cards: | |
| edges[c.id].append(t) | |
| elif t in episode_ids: # E02 ๋ฑ โ ํด๋น ์ํผ์๋ ์ฒซ ์นด๋ | |
| first = next(ep.cards[0].id for ep in episodes if ep.episode.id == t) | |
| edges[c.id].append(first) | |
| if chapter: | |
| if chapter.entry_beat not in cards: | |
| errors.append(f"entry_beat '{chapter.entry_beat}' ์นด๋ ์์") | |
| else: | |
| seen = {chapter.entry_beat} | |
| q = deque([chapter.entry_beat]) | |
| while q: | |
| for t in edges[q.popleft()]: | |
| if t not in seen: | |
| seen.add(t) | |
| q.append(t) | |
| unreachable = sorted(set(cards) - seen) | |
| if unreachable: | |
| warnings.append(f"entry์์ ๋๋ฌ ๋ถ๊ฐ ์นด๋ {len(unreachable)}๊ฐ: {', '.join(unreachable)}") | |
| registered = {r.id for r in chapter.r_cards} | |
| for c in cards.values(): | |
| for ref in c.r_card_refs: | |
| if ref not in registered: | |
| warnings.append(f"{c.id}: R์นด๋ '{ref}' ์ฑํฐ ๋ฏธ๋ฑ๋ก") | |
| if ref not in r_cards: | |
| errors.append(f"{c.id}: R์นด๋ '{ref}' YAML ์์") | |
| # ๋ง๋ค๋ฅธ ์นด๋ (next๋ ๋ถ๊ธฐ๋ ์๋๋ฐ ์ฑํฐ ์ถ๊ตฌ๋ ์๋) | |
| for c in cards.values(): | |
| has_out = c.next or c.next_options or any(ch.next for ch in c.choices) | |
| if not has_out: | |
| warnings.append(f"{c.id}: ๋๊ฐ๋ ๋งํฌ ์์ (์ฑํฐ ์ถ๊ตฌ๊ฐ ์๋๋ฉด ๊ฒฐํจ)") | |
| return errors, warnings | |
| def main(argv: list[str]) -> int: | |
| root = Path(argv[0]) if argv else Path("content") | |
| episodes, r_cards, chapter, characters, errors = load_content(root) | |
| link_errors, warnings = check_links(episodes, chapter, r_cards) | |
| errors += link_errors | |
| n_cards = sum(len(ep.cards) for ep in episodes) | |
| print(f"์ํผ์๋ {len(episodes)}๊ฐ ยท ์นด๋ {n_cards}๊ฐ ยท R์นด๋ {len(r_cards)}๊ฐ ยท " | |
| f"์ฑํฐ {'OK' if chapter else '์์'} ยท ์ธ๋ฌผ {len(characters.characters) if characters else 0}๋ช ") | |
| for w in warnings: | |
| print(f" โ {w}") | |
| if errors: | |
| print(f"\n๊ฒ์ฆ ์คํจ {len(errors)}๊ฑด:") | |
| for e in errors: | |
| print(f" โ {e}") | |
| return 1 | |
| print("โ ๊ฒ์ฆ ํต๊ณผ") | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main(sys.argv[1:])) | |