Junhoee's picture
Upload 98 files
f646e0b verified
Raw
History Blame Contribute Delete
5.85 kB
"""์ฝ˜ํ…์ธ  ๊ฒ€์ฆ 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:]))