"""카르밀라 챕터1 플레이 CLI — 리딩 루프 + 개입 포켓 (M4 통합). 사용법: .venv/bin/python scripts/play.py # 대화형 (포켓은 LLM 키 있으면 채팅) .venv/bin/python scripts/play.py --policy trust --quiet # 자동 플레이, 엔딩 확인 .venv/bin/python scripts/play.py --chat # 포켓 자유채팅 강제 (OPENAI_API_KEY 필요) .venv/bin/python scripts/play.py --no-chat # 포켓 채팅 끄기 (선택지만) 포켓 채팅: 선택지 카드에서 번호 대신 자유 텍스트를 입력하면 카르밀라와 대화가 열리고, director 수렴(또는 턴 상한) 후 선택지로 복귀한다. """ from __future__ import annotations import argparse import asyncio import os import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from engine.repositories.content import ContentRepository # noqa: E402 from engine.services.story import StepResult, StoryEngine # noqa: E402 DIM = "\033[2m" BOLD = "\033[1m" RESET = "\033[0m" def render(step: StepResult, quiet: bool): if quiet or step.card is None: return c = step.card print(f"\n{BOLD}── {c.id} · {c.title} ──{RESET}") for b in step.narration: if b.type == "dialogue_center": print(f"\n{BOLD} ❝ {b.text} ❞{RESET}\n") elif b.type == "character_dialogue": print(f' {b.speaker}: "{b.text}"') elif b.type == "stage_direction": print(f"{DIM} ({b.text}){RESET}") else: print("\n" + b.text) async def pocket_chat(pocket_service, engine: StoryEngine, card_id: str, first_line: str): """개입 포켓 자유채팅 — 수렴/턴 상한까지 돌고 숨은 상태를 커밋한다.""" sid = await pocket_service.open(card_id, engine.state) line = first_line while True: turn = await pocket_service.run_turn(sid, line) print(f"\n {BOLD}카르밀라{RESET}: {turn.reply}") if turn.converged: print(f"{DIM} (장면이 조용히 다음으로 기운다 — {turn.reason}){RESET}") break line = input("나(로라) > ").strip() if not line: break log = await pocket_service.close(sid, engine.state) return log def pick_or_chat(step: StepResult, policy: str, chat_enabled: bool): """선택지 입력 또는 자유 텍스트(포켓 채팅 진입). 반환: ('choice', idx) | ('chat', text).""" choices = step.choices if policy != "ask": for i, ch in enumerate(choices): if ch.axis == policy: return "choice", i for i, ch in enumerate(choices): if ch.axis in (None, "neutral"): return "choice", i return "choice", 0 for i, ch in enumerate(choices): print(f" [{i + 1}] {ch.label}") if chat_enabled: print(f"{DIM} (번호 대신 하고 싶은 말을 쓰면 카르밀라와 대화합니다){RESET}") while True: raw = input("선택 > ").strip() if raw.isdigit() and 1 <= int(raw) <= len(choices): return "choice", int(raw) - 1 if raw and chat_enabled: return "chat", raw if raw and not chat_enabled: print(f"{DIM} (포켓 채팅 꺼짐 — 번호를 입력하세요){RESET}") async def main(): ap = argparse.ArgumentParser() ap.add_argument("--policy", default="ask", choices=["ask", "trust", "doubt", "neutral"]) ap.add_argument("--quiet", action="store_true") ap.add_argument("--content", default="content") ap.add_argument("--chat", action="store_true", help="포켓 자유채팅 강제 활성화") ap.add_argument("--no-chat", action="store_true", help="포켓 자유채팅 비활성화") args = ap.parse_args() chat_enabled = not args.no_chat and (args.chat or bool(os.environ.get("OPENAI_API_KEY"))) repo = ContentRepository(args.content) engine = StoryEngine(repo) pocket_service = None if chat_enabled: from engine.plugins.logging import TurnLoggingPlugin from engine.services.pocket import PocketService pocket_service = PocketService(repo, plugins=[TurnLoggingPlugin()]) step = engine.start() while step.ending is None: render(step, args.quiet) if step.choices: while True: kind, val = pick_or_chat(step, args.policy, chat_enabled) if kind == "choice": choice, step = engine.choose(val) if not args.quiet: print(f"\n{DIM}▷ {choice.label}{RESET}") if choice.result: print(choice.result) break card = repo.get_card(engine.current_id) if not card.can_chat: print(f"{DIM} (이 장면에선 그녀가 자리에 없다 — 번호를 입력하세요){RESET}") continue await pocket_chat(pocket_service, engine, engine.current_id, val) # 채팅 후 같은 카드의 선택지로 복귀 (도달점·분기는 코드가 보장) else: if not args.quiet and args.policy == "ask": input(f"{DIM}(다음 ⏎){RESET}") step = engine.advance() s = engine.state print(f"\n{BOLD}═══ 챕터 종료: {step.ending.label} ═══{RESET}") print(f"{DIM}[디버그 — 유저 비노출] trust={s.trust_score} doubt={s.doubt_score} " f"frailty={s.frailty} level={s.trust_level} 카드 {len(engine.visited)}장 방문{RESET}") if __name__ == "__main__": asyncio.run(main())