"""FastAPI 엔드포인트 — HTTP만 담당 (계약: docs/contract/demo_api.md). 서사 판단은 전부 services에 위임한다. 숨은 상태는 어떤 응답에도 싣지 않는다. 실행: .venv/bin/uvicorn engine.router:app --port 8777 """ from __future__ import annotations import bisect import uuid from dataclasses import dataclass, field from pathlib import Path from typing import Optional from fastapi import FastAPI, HTTPException from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from engine.plugins.logging import TurnLoggingPlugin from engine.repositories.content import ContentRepository from engine.repositories.play_session import PlaySessionRepository from engine.repositories.playlog import PlaylogRepository from engine.services.pocket import PocketService from engine.services.story import StepResult, StoryEngine ROOT = Path(__file__).resolve().parent.parent # 연출 이미지 (대표님 산출 에셋 — 읽기 전용). 매핑은 연출 결정이므로 여기(프레젠테이션 계층)에 둔다. # 데모/배포용으로 IMAGE_MAP이 쓰는 6장만 web/assets/에 슬림 복사 (원본 95MB 폴더는 런타임 불필요). # 원본 갱신 시: scripts/sync_assets.py 재실행 (원천은 카르밀라/…/이미지/) ASSET_DIR = ROOT / "web" / "assets" COVER_IMAGE = "/assets/플래이 이미지/1.png" IMAGE_MAP = { "E01-07": "/assets/플래이 이미지/4.png", # 6세의 밤 — "그녀는 미동도 없이 나를 내려다보았다" "E03-03": "/assets/인물/카르밀라.png", # 재회 — 같은 꿈의 대면 "E05-02": "/assets/배경/성.png", # 초상화의 밤 무렵 — 고성 "E06-09": "/assets/인물/카르밀라 가면.png", # 둘만의 비밀 — 가장 위험한 다정함 "E06-10": "/assets/배경/침실.png", # 도달점 — 두 점 발견 } app = FastAPI(title="carmilla-demo") _repo = ContentRepository(ROOT / "content") # 연출 컷은 독립 페이지로 센다 (표시용 번호 — 프레젠테이션 결정이라 IMAGE_MAP과 같은 계층). # 예: 6(본문) → 7(이미지) → 8(본문). total = 카드 수 + 이미지 수 _IMAGE_ORDERS = sorted(_repo.order(cid) for cid in IMAGE_MAP if _repo.has_card(cid)) _TOTAL_PAGES = _repo.total_cards + len(_IMAGE_ORDERS) def _page_index(card_id: str) -> int: """카드 본문 페이지의 표시 번호 (앞선 이미지 페이지들 + 자기 이미지 페이지만큼 밀림). 이미지 카드의 이미지 페이지 번호는 이 값 - 1 (클라이언트가 유도).""" order = _repo.order(card_id) images_before = bisect.bisect_left(_IMAGE_ORDERS, order) own_image = 1 if card_id in IMAGE_MAP else 0 return order + 1 + images_before + own_image _pocket = PocketService( _repo, plugins=[TurnLoggingPlugin(PlaylogRepository(ROOT / "logs" / "playlog.jsonl"))]) _playdb = PlaySessionRepository(ROOT / "data" / "sessions.db") _playdb.purge(days=30) @dataclass class PlaySession: engine: StoryEngine last_step: StepResult pocket_sid: Optional[str] = None episode_titles: dict = field(default_factory=dict) _sessions: dict[str, PlaySession] = {} class ChooseBody(BaseModel): index: int class TurnBody(BaseModel): text: str def _get(sid: str) -> PlaySession: ps = _sessions.get(sid) if ps is None: ps = _restore(sid) # 서버 재시작 후 — 저장소에서 복원 if ps is None: raise HTTPException(404, "세션 없음") return ps def _restore(sid: str) -> Optional[PlaySession]: """저장 스냅샷에서 세션 복원. 포켓은 폐기(비영속 — 유저 결정), 읽기 화면부터.""" payload = _playdb.load(sid, _repo.content_version) if payload is None: return None engine = StoryEngine.restore(_repo, payload) ps = PlaySession(engine=engine, last_step=engine.current_step(), episode_titles=_episode_titles()) _sessions[sid] = ps return ps def _save(sid: str, ps: PlaySession) -> None: """write-through — 영속 상태가 바뀌는 모든 지점에서 즉시 저장.""" _playdb.save(sid, ps.engine.snapshot(), _repo.content_version) def _step_json(ps: PlaySession) -> dict: step = ps.last_step if step.ending is not None: return {"card": None, "narration": [], "choices": [], "can_chat": False, "image": None, "progress": {"index": _TOTAL_PAGES, "total": _TOTAL_PAGES}, "ending": {"id": step.ending.id, "label": step.ending.label, "description": step.ending.description}} card = step.card return { "card": {"id": card.id, "title": card.title, "episode": card.episode_id, "episode_title": ps.episode_titles.get(card.episode_id, card.episode_id)}, "narration": [{"type": b.type, "text": b.text, "speaker": b.speaker} for b in step.narration], "choices": [{"label": c.label} for c in step.choices], "can_chat": card.can_chat, "image": IMAGE_MAP.get(card.id), "progress": {"index": _page_index(card.id), "total": _TOTAL_PAGES}, "ending": None, } # ── 세션 ──────────────────────────────────────────────────── @app.post("/api/session") async def create_session(): engine = StoryEngine(_repo) ps = PlaySession(engine=engine, last_step=engine.start(), episode_titles=_episode_titles()) sid = uuid.uuid4().hex[:12] _sessions[sid] = ps _save(sid, ps) return {"session_id": sid, "step": _step_json(ps)} def _episode_titles() -> dict: from engine.schemas.validate import load_content episodes, *_ = load_content(_repo.root) return {ep.episode.id: ep.episode.title for ep in episodes} @app.get("/api/session/{sid}") async def get_session(sid: str): return {"step": _step_json(_get(sid))} # ── 리딩 ──────────────────────────────────────────────────── @app.post("/api/session/{sid}/advance") async def advance(sid: str): ps = _get(sid) if ps.pocket_sid: raise HTTPException(409, "포켓 대화 중 — 먼저 닫을 것") if ps.last_step.ending or ps.last_step.choices: raise HTTPException(409, "advance 불가 상태") ps.last_step = ps.engine.advance() _save(sid, ps) return {"step": _step_json(ps)} @app.post("/api/session/{sid}/choose") async def choose(sid: str, body: ChooseBody): ps = _get(sid) if ps.pocket_sid: raise HTTPException(409, "포켓 대화 중 — 먼저 닫을 것") if ps.last_step.ending or not ps.last_step.choices: raise HTTPException(409, "선택 불가 상태") if not 0 <= body.index < len(ps.last_step.choices): raise HTTPException(422, "선택지 범위 밖") picked, ps.last_step = ps.engine.choose(body.index) _save(sid, ps) return {"picked": {"label": picked.label, "result": picked.result}, "step": _step_json(ps)} # ── 개입 포켓 ─────────────────────────────────────────────── @app.post("/api/session/{sid}/pocket/open") async def pocket_open(sid: str): ps = _get(sid) if ps.pocket_sid: return {"opened": True} if ps.last_step.ending or not (ps.last_step.card and ps.last_step.card.can_chat): raise HTTPException(409, "이 카드는 자유채팅 포켓이 아님") ps.pocket_sid = await _pocket.open(ps.engine.current_id, ps.engine.state) return {"opened": True} @app.post("/api/session/{sid}/pocket/turn") async def pocket_turn(sid: str, body: TurnBody): ps = _get(sid) if not ps.pocket_sid: raise HTTPException(409, "포켓이 열려 있지 않음") turn = await _pocket.run_turn(ps.pocket_sid, body.text) if turn.converged: # 수렴 → 자동 커밋·복귀 await _pocket.close(ps.pocket_sid, ps.engine.state) ps.pocket_sid = None _save(sid, ps) # 포켓 커밋으로 숨은 상태 변경 return {"reply": turn.reply, "converged": turn.converged, "reason": turn.reason, "turn_no": turn.turn_no} @app.post("/api/session/{sid}/pocket/close") async def pocket_close(sid: str): ps = _get(sid) if ps.pocket_sid: await _pocket.close(ps.pocket_sid, ps.engine.state) ps.pocket_sid = None _save(sid, ps) return {"closed": True} # ── 메타 (표지 등 프레젠테이션 상수) ──────────────────────── @app.get("/api/meta") async def meta(): return {"cover_image": COVER_IMAGE, "title": "카르밀라", "subtitle": "잠들지 않는 밤 · 도입 아크"} # ── 정적 프론트 ───────────────────────────────────────────── @app.get("/") async def index(): return FileResponse(ROOT / "web" / "index.html") app.mount("/static", StaticFiles(directory=ROOT / "web"), name="static") app.mount("/assets", StaticFiles(directory=ASSET_DIR), name="assets")