"""FastAPI 엔드포인트 — HTTP만 담당 (계약: docs/contract/demo_api.md). 서사 판단은 전부 services에 위임한다. 숨은 상태는 어떤 응답에도 싣지 않는다. 실행: .venv/bin/uvicorn engine.router:app --port 8777 """ from __future__ import annotations 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.paging import Pager, PageView from engine.services.pocket import PocketService from engine.services.story import 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", # 재회 — 같은 꿈의 대면 "E06-09": "/assets/인물/카르밀라 가면.png", # 둘만의 비밀 — 가장 위험한 다정함 # E05-02(성)·E06-10(침실) 컷은 제거 — 장소 배경과 중복이라 컷 페이지가 군더더기 (유저 결정) } # 인트로 시퀀스 — 표지 "밤을 연다" 뒤, 새 시작에만 (이어 읽기는 건너뜀) INTRO_IMAGES = [ "/assets/오노마x소락_방향/개요2.png", # 작품 소개 "/assets/오노마x소락_방향/개요3.png", # 등장인물 소개 "/assets/플래이 이미지/2.png", # 1장 시작 ] # 화자 초상 (비주얼 노벨 발화 — 참고 연출: 플래이 이미지/7.png). character_dialogue의 # speaker 기준. 매핑 없는 화자(하인·백작부인·약장수 등)는 이름표만 표시된다 SPEAKER_PORTRAIT = { "카르밀라": "/assets/인물/카르밀라.png", "아버지": "/assets/인물/아버지.png", # 마담·마드모아젤은 전면 스테이지 대상에서 제외 (유저 결정 — 앙상블 장면은 # 본문 흐름으로. 에셋 매핑도 추정이었음). 필요 시 여기 한 줄로 복원 } # 장소별 배경 (비주얼 노벨) — 카드 태그 `#장소/X` 기준. 매핑 없는 장소(정원·화랑·서재 등)는 # 직전 배경 유지. 에셋이 생기면 여기 한 줄 추가 + scripts/sync_assets.py 갱신 BACKGROUND_MAP = { "침실": "/assets/배경/침실.png", "만찬홀": "/assets/배경/식당.png", "숲길": "/assets/배경/안개 숲.png", "해자": "/assets/배경/성.png", "폐허": "/assets/배경/폐허성당.png", "예배당": "/assets/배경/폐허성당.png", } app = FastAPI(title="carmilla-demo") _repo = ContentRepository(ROOT / "content") # 페이지 병합 — 여러 카드를 한 화면으로 (밀도: chapter.page_target_chars). # 연출 컷·채팅 앵커는 단독 페이지. 컷도 표시 1페이지(프론트가 이미지 캔버스 단계로 렌더 — # 단독 캡션 화면 없음). 파티션은 결정적(경로 무관) _CUT_IDS = {cid for cid in IMAGE_MAP if _repo.has_card(cid)} _SOLO_IDS = _CUT_IDS | {c.id for c in _repo.all_cards() if c.can_chat} _pager = Pager(_repo, solo_ids=_SOLO_IDS, cut_ids=set()) def _partition_backgrounds() -> list[Optional[str]]: """파티션별 유효 배경 — 카드의 첫 `#장소/` 태그가 매핑되면 갱신, 아니면 직전 배경 유지. 파티션 순서(전역)의 순수 함수라 경로·복원과 무관하게 결정적.""" bgs: list[Optional[str]] = [] current: Optional[str] = None for part in _pager.partitions: for cid in part: place = next((t.split("/", 1)[1] for t in _repo.get_card(cid).tags if t.startswith("#장소/")), None) if place and place in BACKGROUND_MAP: current = BACKGROUND_MAP[place] bgs.append(current) return bgs _PART_BG = _partition_backgrounds() _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: PageView 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=_pager.render_current(engine), 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: page = ps.last_step if page.ending is not None: return {"card": None, "narration": [], "choices": [], "can_chat": False, "image": None, "background": None, # 엔딩은 배경을 걷어낸다 "progress": {"index": _pager.display_total, "total": _pager.display_total}, "ending": {"id": page.ending.id, "label": page.ending.label, "description": page.ending.description}} first, last = page.first, page.last return { # id는 페이지 끝 카드(선택·채팅·진행 기준), 제목·에피소드·컷은 첫 카드 기준 "card": {"id": last.id, "title": first.title, "episode": first.episode_id, "episode_title": ps.episode_titles.get(first.episode_id, first.episode_id)}, "narration": [{"type": b.type, "text": b.text, "speaker": b.speaker, "role": b.role, "card": cid, "portrait": (SPEAKER_PORTRAIT.get(b.speaker) if b.type == "character_dialogue" else None)} for b, cid in zip(page.narration, page.narration_cards)], "choices": [{"label": c.label} for c in page.choices], "can_chat": page.can_chat, "image": IMAGE_MAP.get(first.id), "background": _PART_BG[_pager.part_of[last.id]], "progress": {"index": _pager.display_index(last.id), "total": _pager.display_total}, "ending": None, } # ── 세션 ──────────────────────────────────────────────────── @app.post("/api/session") async def create_session(): engine = StoryEngine(_repo) ps = PlaySession(engine=engine, last_step=_pager.complete(engine, 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 = _pager.complete(ps.engine, 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, step = ps.engine.choose(body.index) ps.last_step = _pager.complete(ps.engine, step) _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.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": "잠들지 않는 밤 · 도입 아크", "intro_images": INTRO_IMAGES} # ── 정적 프론트 ───────────────────────────────────────────── @app.get("/") async def index(): # no-cache: 매 접속마다 재검증 — 배포 후 브라우저가 옛 HTML을 계속 보여주는 사고 방지 return FileResponse(ROOT / "web" / "index.html", headers={"Cache-Control": "no-cache"}) app.mount("/static", StaticFiles(directory=ROOT / "web"), name="static") app.mount("/assets", StaticFiles(directory=ASSET_DIR), name="assets")