# 데모 API 계약 — 웹 리더 ↔ 엔진 (M6) > 원칙: **숨은 상태(trust/doubt/쇠약도)는 어떤 응답에도 싣지 않는다** (보이지 않는 게임). > 세션 영속화: 서사 위치 + 숨은 상태는 SQLite(`data/sessions.db`)에 매 변경 즉시 저장. > 서버 재시작 후에도 `GET /api/session/{sid}`로 복원된다. **포켓(채팅) 중간 상태는 비영속** — > 복원 시 포켓은 닫힌 상태로 시작하고, 진행 중이던 대화의 신뢰축 증분은 유실된다 (승인된 트레이드오프). > 단일 프로세스 전제(인메모리 캐시 + ADK 인메모리 세션)는 유지. ## 공통 타입 ```jsonc // Step — 리딩 화면 한 장 { "card": {"id": "E01-01", "title": "어둠으로의 초대", "episode": "E01", "episode_title": "제1화: 어린 밤의 손님"}, "narration": [ {"type": "prose|dialogue_center|character_dialogue|stage_direction", "text": "...", "speaker": "", // speaker는 character_dialogue만 "role": "lead_in|aftermath|null", // 화면 배치 역할 — lead_in(발화 직전 짧은 도입)은 // 스테이지 화면 상단에 얹힌다 (유도: 적재 시 규칙) "portrait": "/assets/인물/카르밀라.png"} // 화자 초상 (router SPEAKER_PORTRAIT, ], // 없으면 null → 이름표만. 유저·로라는 프론트가 // 1인칭(오른쪽 정렬)으로 처리 "choices": [{"label": "눈을 뜬다"}], // 없으면 [] "can_chat": true, // 개입 포켓(자유채팅) 가능 페이지 "image": "/assets/플래이 이미지/4.png", // 연출 컷 (없으면 null). 매핑: router IMAGE_MAP "background": "/assets/배경/침실.png", // 장소 배경 (카드 #장소/ 태그 → router BACKGROUND_MAP, // 매핑 없는 장소는 직전 배경 유지 · 엔딩은 null) "progress": {"index": 5, "total": 44}, // 읽기 진행도 (페이지 단위) // 페이지 = 여러 카드 병합(services/paging.py, 결정적 // 파티션 · run별 균형 분할, 목표 chapter.page_target_chars // 기본 500자). card.title은 참고용 — 클라이언트는 카드 // 소제목을 표시하지 않는다(에피소드 헤더·컷 캡션만). // card.id = 페이지 끝 카드(선택·채팅 기준), // title/episode = 첫 카드 기준. // 연출 컷은 단독 페이지 + 표시 2페이지(컷+본문) — // image가 있으면 컷 페이지 번호 = index - 1 // (클라이언트는 컷을 먼저 렌더한 뒤 본문) "ending": null // 챕터 종료 시에만 아래 Ending } // Ending {"id": "ending_eternal", "label": "The Eternal — 동화/영원", "description": "..."} // PocketTurn {"reply": "…이상한 말을 하는구나, 로라.", "converged": false, "reason": null, "turn_no": 1} ``` ## 엔드포인트 | 메서드/경로 | 요청 | 응답 | 비고 | |---|---|---|---| | `POST /api/session` | `{}` | `{"session_id", "step": Step}` | 새 플레이 시작 (E01-01) | | `GET /api/session/{sid}` | — | `{"step": Step}` | 현재 위치 재조회 = **복원 진입점** (새로고침·서버 재시작). 404면 새 세션을 만들 것 | | `POST /api/session/{sid}/advance` | `{}` | `{"step": Step}` | 선택지 없는 카드에서 다음 장 | | `POST /api/session/{sid}/choose` | `{"index": 0}` | `{"picked": {"label","result"}, "step": Step}` | 선택 반영(축은 서버 내부) | | `POST /api/session/{sid}/pocket/open` | `{}` | `{"opened": true}` | 현재 카드에서 채팅 시작. can_chat=false면 409 | | `POST /api/session/{sid}/pocket/turn` | `{"text": "..."}` | `PocketTurn` | converged=true면 자동 close(커밋) 완료 상태 | | `POST /api/session/{sid}/pocket/close` | `{}` | `{"closed": true}` | 유저가 먼저 대화를 접을 때 (커밋 포함) | | `GET /api/meta` | — | `{"cover_image","title","subtitle","intro_images"}` | 표지·인트로 시퀀스(작품/인물 소개·1장 시작 — 새 시작에만 표시, 이어 읽기는 건너뜀) | | `GET /assets/…` | — | 이미지 파일 | 연출 에셋 (대표님 산출물, 읽기 전용) | 오류: 존재하지 않는 세션 **또는 콘텐츠 갱신으로 무효화된 세션** 404 · 상태에 안 맞는 호출(선택지 카드에서 advance 등) 409 · `{"detail": "..."}` (FastAPI 기본 포맷). - 클라이언트는 `session_id`를 `localStorage`(`carmilla_sid`)에 보관하고, 기동 시 GET으로 복원을 시도한다. 404면 키를 버리고 새 세션. 복원 범위는 "현재 페이지부터" — 뒤로가기 히스토리는 세션 내 한정 - 콘텐츠(비트카드) 재변환으로 카드 ID/순서가 바뀌면 기존 세션은 서버가 무효화한다 (버전 스탬프) ## 흐름 ``` POST /api/session ─→ step ├─ choices=[] ──── POST advance ──→ step (반복) ├─ choices=[...] ─ POST choose ──→ picked.result 표시 → step │ └─ (선택 전) POST pocket/open → pocket/turn* → converged → 선택지로 복귀 └─ ending ─────── 엔딩 화면 ``` - 포켓 수렴/턴 상한(6턴)·최소 체류(2턴)는 서버 코드가 강제. 클라이언트는 converged만 따른다 - 턴 로그는 서버가 `logs/playlog.jsonl`에 기록 (연구 데이터)