Junhoee commited on
Commit
1c9c13f
·
verified ·
1 Parent(s): e68cec5

Upload 162 files

Browse files
Files changed (41) hide show
  1. CLAUDE.md +318 -0
  2. content/beatcards/E01.yaml +5 -0
  3. content/beatcards/E02.yaml +3 -0
  4. content/beatcards/E03.yaml +2 -0
  5. content/beatcards/E04.yaml +1 -0
  6. content/beatcards/E05.yaml +3 -0
  7. content/beatcards/E06.yaml +1 -0
  8. docs/ARCHITECTURE.md +10 -3
  9. docs/contract/beatcard_guide.md +41 -0
  10. docs/contract/demo_api.md +12 -4
  11. engine/__pycache__/router.cpython-312.pyc +0 -0
  12. engine/__pycache__/router.cpython-314.pyc +0 -0
  13. engine/repositories/__pycache__/content.cpython-312.pyc +0 -0
  14. engine/repositories/__pycache__/content.cpython-314.pyc +0 -0
  15. engine/repositories/content.py +2 -1
  16. engine/router.py +1 -0
  17. engine/schemas/__pycache__/beatcard.cpython-312.pyc +0 -0
  18. engine/schemas/__pycache__/beatcard.cpython-314.pyc +0 -0
  19. engine/schemas/__pycache__/validate.cpython-312.pyc +0 -0
  20. engine/schemas/__pycache__/validate.cpython-314.pyc +0 -0
  21. engine/schemas/beatcard.py +20 -0
  22. engine/schemas/validate.py +9 -0
  23. engine/services/__pycache__/paging.cpython-312.pyc +0 -0
  24. engine/services/__pycache__/paging.cpython-314.pyc +0 -0
  25. engine/services/paging.py +53 -17
  26. scripts/beatcard_convert.py +5 -0
  27. tests/__init__.py +0 -0
  28. tests/__pycache__/__init__.cpython-314.pyc +0 -0
  29. tests/__pycache__/fakes.cpython-314.pyc +0 -0
  30. tests/__pycache__/test_integration.cpython-314-pytest-9.1.1.pyc +0 -0
  31. tests/__pycache__/test_paging.cpython-314-pytest-9.1.1.pyc +0 -0
  32. tests/__pycache__/test_persistence.cpython-314-pytest-9.1.1.pyc +0 -0
  33. tests/__pycache__/test_pocket.cpython-314-pytest-9.1.1.pyc +0 -0
  34. tests/__pycache__/test_story.cpython-314-pytest-9.1.1.pyc +0 -0
  35. tests/fakes.py +44 -0
  36. tests/test_integration.py +88 -0
  37. tests/test_paging.py +200 -0
  38. tests/test_persistence.py +124 -0
  39. tests/test_pocket.py +137 -0
  40. tests/test_story.py +113 -0
  41. web/index.html +117 -47
CLAUDE.md ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md — Interactive Novel Engine Backend
2
+
3
+ ## 프로젝트 개요
4
+
5
+ 원작 고전 소설(카르밀라)을 온톨로지 기반으로 형식화하여 "책을 읽는 느낌 + 핵심 씬에서의 상호작용"을 제공하는 인터랙티브 노벨 엔진. Google ADK 기반 파이프라인이 유저 입력을 받아 가드 → 캐릭터 응답 → 디렉터(수렴 판단) 순으로 처리하고, 원작·온톨로지 검색은 GraphRAG 계층이 담당.
6
+
7
+ **최상위 가치: 원작 충실.** 원작의 톤과 세계관이 깨지는 순간 실패다. LLM의 자유도는 기능이 아니라 리스크로 취급한다.
8
+
9
+ ## 아키텍처 3층 구조
10
+
11
+ ```
12
+ [밑단] 온톨로지/상태 설계 문서 → YAML v1.0 (대표님 산출물) → 엔진이 파싱
13
+ [중간] GraphRAG: 온톨로지 YAML을 Neo4j에 적재 + 원문 발췌 벡터 검색
14
+ [앞단] ADK 에이전트 파이프라인 (guard → character → director)
15
+ ```
16
+
17
+ - 온톨로지의 **내용**은 대표님 소유, YAML **스키마(형식)**는 개발팀 소유. 스키마 변경은 양쪽 합의로만.
18
+ - 그래프는 원문에서 LLM이 자동 추출하지 않는다. 큐레이션된 YAML을 적재하는 것이 유일한 경로.
19
+
20
+ ## 작업 원칙
21
+
22
+ ### 1. 논의 → 설계 → 계획 → 구현 → 리뷰
23
+
24
+ 코드를 먼저 작성하지 않는다. 반드시 이 순서를 따른다:
25
+
26
+ 1. **논의**: 요구사항을 정확히 이해하고 질문한다
27
+ 2. **설계**: 2-3개 접근법을 비교하고 추천한다
28
+ 3. **계획**: 구체적인 파일 목록과 변경 사항을 문서화한다
29
+ 4. **구현**: 계획대로만 구현한다. 범위를 벗어나지 않는다
30
+ 5. **리뷰**: 구현 결과를 spec과 대조하여 검증한다
31
+ 6. **갱신**: 커밋 및 코드 구현이 완료되면 해당 부분을 꼼꼼하게 단계별로 메모리에 반영 및 갱신하세요
32
+
33
+ 이 사이클을 충분히 반복한다. 급하다고 건너뛰지 않는다.
34
+
35
+ ### 2. 기존 코드의 일관성을 지킨다
36
+
37
+ - 새 코드는 기존 패턴을 따른다. 더 나은 패턴이 있어도 혼자 바꾸지 않는다
38
+ - 폴더 구조, 네이밍, import 스타일을 기존 코드에서 먼저 확인한다
39
+ - "개선"이라는 이름으로 기존 컨벤션을 무시하지 않는다
40
+
41
+ ### 3. Google ADK 공식 패턴을 따른다
42
+
43
+ - ADK가 제공하는 기능(LlmAgent, ParallelAgent, SequentialAgent, LoopAgent, AgentTool, FunctionTool)으로 해결한다
44
+ - Python으로 ADK를 우회하거나 대체하는 코드를 작성하지 않는다
45
+ - 프롬프트로 해결하려 하기 전에, 에이전트 구조(계층, 타입, 조합)로 해결할 수 있는지 먼저 검토한다
46
+ - **조금이라도 의심되면 바로 ADK 공식문서를 확인한다**: https://google.github.io/adk-docs/
47
+
48
+ ### 4. 확인 없이 추측하지 않는다
49
+
50
+ - 파일을 수정하기 전에 반드시 읽는다
51
+ - ADK API를 사용할 때 기억에 의존하지 않고 문서나 소스를 확인한다
52
+ - Docker 로그, grep 결과 등 실제 증거를 기반으로 판단한다
53
+ - 원작 원문·온톨로지 내용을 기억으로 인용하지 않는다. 반드시 content/ 또는 그래프에서 조회한다
54
+
55
+ ### 5. 서사 진행은 코드가, 대사는 LLM이
56
+
57
+ - 챕터/씬 전환, 분기 조건, 수렴 판정의 **최종 결정은 결정적 코드**(상태머신 + director 출력의 구조화된 검증)가 내린다
58
+ - LLM은 "지금 이 씬 안에서 이 캐릭터로 응답"하는 좁은 역할만 맡는다
59
+ - LLM 출력만 믿고 state를 전이시키지 않는다. 전이 조건은 반드시 코드에서 재검증한다
60
+
61
+ ---
62
+
63
+ ## 핵심 도메인 규칙
64
+
65
+ ### 원작 충실 (Canon Fidelity)
66
+
67
+ - 캐릭터가 원작에 없는 사실을 단정하는 응답은 결함이다. 프롬프트에 주입된 온톨로지·원문 발췌 범위 안에서만 말하게 한다
68
+ - 유저가 세계관을 벗어난 행동을 하면 director가 세계관 내 화법으로 되돌린다. 메타 발화("저는 AI라서...")는 절대 금지
69
+ - 문체는 `content/style/` 문체연출 가이드가 기준. style_critic 루프가 검수한다
70
+
71
+ ### 스포일러 스코프 (매우 중요)
72
+
73
+ - 모든 원문 청크, 그래프 노드/엣지, 온톨로지 항목에는 `state_id`(서사 진행 단위)가 붙는다
74
+ - **모든 검색은 `state_id <= 현재 state` 필터를 강제한다.** 필터 없는 검색 코드는 리뷰에서 반려
75
+ - 지식 상태: "state N 시점에 이 캐릭터가 아는 것"은 온톨로지의 지식 상태 항목이 권위. 캐릭터 프롬프트에는 해당 시점 지식만 주입한다
76
+
77
+ ### 수렴 (Convergence)
78
+
79
+ - 씬마다 YAML에 정의된 목표 비트(goal beats)와 수렴 조건(exit condition)이 있다
80
+ - director는 매 턴 "목표 비트 달성 여부 / 이탈 정도 / 수렴 시점 도달 여부"를 구조화 출력으로 판단한다
81
+ - 최대 턴 수를 초과하면 director가 강제 수렴 내레이션으로 씬을 닫는다 (무한 체류 방지)
82
+
83
+ ---
84
+
85
+ ## 에이전트 폴더 규칙
86
+
87
+ ```
88
+ 에이전트 = 폴더 + agent.py ← 하위 에이전트��� 있는 계층형
89
+ 하위 에이전트 = sub_agents/ 폴더 ← 부모-자식 계층
90
+ 같은 뎁스 병렬 에이전트 = agents/ 파일 ← 자체 하위 없는 단순 에이전트 (가드 등)
91
+ 도구 = tools/ 안에 1파일 = 1함수
92
+ instruction 10줄 초과 = instructions/ 폴더로 분리
93
+ 팩토리 함수 = 조립만 담당 (클래스 정의/DB 헬퍼 포함 금지)
94
+ ```
95
+
96
+ ### sub_agents/ vs agents/ 구분
97
+
98
+ - `sub_agents/X/agent.py` 패턴은 **X가 추가로 하위 계층을 가질 때** 사용.
99
+ 예: `sub_agents/director/sub_agents/style_critic/...`
100
+ - `agents/X.py` 패턴은 **X가 자체 하위 없이 단일 BaseAgent/LlmAgent로 끝날 때** 사용.
101
+ 팩토리 함수(`create_X()`)와 클래스를 같은 파일에 모아둠.
102
+ 예: `engine/agents/input_guard.py`, `spoiler_guard.py`, `scene_context_loader.py`
103
+
104
+ ### 팩토리 파일 책임 분리
105
+
106
+ `factory.py`는 **조립만** 담당. 에이전트 클래스 정의·DB 헬퍼 함수는
107
+ `agents/*.py`나 `sub_agents/*/agent.py`에 위치. 팩토리는 그것들을 import해서
108
+ `SequentialAgent(sub_agents=[create_guard(scene_id), create_character(scene_id), ...])`
109
+ 형태로 엮는 역할만.
110
+
111
+ ### 엔진 구조
112
+
113
+ ```
114
+ engine/
115
+ ├── agent.py ← 루트 에이전트 (턴 파이프라인)
116
+ ├── app.py ← ADK App + Runner + Plugin 등록
117
+ ├── model.py ← LLM 모델 설정 (env에서 읽음)
118
+ ├── router.py ← FastAPI 엔드포인트 (HTTP만 담당)
119
+ ├── instructions/ ← instruction 텍스트 (캐릭터/디렉터/가드)
120
+ ├── callbacks/ ← 에이전트별 콜백
121
+ ├── plugins/ ← ADK Plugin (횡종단 관심사)
122
+ │ └── logging.py ← 턴 로깅 (입력/컨텍스트/응답/가드발동/수렴) — 연구 데이터 겸용
123
+ ├── services/ ← 비즈니스 로직
124
+ │ ├── story.py ← 카드 진행 상태머신 (canon 읽기/next 전이/엔딩 판정)
125
+ │ ├── pocket.py ← 개입 포켓: ADK 세션 관리 + run_turn() + 수렴 코드 재검증
126
+ │ ├── state.py ← 숨은 상태 (신뢰축/쇠약도/앎) 커밋
127
+ │ └── retrieval.py ← 검색 오케스트레이션 (스포일러 필터 강제 지점, GraphRAG는 2단계)
128
+ ├── repositories/ ← 데이터 접근
129
+ │ ├── ontology.py ← 온톨로지 조회 인터페이스 (구현: yaml_direct / neo4j)
130
+ │ ├── manuscript.py ← 원문 청크 + 벡터 검색
131
+ │ └── playlog.py ← 턴 로그 저장
132
+ ├── tools/ ← 1파일 = 1도구함수
133
+ │ ├── retrieval/ ← query_ontology, search_excerpt
134
+ │ └── state/ ← set_flag, update_affinity, mark_beat
135
+ ├── sub_agents/ ← 에이전트 트리
136
+ │ ├── character/ ← 캐릭터 응답 (인물 시트 + 씬 브리프 + 발췌 주입)
137
+ │ ├── director/ ← 비트 달성/이탈/수렴 판단 (구조화 출력)
138
+ │ └── style_critic/ ← LoopAgent (generator + critic) 문체 검수
139
+ ├── schemas/ ← Pydantic: 콘텐츠 3레이어 스키마 + validate CLI
140
+ └── content/ ← (읽기 전용) 엔진의 유일한 서사 입력 — 원천은 비트카드 MD(대표님 소유)
141
+ ├── beatcards/ ← 비트카드 YAML (scripts/beatcard_convert.py 산출물, E01~)
142
+ ├── chapters/ ← 챕터 메타 (entry/exit beats, ending_rules, R카드 등록)
143
+ ├── characters.yaml ← 인물 정체성 시트 (ENOS, 지식경계, kb_query 훅)
144
+ ├── style/ ← 문체연출 가이드
145
+ └── narrative/ ← 서사/핸드오프 문서
146
+ ```
147
+
148
+ ### ADK App + Plugin 규칙
149
+
150
+ - `app.py`에서 `App` 객체 + `Runner`를 정의한다
151
+ - 횡종단 관심사(로깅, 모니터링 등)는 Plugin으로 분리한다 (`plugins/`)
152
+ - Runner는 `App` 기반으로 초기화한다 (`Runner(app=app, ...)`)
153
+ - agent를 Runner에 직접 전달하지 않는다 (레거시 패턴)
154
+ - 스캐폴드 파일(미구현 도구, 빈 콜백, 빈 instruction)은 삭제하지 않는다
155
+ - Claude Code가 ADK 패턴을 벗어나지 않도록 하는 가드레일 역할
156
+ - 호출 흐름: Agent → Tool(FunctionTool) → Service → Repository → 데이터(YAML/Neo4j/DB)
157
+
158
+ ---
159
+
160
+ ## 콘텐츠 파이프라인 (A안 아키텍처 — 2026-07-03 확정)
161
+
162
+ ### 콘텐츠 3레이어
163
+
164
+ | 레이어 | 파일 | 내용 | 대응 스키마 |
165
+ |------|------|------|------|
166
+ | 정체성 (불변) | `content/characters.yaml` | 인물 시트(ENOS), player=laura, 지식경계, kb_query 훅 | `schemas/character.py` |
167
+ | 챕터 메타 | `content/chapters/*.yaml` | entry/exit beats, beat_range, 엔딩 규칙, R카드 등록 | `schemas/chapter.py` |
168
+ | 내러티브 | `content/beatcards/E##/*.yaml` | 카드: narration 블록/choices/next/canon/복선 태그/연출 | `schemas/beatcard.py` |
169
+
170
+ - **콘텐츠 원천(권위)은 비트카드 MD**(`카르밀라/.../비트카드/`, 대표님 소유). 변환 YAML은 파생물이며 `scripts/beatcard_convert.py`(개발팀 소유)로만 생성
171
+ - 콘텐츠 내용 수정은 개발팀이 하지 않는다. 스키마 위반 발견 시 검증 리포트로 대표님께 전달
172
+ - `python -m engine.schemas.validate content/` 가 통과하지 않는 YAML은 적재하지 않는다
173
+ - MVP의 서사 진행 단위는 비트카드 순서: 스포일러 필터의 `state_id`는 `beat_id ≤ 현재`로 해석한다
174
+
175
+ ### 숨은 상태 모델 (P0 4종 엔진 명세 준거)
176
+
177
+ - **신뢰축**(의심↔신뢰 시소) · **쇠약도**(단조 증가, 신뢰와 독립 — 핵심 아이러니) · **앎**(유저, 도입 1회 확립 후 불변)
178
+ - 숫자·게이지·시스템 메시지를 유저에게 절대 노출하지 않는다. 상태는 내레이션 톤·카르밀라의 결·해금으로만 드러낸다
179
+ - 엔딩 판정은 챕터 메타의 `ending_rules` 조건식을 코드로 평가한다 (LLM 판정 금지)
180
+
181
+ ### GraphRAG 계층 (2단계 — MVP 제외)
182
+
183
+ - MVP는 pre_kb 상태: `content/`의 `kb_query` 훅(`source: kb_query, status: pending`)은 채우지 않고 그대로 둔다
184
+ - MVP 콘텐츠 로더는 `repositories/content.py`(YAML 메모리 로드). 이후 GraphRAG 도입 시 `kb_query` 훅을 채우는 구현으로 교체. 상위 계층은 구현을 몰라야 한다 (ablation 실험 요건)
185
+ - Neo4j 적재 시 LLM 자동 추출 KG 빌더(SimpleKGPipeline 류)는 사용 금지 — 큐레이션 콘텐츠와 충돌
186
+ - 독자 상호작용 데이터의 그래프화도 2단계. MVP에서는 `playlog` 스키마 설계까지만
187
+
188
+ ---
189
+
190
+ ## ADK 핵심 규칙
191
+
192
+ ### Instruction
193
+
194
+ | 종류 | 용도 | 주의 |
195
+ |------|------|------|
196
+ | `instruction` | 동적. `{var}`로 state 변수 치환 | `{}`는 state 변수로 파싱됨. 예시 텍스트에 `{}` 쓰지 말 것 |
197
+ | `global_instruction` | 전체 sub_agent에 전파 | 의도적으로 사용할 때만 채울 것 |
198
+ | `static_instruction` | 고정. 멀티모달 지원 | 캐릭터 시트처럼 씬 내 불변 컨텍스트에 검토 |
199
+
200
+ - 원작 발췌·온톨로지 컨텍스트를 instruction에 넣을 때 원문에 `{`, `}`가 포함될 수 있으므로 주입 전 이스케이프 처리를 거친다 (전처리 유틸 필수)
201
+
202
+ ### Callback (8종)
203
+
204
+ `before_agent`, `after_agent`, `before_model`, `after_model`, `before_tool`, `after_tool`, `on_model_error`, `on_tool_error`
205
+
206
+ - `before_model`: 스포일러 필터 최종 방어선 (주입 컨텍스트의 state_id 검사)
207
+ - `after_model`: 턴 로깅 훅
208
+
209
+ ### 에이전트 타입
210
+
211
+ | 타입 | 용도 | 예시 |
212
+ |------|------|------|
213
+ | `LlmAgent` | LLM이 판단/도구 호출 | character, director |
214
+ | `ParallelAgent` | 하위 에이전트 병렬 실행 | (필요 시) 다중 검색 |
215
+ | `SequentialAgent` | 하위 에이전트 순차 실행 | turn_pipeline (guard → character → director) |
216
+ | `LoopAgent` | 하위 에이전트 반복 (escalate로 탈출) | style_critic (generator + critic), 씬 내 턴 루프 |
217
+ | `BaseAgent` | LLM 없이 코드로 직접 처리 | input_guard(규칙 기반 1차), scene_context_loader, 상태 전이 검증 |
218
+ | `CustomAgent` | BaseAgent 확장. 조건 분기, 동적 에이전트 선택 | 씬 타입별 파이프라인 선택 |
219
+
220
+ ### 절대 하지 말 것
221
+
222
+ - `output_schema`와 `tools` 동시 사용 — **Gemini 3.0만 지원**, 다른 모델에서는 안 됨. 필요하면 sub-agent로 출력 포맷팅을 분리할 것
223
+ - `mode="ANY"` 사용 (무한 루프 발생)
224
+ - instruction 안에 예시 텍스트로 `{한국어}` 사용 (state 변수로 파싱됨 → 에러)
225
+ - Python 코드로 ADK 워크플로우를 대체 (keyword matching, hardcoded fallback 등)
226
+ - `LlmAgent`의 `output_key`로 **구조화된 값을 기대** — LLM 텍스트만 저장됨. director의 수렴 판정처럼 구조화 값이 필요하면 `FunctionTool`이 `tool_context.state`에 직접 쓰거나 BaseAgent로 구현
227
+ - 검색/컨텍스트 주입 경로에서 `state_id` 필터를 생략 (스포일러 사고)
228
+ - LLM 판단만으로 씬/챕터 전이 (반드시 `services/scene.py`의 코드 검증을 거칠 것)
229
+ - 원작 원문을 프롬프트에 기억으로 재현 (반드시 repository 조회)
230
+
231
+ ### Escalate scope 격리 (매우 중요)
232
+
233
+ ADK `LoopAgent`는 sub_agent의 `escalate=True` 이벤트를 **상위로 yield**한다. 즉
234
+ 중첩 LoopAgent가 있으면 바깥 루프까지 함께 탈출한다(전 프로젝트 실측 확인). 이로 인해:
235
+
236
+ - **SequentialAgent는 escalate를 인식하지 않음**. escalate가 필요한 자리에 놓으려면
237
+ 해당 범위만 `LoopAgent(max_iterations=1)`로 감쌀 것
238
+ - **내부 LoopAgent의 성공-escalate가 바깥을 오염시키면 안 되는 경우**엔 반드시
239
+ 바깥을 SequentialAgent로 두고, escalate를 소화할 scope만 LoopAgent로
240
+ - 씬 턴 파이프라인 설계 시 이 규칙을 적용한 기준 구조:
241
+ ```
242
+ scene_session (SequentialAgent)
243
+ ├── scene_context_loader ← BaseAgent, YAML/그래프에서 씬 브리프 로드
244
+ ├── turn_loop (LoopAgent max=씬별 max_turns) ← director의 수렴-escalate가 여기서 그침
245
+ │ ├── input_guard
246
+ │ ├── character
247
+ │ └── director ← 수렴 판정 시 escalate
248
+ └── scene_closer (LoopAgent max=1) ← 강제 수렴 내레이션 + 상태 전이 커밋
249
+ ```
250
+
251
+ ### 권위 소스 원칙
252
+
253
+ **"한 정보의 진실은 한 곳에만 있어야 한다."**
254
+
255
+ - 서사 내용(인물/관계/사건/지식상태) 권위: `content/yaml/` (적재된 Neo4j는 그 사본). 코드에 서사 내용 하드코딩 금지
256
+ - 씬 진행 상태 권위: `session.state` (씬 내) → 씬 종료 시 `scene.py`가 DB에 커밋 (영속). 두 곳을 동시에 갱신하는 코드 금지
257
+ - 현재 유저의 서사 위치 권위: DB `play_session.current_state_id`. 검색 필터는 이 값으로만
258
+ - 문체 기준 권위: `content/style/`. 프롬프트마다 문체 지시를 즉흥 작성하지 않는다
259
+ - 수렴 판정 권위: director의 구조화 출력 + `scene.py`의 코드 검증. LLM 텍스트 파싱으로 판정 금지
260
+
261
+ ---
262
+
263
+ ## LLM 모델
264
+
265
+ ```python
266
+ # engine/model.py
267
+ from google.adk.models.lite_llm import LiteLlm
268
+ _MODEL_ID = os.environ.get("LLM_MODEL", "openai/gpt-5.4-nano")
269
+ LLM_MODEL = LiteLlm(model=_MODEL_ID)
270
+ ```
271
+
272
+ - 로컬 테스트: 저비용 모델로 파이프라인 검증
273
+ - 캐릭터/문체 품질이 중요한 character, style_critic은 상위 모델을 별도 env로 분리 가능하게 (`CHARACTER_MODEL`)
274
+ - 모델 교체가 잦으므로 프롬프트에 특정 모델 의존 문법을 넣지 않는다
275
+
276
+ ---
277
+
278
+ ## 테스트 & 검증
279
+
280
+ ```bash
281
+ # 단위 테스트
282
+ python3 -m pytest tests/ -v
283
+
284
+ # 콘텐츠 스키마 검증
285
+ python3 -m engine.schemas.validate content/
286
+
287
+ # 스포일러 필터 회귀 테스트 (state N에서 N+1 정보 누출 여부)
288
+ python3 -m pytest tests/test_spoiler_scope.py -v
289
+
290
+ # 평가 하네스 (LLM judge: 원작 충실도 / 문체 / 수렴 성공률)
291
+ python3 -m eval.run --scene <scene_id> --n 10
292
+
293
+ # Docker 빌드 확인
294
+ docker compose up -d --build backend
295
+ docker compose logs --tail=20 backend
296
+ ```
297
+
298
+ - 프롬프트 변경은 평가 하네스 점수와 함께 PR에 첨부한다 (감이 아니라 숫자로)
299
+ - 턴 로그는 삭제하지 않는다 (연구 데이터)
300
+
301
+ ---
302
+
303
+ ## 커밋 규칙
304
+
305
+ - **main에 직접 푸시 금지.** 작업은 무조건 feature 브랜치에서 하고 PR로 올린다
306
+ - 파일 수정 전 반드시 Read로 현재 내용 확인
307
+ - 구조 변경 시 "복사 → 전환 → 삭제" 순서 (기존 기능 유지)
308
+ - Docker 빌드로 검증 후 커밋
309
+ - 비트카드 MD 원본과 `content/` 하위 콘텐츠 내용은 개발 커밋에서 수정하지 않는다 (콘텐츠 변경은 별도 PR + 대표님 승인. 변환 스크립트 재실행 산출물 갱신은 예외)
310
+
311
+ ---
312
+
313
+ ## 참고 링크
314
+
315
+ - ADK 공식문서: https://google.github.io/adk-docs/
316
+ - ADK GitHub: https://github.com/google/adk-python
317
+ - Neo4j GraphRAG Python: https://github.com/neo4j/neo4j-graphrag-python
318
+ - LiteLLM 문서: https://docs.litellm.ai/docs/providers
content/beatcards/E01.yaml CHANGED
@@ -142,12 +142,14 @@ cards:
142
  - type: prose
143
  text: 성 안의 식구는 단출했다. 아버지와 나, 그리고 두 분의 부인. 한 분은 마담 페로동, 스위스 출신의 가정부이자 내 말동무였다. 둥글고 따뜻한 얼굴에 늘 바느질감을
144
  든 분이었다.
 
145
  - type: character_dialogue
146
  speaker: 마담
147
  text: 우리 아씨, 또 그렇게 멍하니 계시네. 이리 와 곁에 앉아요.
148
  anchor: true
149
  - type: prose
150
  text: 또 한 분은 마드모아젤 드 라퐁텐, 프랑스계 가정교사였다. 책과 별자리와 옛 전설에 밝아, 달이 밝은 밤이면 알 수 없는 이야기로 나를 오싹하게 했다.
 
151
  - type: character_dialogue
152
  speaker: 마드모아젤
153
  text: 달이 저렇게 밝은 밤은 조심해야 해요. 사람의 마음을 흔드는 기운이 있으니까.
@@ -374,12 +376,14 @@ cards:
374
  narration:
375
  - type: prose
376
  text: 내 비명에 온 집안이 발칵 뒤집혔다. 보모와 하녀들이 등불을 들고 달려왔고, 곧 아버지와 두 부인도 잠옷 바람으로 들이닥쳤다.
 
377
  - type: character_dialogue
378
  speaker: 아버지
379
  text: 무슨 일이냐, 로라. 무얼 보았어?
380
  anchor: true
381
  - type: prose
382
  text: '*(유저는 본 것을 말할 수 있다 — 사람이 있었다고 우기든, 더듬더듬 설명하든.)* 어른들은 등불을 들고 침대 밑이며 옷장이며 온 방을 뒤졌다.'
 
383
  - type: character_dialogue
384
  speaker: 하인
385
  text: 침대 밑엔… 아무것도 없습니다.
@@ -394,6 +398,7 @@ cards:
394
  anchor: true
395
  - type: prose
396
  text: 아버지가 내 머리를 쓰다듬으며 부드럽게 말씀하셨다.
 
397
  - type: character_dialogue
398
  speaker: 아버지
399
  text: 무서운 꿈을 꾼 게로구나, 우리 로라.
 
142
  - type: prose
143
  text: 성 안의 식구는 단출했다. 아버지와 나, 그리고 두 분의 부인. 한 분은 마담 페로동, 스위스 출신의 가정부이자 내 말동무였다. 둥글고 따뜻한 얼굴에 늘 바느질감을
144
  든 분이었다.
145
+ role: lead_in
146
  - type: character_dialogue
147
  speaker: 마담
148
  text: 우리 아씨, 또 그렇게 멍하니 계시네. 이리 와 곁에 앉아요.
149
  anchor: true
150
  - type: prose
151
  text: 또 한 분은 마드모아젤 드 라퐁텐, 프랑스계 가정교사였다. 책과 별자리와 옛 전설에 밝아, 달이 밝은 밤이면 알 수 없는 이야기로 나를 오싹하게 했다.
152
+ role: lead_in
153
  - type: character_dialogue
154
  speaker: 마드모아젤
155
  text: 달이 저렇게 밝은 밤은 조심해야 해요. 사람의 마음을 흔드는 기운이 있으니까.
 
376
  narration:
377
  - type: prose
378
  text: 내 비명에 온 집안이 발칵 뒤집혔다. 보모와 하녀들이 등불을 들고 달려왔고, 곧 아버지와 두 부인도 잠옷 바람으로 들이닥쳤다.
379
+ role: lead_in
380
  - type: character_dialogue
381
  speaker: 아버지
382
  text: 무슨 일이냐, 로라. 무얼 보았어?
383
  anchor: true
384
  - type: prose
385
  text: '*(유저는 본 것을 말할 수 있다 — 사람이 있었다고 우기든, 더듬더듬 설명하든.)* 어른들은 등불을 들고 침대 밑이며 옷장이며 온 방을 뒤졌다.'
386
+ role: lead_in
387
  - type: character_dialogue
388
  speaker: 하인
389
  text: 침대 밑엔… 아무것도 없습니다.
 
398
  anchor: true
399
  - type: prose
400
  text: 아버지가 내 머리를 쓰다듬으며 부드럽게 말씀하셨다.
401
+ role: lead_in
402
  - type: character_dialogue
403
  speaker: 아버지
404
  text: 무서운 꿈을 꾼 게로구나, 우리 로라.
content/beatcards/E02.yaml CHANGED
@@ -116,6 +116,7 @@ cards:
116
  narration:
117
  - type: prose
118
  text: 그런데 산책 도중, 아버지의 손에 한 통의 편지가 들려 있었다. 그날 낮 인편으로 도착한 장군의 편지였다. 아버지의 표정은 무겁게 가라앉아 있었다.
 
119
  - type: character_dialogue
120
  speaker: 아버지
121
  text: 로라, 안타까운 소식이구나. 베르타 양은 오지 못한다." … "그 아이가 세상을 떠났다는구나. 알 수 없는 병에 걸려, 손쓸 새도 없이 시들어 갔다고 한다.
@@ -215,6 +216,7 @@ cards:
215
  narration:
216
  - type: prose
217
  text: 노부인은 아버지에게 다가와 사고를 사죄하고는, 다급한 어조로 청했다.
 
218
  - type: character_dialogue
219
  speaker: 백작부인
220
  text: 신사 양반, 부탁이 있습니다. 저는 한시도 지체할 수 없는 비밀스러운 여정 중입니다. 목숨이 걸린 일이며, 석 달 안에는 반드시 마쳐야 합니다. 한데 마차가 부서지고
@@ -254,6 +256,7 @@ cards:
254
  - type: prose
255
  text: 아버지는 잠시 곤혹스러워했으나, 쓰러진 아가씨의 가련한 모습과 노부인의 절박한 간청 앞에서 차마 거절하지 못하셨다. 본디 인정 많은 분이었고, 또래의 벗을 잃고 상심한
256
  내 처지도 떠올리신 듯했다.
 
257
  - type: character_dialogue
258
  speaker: 아버지
259
  text: 사정이 그러하다면, 기꺼이 따님을 모시겠습니다. 제 딸 로라가 좋은 벗이 되어 드릴 겁니다.
 
116
  narration:
117
  - type: prose
118
  text: 그런데 산책 도중, 아버지의 손에 한 통의 편지가 들려 있었다. 그날 낮 인편으로 도착한 장군의 편지였다. 아버지의 표정은 무겁게 가라앉아 있었다.
119
+ role: lead_in
120
  - type: character_dialogue
121
  speaker: 아버지
122
  text: 로라, 안타까운 소식이구나. 베르타 양은 오지 못한다." … "그 아이가 세상을 떠났다는구나. 알 수 없는 병에 걸려, 손쓸 새도 없이 시들어 갔다고 한다.
 
216
  narration:
217
  - type: prose
218
  text: 노부인은 아버지에게 다가와 사고를 사죄하고는, 다급한 어조로 청했다.
219
+ role: lead_in
220
  - type: character_dialogue
221
  speaker: 백작부인
222
  text: 신사 양반, 부탁이 있습니다. 저는 한시도 지체할 수 없는 비밀스러운 여정 중입니다. 목숨이 걸린 일이며, 석 달 안에는 반드시 마쳐야 합니다. 한데 마차가 부서지고
 
256
  - type: prose
257
  text: 아버지는 잠시 곤혹스러워했으나, 쓰러진 아가씨의 가련한 모습과 노부인의 절박한 간청 앞에서 차마 거절하지 못하셨다. 본디 인정 많은 분이었고, 또래의 벗을 잃고 상심한
258
  내 처지도 떠올리신 듯했다.
259
+ role: lead_in
260
  - type: character_dialogue
261
  speaker: 아버지
262
  text: 사정이 그러하다면, 기꺼이 따님을 모시겠습니다. 제 딸 로라가 좋은 벗이 되어 드릴 겁니다.
content/beatcards/E03.yaml CHANGED
@@ -98,6 +98,7 @@ cards:
98
  narration:
99
  - type: prose
100
  text: 우리는 거실 창가에 마주 앉았다. 나는 차마 입을 떼지 못하다가, 결국 마음속에 담아 두었던 말을 꺼내고 말았다.
 
101
  - type: character_dialogue
102
  speaker: 로라
103
  text: 이상하게 들리실지 모르지만… 저는 당신을 전에 본 적이 있어요. 여섯 살 무렵, 한밤중에 제 방에 한 아름다운 여인이 나타나 저를 어르고 곁에 누웠지요. 모두들 꿈이라
@@ -105,6 +106,7 @@ cards:
105
  anchor: true
106
  - type: prose
107
  text: 내 말이 끝나기 무섭게, 카르밀라의 두 눈이 놀라움으로 크게 떠졌다. 그녀는 한참을 말없이 나를 응시하더니, 떨리는 목소리로 입을 열었다.
 
108
  - type: character_dialogue
109
  speaker: 카르밀라
110
  text: 세상에 이런 일이. 저 역시 똑같은 꿈을 꾸었어요. 어린 시절 어느 밤, 제 방에 한 사랑스러운 소녀가 나타났지요. 저는 그 아이를 끌어안고 함께 잠들었는데, 깨어
 
98
  narration:
99
  - type: prose
100
  text: 우리는 거실 창가에 마주 앉았다. 나는 차마 입을 떼지 못하다가, 결국 마음속에 담아 두었던 말을 꺼내고 말았다.
101
+ role: lead_in
102
  - type: character_dialogue
103
  speaker: 로라
104
  text: 이상하게 들리실지 모르지만… 저는 당신을 전에 본 적이 있어요. 여섯 살 무렵, 한밤중에 제 방에 한 아름다운 여인이 나타나 저를 어르고 곁에 누웠지요. 모두들 꿈이라
 
106
  anchor: true
107
  - type: prose
108
  text: 내 말이 끝나기 무섭게, 카르밀라의 두 눈이 놀라움으로 크게 떠졌다. 그녀는 한참을 말없이 나를 응시하더니, 떨리는 목소리로 입을 열었다.
109
+ role: lead_in
110
  - type: character_dialogue
111
  speaker: 카르밀라
112
  text: 세상에 이런 일이. 저 역시 똑같은 꿈을 꾸었어요. 어린 시절 어느 밤, 제 방에 한 사랑스러운 소녀가 나타났지요. 저는 그 아이를 끌어안고 함께 잠들었는데, 깨어
content/beatcards/E04.yaml CHANGED
@@ -441,6 +441,7 @@ cards:
441
  narration:
442
  - type: prose
443
  text: 그날 밤도 카르밀라는 격정적으로 나를 끌어안으며, 눈물 어린 눈으로 속삭였다.
 
444
  - type: character_dialogue
445
  speaker: 카르밀라
446
  text: 로라, 너만은 절대로 그 행렬 속에 들어가게 하지 않을 거야. 너는 영원히 나와 함께 살 거란다. 죽음 따위가 너를 데려가도록 두지 않을 거야.
 
441
  narration:
442
  - type: prose
443
  text: 그날 밤도 카르밀라는 격정적으로 나를 끌어안으며, 눈물 어린 눈으로 속삭였다.
444
+ role: lead_in
445
  - type: character_dialogue
446
  speaker: 카르밀라
447
  text: 로라, 너만은 절대로 그 행렬 속에 들어가게 하지 않을 거야. 너는 영원히 나와 함께 살 거란다. 죽음 따위가 너를 데려가도록 두지 않을 거야.
content/beatcards/E05.yaml CHANGED
@@ -156,6 +156,7 @@ cards:
156
  narration:
157
  - type: prose
158
  text: 나는 반쯤 농담 삼아 말했다.
 
159
  - type: character_dialogue
160
  speaker: 로라
161
  text: 카르밀라, 이 그림 당신과 너무 똑같지 않아요? 미르칼라 카른슈타인 백작부인이라니… 카른슈타인은 우리 어머니의 가문이에요. 어쩌면 이 부인이 내 먼 조상일지도. 그렇다면
@@ -203,6 +204,7 @@ cards:
203
  narration:
204
  - type: prose
205
  text: 그녀는 그제야 나를 돌아보며, 나직이 말을 이었다.
 
206
  - type: character_dialogue
207
  speaker: 카르밀라
208
  text: 그래, 어쩌면 우리는 아주 오래전부터 이어져 있었는지도 몰라. 우리가 이렇게 만난 것도, 그저 우연만은 아닐 거야, 로라.
@@ -354,6 +356,7 @@ cards:
354
  narration:
355
  - type: prose
356
  text: 그날 밤늦게, 나는 아버지께 화랑의 초상화 이야기를 꺼냈다. 아버지는 서재의 오래된 가계도를 펼치며 말씀하셨다.
 
357
  - type: character_dialogue
358
  speaker: 아버지
359
  text: 카른슈타인 가문은 한때 이 일대에서 가장 위세 높은 귀족이었단다. 그러나 백오십 년쯤 전 완전히 끊기고 말았지. 마지막 후손까지 모두 떠났고, 성과 마을은 버려졌다.
 
156
  narration:
157
  - type: prose
158
  text: 나는 반쯤 농담 삼아 말했다.
159
+ role: lead_in
160
  - type: character_dialogue
161
  speaker: 로라
162
  text: 카르밀라, 이 그림 당신과 너무 똑같지 않아요? 미르칼라 카른슈타인 백작부인이라니… 카른슈타인은 우리 어머니의 가문이에요. 어쩌면 이 부인이 내 먼 조상일지도. 그렇다면
 
204
  narration:
205
  - type: prose
206
  text: 그녀는 그제야 나를 돌아보며, 나직이 말을 이었다.
207
+ role: lead_in
208
  - type: character_dialogue
209
  speaker: 카르밀라
210
  text: 그래, 어쩌면 우리는 아주 오래전부터 이어져 있었는지도 몰라. 우리가 이렇게 만난 것도, 그저 우연만은 아닐 거야, 로라.
 
356
  narration:
357
  - type: prose
358
  text: 그날 밤늦게, 나는 아버지께 화랑의 초상화 이야기를 꺼냈다. 아버지는 서재의 오래된 가계도를 펼치며 말씀하셨다.
359
+ role: lead_in
360
  - type: character_dialogue
361
  speaker: 아버지
362
  text: 카른슈타인 가문은 한때 이 일대에서 가장 위세 높은 귀족이었단다. 그러나 백오십 년쯤 전 완전히 끊기고 말았지. 마지막 후손까지 모두 떠났고, 성과 마을은 버려졌다.
content/beatcards/E06.yaml CHANGED
@@ -337,6 +337,7 @@ cards:
337
 
338
  그러나 그 위안의 한복판에는 늘 한 점 서늘한 의문이 박혀 있었다. 어째서 그녀는 내가 쇠약해지는 것을 슬퍼하면서도, 단 한 번도 의사를 부르자거나 아버지께 알리자고 하지
339
  않았을까.'
 
340
  - type: character_dialogue
341
  speaker: 카르밀라
342
  text: 사람들은 이해 못 해. 너와 나 사이의 일을. 그러니 우리끼리만 간직하자.
 
337
 
338
  그러나 그 위안의 한복판에는 늘 한 점 서늘한 의문이 박혀 있었다. 어째서 그녀는 내가 쇠약해지는 것을 슬퍼하면서도, 단 한 번도 의사를 부르자거나 아버지께 알리자고 하지
339
  않았을까.'
340
+ role: lead_in
341
  - type: character_dialogue
342
  speaker: 카르밀라
343
  text: 사람들은 이해 못 해. 너와 나 사이의 일을. 그러니 우리끼리만 간직하자.
docs/ARCHITECTURE.md CHANGED
@@ -139,9 +139,12 @@ MD를 고치고 변환기를 다시 돌린다 (YAML 직접 수정 금지).
139
 
140
  `web/index.html` 단일 파일 (바닐라 JS, 빌드 없음). router.py가 서빙.
141
 
142
- - **읽기**: 페이지 단위 페이징 — 는 여러 카드 병합(서버 `paging.py`, 목표 500자). 우측 클릭/→ = 다음, 좌측 클릭/← = 이전(읽기 전용 되돌아보기 — 선택 번복 불가). 연출 컷(이미지)은 자체 번호를 가진 독립 페이지 — 본문과 똑같이 넘기고 되돌아본다
 
143
  - **이어 읽기**: 세션 ID를 `localStorage`에 보관 — 새로고침·서버 재시작 후에도 현재 페이지부터 재개 (표지 버튼이 "밤을 잇는다"로 바뀜). 엔딩에서 재시작하면 폐기
144
- - **선택지**: 본문 아래 중앙 1열. 결과 산문 우측 클릭으로 진행
 
 
145
  - **채팅**: 카르밀라 카드에서 "…말을 건다" → 하단 드로어. 수렴 시 여운(3.4초) 후 자동 복귀
146
  - **연출**: 상단 진행 바(n/총페이지 = 카드 61 + 컷 5 = 66), 중요 장면 이미지 페이지, 표지
147
  - 이미지 원본: `카르밀라/0630…/이미지/` (router의 `/assets`로 서빙, 매핑은 `IMAGE_MAP`)
@@ -157,6 +160,7 @@ MD를 고치고 변환기를 다시 돌린다 (YAML 직접 수정 금지).
157
  | `tests/` (18개) | `test_story.py` 읽기/엔딩/스포일러 · `test_pocket.py` 채팅 파이프라인(가짜 LLM으로 결정적 검증) · `test_integration.py` 읽기→채팅→엔딩 전체 관통 · `fakes.py` 스크립트 LLM 테스트 더블 |
158
  | `docs/plans/mvp.md` | 마일스톤 M0~M6 계획·상태·결정 로그 |
159
  | `docs/contract/demo_api.md` | 웹 리더 ↔ 서버 HTTP API 계약 |
 
160
  | `docs/ARCHITECTURE.md` | 본 문서 |
161
 
162
  ---
@@ -228,7 +232,10 @@ E06 끝(→E07 참조) 도달 → story.py가 ending_rules 평가:
228
  | 신뢰축 이동량·레벨 경계 | `engine/services/state.py` (AXIS_DELTA, trust_level) |
229
  | 채팅 턴 상한 | `content/chapters/carmilla_chapter1.yaml` (pocket_max_turns) |
230
  | 페이지당 글 밀도 | `content/chapters/carmilla_chapter1.yaml`에 `page_target_chars` (미설정 시 500) |
231
- | 연출 이미지 추가·교체 | `engine/router.py` (IMAGE_MAP) |
 
 
 
232
  | 화면 디자인·인터랙션 | `web/index.html` |
233
  | 세션 저장·복원 정책 (TTL, 버전 무효화) | `engine/repositories/play_session.py` + `engine/router.py` (`_save`/`_restore`) |
234
  | 정체직격/약점 트리거 단어 | `engine/agents/input_guard.py` (TRIGGERS) |
 
139
 
140
  `web/index.html` 단일 파일 (바닐라 JS, 빌드 없음). router.py가 서빙.
141
 
142
+ - **읽기**: 화면 단위 페이징 — 스크롤 넘김으로만 읽다. 서버 페이지(여러 카드 병합, `paging.py` run별 균형 분할 — 목표 500자, 병합 페이지 밴드 약 360~760자)를 프론트가 뷰포트 실측(`#page-measure`)으로 화면들로 분할(`buildScreens`), 같은 진행도 번호를 공유. 우측 클릭/→ = 다음, 좌측 클릭/← = 이전(읽기 전용 되돌아보기 — 선택 번복 불가). 연출 컷(이미지)은 자체 번호를 가진 독립 페이지
143
+ - **타이틀**: 에피소드 첫 페이지의 "제N화" 헤더만 표시 — 카드 소제목은 렌더하지 않는다(책의 흐름). 컷 캡션은 유지
144
  - **이어 읽기**: 세션 ID를 `localStorage`에 보관 — 새로고침·서버 재시작 후에도 현재 페이지부터 재개 (표지 버튼이 "밤을 잇는다"로 바뀜). 엔딩에서 재시작하면 폐기
145
+ - **캐릭터 발화(하이브리드)**: 서사 속 `character_dialogue`는 컴팩트 char-line(좌 화자명 · 우 대사)으로 본문 흡수 책의 흐름 우. 유저·로라는 "나"로기. `dialogue_center`는 중앙 인용 스타일 유지
146
+ - **VN 스테이지**: 인터랙션 페이지(선택지·채팅)의 마지막 초상 발화만 스테이지(단독 화면) — 초상이 프레임(76vh)을 채우고 하단 패널에 장식 이름표·대사, 마지막 그룹이면 선택지·말걸기 버튼 내장. **발화 직전의 짧은 도입 prose(role=lead_in, 140자 이하 자동 유도)는 스테이지 상단에 얹힌다** — 한 문장 파편 화면 방지. 채팅 드로어가 열리면 초상 위에서 대화하는 구도 (참고: 플래이 이미지/7.png)
147
+ - **선택지**: `›` 마커 + 가는 구분선 리스트 (대화하듯). 선택 결과 산문 표시 후 우측 클릭으로 진행
148
  - **채팅**: 카르밀라 카드에서 "…말을 건다" → 하단 드로어. 수렴 시 여운(3.4초) 후 자동 복귀
149
  - **연출**: 상단 진행 바(n/총페이지 = 카드 61 + 컷 5 = 66), 중요 장면 이미지 페이지, 표지
150
  - 이미지 원본: `카르밀라/0630…/이미지/` (router의 `/assets`로 서빙, 매핑은 `IMAGE_MAP`)
 
160
  | `tests/` (18개) | `test_story.py` 읽기/엔딩/스포일러 · `test_pocket.py` 채팅 파이프라인(가짜 LLM으로 결정적 검증) · `test_integration.py` 읽기→채팅→엔딩 전체 관통 · `fakes.py` 스크립트 LLM 테스트 더블 |
161
  | `docs/plans/mvp.md` | 마일스톤 M0~M6 계획·상태·결정 로그 |
162
  | `docs/contract/demo_api.md` | 웹 리더 ↔ 서버 HTTP API 계약 |
163
+ | `docs/contract/beatcard_guide.md` | 비트카드 작성 가이드 (권장 분량·3막 패턴·리드인 규칙 — E07~ 집필 참고, validate 경고와 연동) |
164
  | `docs/ARCHITECTURE.md` | 본 문서 |
165
 
166
  ---
 
232
  | 신뢰축 이동량·레벨 경계 | `engine/services/state.py` (AXIS_DELTA, trust_level) |
233
  | 채팅 턴 상한 | `content/chapters/carmilla_chapter1.yaml` (pocket_max_turns) |
234
  | 페이지당 글 밀도 | `content/chapters/carmilla_chapter1.yaml`에 `page_target_chars` (미설정 시 500) |
235
+ | 연출 이미지 추가·교체 | `engine/router.py` (IMAGE_MAP) + `scripts/sync_assets.py` 재실행 (원천: 카르밀라/0701_카르밀라/이미지/) |
236
+ | 장소별 배경 추가·교체 | `engine/router.py` (BACKGROUND_MAP — 카드 `#장소/` 태그 기준) + sync_assets |
237
+ | 화자 초상 추가·교체 (VN 발화) | `engine/router.py` (SPEAKER_PORTRAIT) + sync_assets |
238
+ | 인트로 시퀀스(작품·인물 소개) 교체 | `engine/router.py` (INTRO_IMAGES) |
239
  | 화면 디자인·인터랙션 | `web/index.html` |
240
  | 세션 저장·복원 정책 (TTL, 버전 무효화) | `engine/repositories/play_session.py` + `engine/router.py` (`_save`/`_restore`) |
241
  | 정체직격/약점 트리거 단어 | `engine/agents/input_guard.py` (TRIGGERS) |
docs/contract/beatcard_guide.md ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 비트카드 작성 가이드 — 화면이 되는 글
2
+
3
+ > 대상: 비트카드 MD 작성(대표님) · E07~E15 집필 참고.
4
+ > 배경: 엔진은 카드를 "화면"으로 만든다. 카드의 글자 수·블록 구성이 그대로
5
+ > 독자의 페이지 경험이 된다. 검증은 `python -m engine.schemas.validate content/`가
6
+ > 경고 리포트로 알려준다 (실패 아님 — 판단은 대표님 몫).
7
+
8
+ ## 1. 카드 한 장 = 의미 있는 화면 하나
9
+
10
+ - **권장 지문 분량: 카드당 200~700자.**
11
+ - 200자 미만 → 화면이 빈약해진다 (validate가 경고). 예: E05-05(96자), E06-10C(154자)
12
+ - 700자 초과 → 엔진이 알아서 나누지만, 700자 안쪽이면 의도한 단위로 보여진다
13
+ - 선택지·채팅(앵커)·연출 컷 카드는 **항상 단독 화면**이 된다 — 이 카드들이 특히
14
+ 분량·구성의 영향을 크게 받는다
15
+
16
+ ## 2. 인터랙션 카드의 3막 패턴 (구조가 연출이 된다)
17
+
18
+ 채팅·선택지 카드는 대개 이런 구조이고, 엔진이 이를 화면 연출로 해석한다:
19
+
20
+ ```
21
+ [리드인] 짧은 도입 산문 (140자 이하) → 초상 화면 상단에 얹힘
22
+ [앵커] 캐릭터 발화 (LLM 변주 기준) → 초상 스테이지의 대사
23
+ [여파] 반응 산문 + 선택지 → 다음 화면, 선택지와 함께
24
+ ```
25
+
26
+ - **발화 직전의 140자 이하 산문은 자동으로 리드인**으로 처리된다
27
+ (엔진 유도 규칙 — YAML `role: lead_in`으로 명시 지정도 가능)
28
+ - 리드인이 140자를 넘으면 독립 문단으로 취급된다 — 도입을 이미지에 얹히게
29
+ 하려면 짧게, 별도 문단으로 읽히게 하려면 길게
30
+
31
+ ## 3. 화자 표기
32
+
33
+ - 지문 속 인물 대사는 `인물명: "대사"` 형식이 아닌 **발화 블록**(변환기가
34
+ character_dialogue로 인식하는 형식)으로 — 그래야 화자명·초상 연출이 붙는다
35
+ - 초상이 있는 인물: 카르밀라·아버지·마드모아젤·마담 (추가 시 개발팀에 전달)
36
+
37
+ ## 4. 장소 태그가 배경을 결정한다
38
+
39
+ - `#장소/X` 태그의 첫 항목이 그 카드의 배경 이미지를 고른다
40
+ (현재 매핑: 침실·만찬홀·숲길·해자·폐허·예배당 — 정원·화랑·서재는 배경 이미지 대기 중)
41
+ - 장소가 바뀌는 카드에는 태그를 꼭 갱신할 것 — 없으면 직전 배경이 이어진다
docs/contract/demo_api.md CHANGED
@@ -15,14 +15,22 @@
15
  "episode_title": "제1화: 어린 밤의 손님"},
16
  "narration": [
17
  {"type": "prose|dialogue_center|character_dialogue|stage_direction",
18
- "text": "...", "speaker": ""} // speaker는 character_dialogue만
19
- ],
 
 
 
 
20
  "choices": [{"label": "눈을 뜬다"}], // 없으면 []
21
  "can_chat": true, // 개입 포켓(자유채팅) 가능 페이지
22
  "image": "/assets/플래이 이미지/4.png", // 연출 컷 (없으면 null). 매핑: router IMAGE_MAP
 
 
23
  "progress": {"index": 5, "total": 44}, // 읽기 진행도 (페이지 단위)
24
  // 페이지 = 여러 카드 병합(services/paging.py, 결정적
25
- // 파티션 · 밀도는 chapter.page_target_chars 기본 500자).
 
 
26
  // card.id = 페이지 끝 카드(선택·채팅 기준),
27
  // title/episode = 첫 카드 기준.
28
  // 연출 컷은 단독 페이지 + 표시 2페이지(컷+본문) —
@@ -49,7 +57,7 @@
49
  | `POST /api/session/{sid}/pocket/open` | `{}` | `{"opened": true}` | 현재 카드에서 채팅 시작. can_chat=false면 409 |
50
  | `POST /api/session/{sid}/pocket/turn` | `{"text": "..."}` | `PocketTurn` | converged=true면 자동 close(커밋) 완료 상태 |
51
  | `POST /api/session/{sid}/pocket/close` | `{}` | `{"closed": true}` | 유저가 먼저 대화를 접을 때 (커밋 포함) |
52
- | `GET /api/meta` | — | `{"cover_image","title","subtitle"}` | 표지 프레젠테 상수 |
53
  | `GET /assets/…` | — | 이미지 파일 | 연출 에셋 (대표님 산출물, 읽기 전용) |
54
 
55
  오류: 존재하지 않는 세션 **또는 콘텐츠 갱신으로 무효화된 세션** 404 ·
 
15
  "episode_title": "제1화: 어린 밤의 손님"},
16
  "narration": [
17
  {"type": "prose|dialogue_center|character_dialogue|stage_direction",
18
+ "text": "...", "speaker": "", // speaker는 character_dialogue만
19
+ "role": "lead_in|aftermath|null", // 화면 배치 역할 — lead_in(발화 직전 짧은 도입)은
20
+ // 스테이지 화면 상단에 얹힌다 (유도: 적재 시 규칙)
21
+ "portrait": "/assets/인물/카르밀라.png"} // 화자 초상 (router SPEAKER_PORTRAIT,
22
+ ], // 없으면 null → 이름표만. 유저·로라는 프론트가
23
+ // 1인칭(오른쪽 정렬)으로 처리
24
  "choices": [{"label": "눈을 뜬다"}], // 없으면 []
25
  "can_chat": true, // 개입 포켓(자유채팅) 가능 페이지
26
  "image": "/assets/플래이 이미지/4.png", // 연출 컷 (없으면 null). 매핑: router IMAGE_MAP
27
+ "background": "/assets/배경/침실.png", // 장소 배경 (카드 #장소/ 태그 → router BACKGROUND_MAP,
28
+ // 매핑 없는 장소는 직전 배경 유지 · 엔딩은 null)
29
  "progress": {"index": 5, "total": 44}, // 읽기 진행도 (페이지 단위)
30
  // 페이지 = 여러 카드 병합(services/paging.py, 결정적
31
+ // 파티션 · run별 균형 분할, 목표 chapter.page_target_chars
32
+ // 기본 500자). card.title은 참고용 — 클라이언트는 카드
33
+ // 소제목을 표시하지 않는다(에피소드 헤더·컷 캡션만).
34
  // card.id = 페이지 끝 카드(선택·채팅 기준),
35
  // title/episode = 첫 카드 기준.
36
  // 연출 컷은 단독 페이지 + 표시 2페이지(컷+본문) —
 
57
  | `POST /api/session/{sid}/pocket/open` | `{}` | `{"opened": true}` | 현재 카드에서 채팅 시작. can_chat=false면 409 |
58
  | `POST /api/session/{sid}/pocket/turn` | `{"text": "..."}` | `PocketTurn` | converged=true면 자동 close(커밋) 완료 상태 |
59
  | `POST /api/session/{sid}/pocket/close` | `{}` | `{"closed": true}` | 유저가 먼저 대화를 접을 때 (커밋 포함) |
60
+ | `GET /api/meta` | — | `{"cover_image","title","subtitle","intro_images"}` | 표지·인트로 시퀀스(작품/인물 소개·1장 시작 — 새 시작에만 표시, 읽기는 건너뜀) |
61
  | `GET /assets/…` | — | 이미지 파일 | 연출 에셋 (대표님 산출물, 읽기 전용) |
62
 
63
  오류: 존재하지 않는 세션 **또는 콘텐츠 갱신으로 무효화된 세션** 404 ·
engine/__pycache__/router.cpython-312.pyc CHANGED
Binary files a/engine/__pycache__/router.cpython-312.pyc and b/engine/__pycache__/router.cpython-312.pyc differ
 
engine/__pycache__/router.cpython-314.pyc CHANGED
Binary files a/engine/__pycache__/router.cpython-314.pyc and b/engine/__pycache__/router.cpython-314.pyc differ
 
engine/repositories/__pycache__/content.cpython-312.pyc CHANGED
Binary files a/engine/repositories/__pycache__/content.cpython-312.pyc and b/engine/repositories/__pycache__/content.cpython-312.pyc differ
 
engine/repositories/__pycache__/content.cpython-314.pyc CHANGED
Binary files a/engine/repositories/__pycache__/content.cpython-314.pyc and b/engine/repositories/__pycache__/content.cpython-314.pyc differ
 
engine/repositories/content.py CHANGED
@@ -12,7 +12,7 @@ from __future__ import annotations
12
  import hashlib
13
  from pathlib import Path
14
 
15
- from engine.schemas.beatcard import BeatCard, RCard
16
  from engine.schemas.character import CharacterSheet
17
  from engine.schemas.chapter import Chapter
18
  from engine.schemas.validate import check_links, load_content
@@ -45,6 +45,7 @@ class ContentRepository:
45
  for ep in episodes:
46
  self.episode_entry[ep.episode.id] = ep.cards[0].id
47
  for card in ep.cards:
 
48
  self._cards[card.id] = card
49
  self._order[card.id] = i
50
  i += 1
 
12
  import hashlib
13
  from pathlib import Path
14
 
15
+ from engine.schemas.beatcard import BeatCard, RCard, derive_roles
16
  from engine.schemas.character import CharacterSheet
17
  from engine.schemas.chapter import Chapter
18
  from engine.schemas.validate import check_links, load_content
 
45
  for ep in episodes:
46
  self.episode_entry[ep.episode.id] = ep.cards[0].id
47
  for card in ep.cards:
48
+ derive_roles(card.narration) # role 미지정 블록에 유도 규칙 (명시값 존중)
49
  self._cards[card.id] = card
50
  self._order[card.id] = i
51
  i += 1
engine/router.py CHANGED
@@ -159,6 +159,7 @@ def _step_json(ps: PlaySession) -> dict:
159
  "card": {"id": last.id, "title": first.title, "episode": first.episode_id,
160
  "episode_title": ps.episode_titles.get(first.episode_id, first.episode_id)},
161
  "narration": [{"type": b.type, "text": b.text, "speaker": b.speaker,
 
162
  "portrait": (SPEAKER_PORTRAIT.get(b.speaker)
163
  if b.type == "character_dialogue" else None)}
164
  for b in page.narration],
 
159
  "card": {"id": last.id, "title": first.title, "episode": first.episode_id,
160
  "episode_title": ps.episode_titles.get(first.episode_id, first.episode_id)},
161
  "narration": [{"type": b.type, "text": b.text, "speaker": b.speaker,
162
+ "role": b.role,
163
  "portrait": (SPEAKER_PORTRAIT.get(b.speaker)
164
  if b.type == "character_dialogue" else None)}
165
  for b in page.narration],
engine/schemas/__pycache__/beatcard.cpython-312.pyc CHANGED
Binary files a/engine/schemas/__pycache__/beatcard.cpython-312.pyc and b/engine/schemas/__pycache__/beatcard.cpython-312.pyc differ
 
engine/schemas/__pycache__/beatcard.cpython-314.pyc CHANGED
Binary files a/engine/schemas/__pycache__/beatcard.cpython-314.pyc and b/engine/schemas/__pycache__/beatcard.cpython-314.pyc differ
 
engine/schemas/__pycache__/validate.cpython-312.pyc CHANGED
Binary files a/engine/schemas/__pycache__/validate.cpython-312.pyc and b/engine/schemas/__pycache__/validate.cpython-312.pyc differ
 
engine/schemas/__pycache__/validate.cpython-314.pyc CHANGED
Binary files a/engine/schemas/__pycache__/validate.cpython-314.pyc and b/engine/schemas/__pycache__/validate.cpython-314.pyc differ
 
engine/schemas/beatcard.py CHANGED
@@ -19,6 +19,16 @@ Axis = Literal["trust", "doubt", "neutral"]
19
  NARRATION_TYPES = ("prose", "dialogue_center", "stage_direction", "character_dialogue")
20
 
21
 
 
 
 
 
 
 
 
 
 
 
22
  class NarrationBlock(BaseModel):
23
  """고정 지문 블록. gate가 있으면 신뢰 레벨에 따라 노출이 갈린다."""
24
 
@@ -29,6 +39,16 @@ class NarrationBlock(BaseModel):
29
  speaker: str = ""
30
  anchor: bool = False # True = 개입 포켓에서 LLM 변주 대상 앵커 대사
31
  gate: Literal["all", "trust_high"] = "all" # 〈표면·전원〉 / 〈심층·신뢰高 해금〉
 
 
 
 
 
 
 
 
 
 
32
 
33
 
34
  class Choice(BaseModel):
 
19
  NARRATION_TYPES = ("prose", "dialogue_center", "stage_direction", "character_dialogue")
20
 
21
 
22
+ # 발화 직전의 이 길이 이하 prose는 리드인 — 렌더가 스테이지 화면 상단에 얹는다
23
+ LEAD_IN_MAX_CHARS = 140
24
+
25
+
26
+ def is_lead_in(block_type: str, text: str, next_type: str) -> bool:
27
+ """리드인 유도 규칙 (단일 권위 — 변환기·적재가 공유)."""
28
+ return (block_type == "prose" and len(text) <= LEAD_IN_MAX_CHARS
29
+ and next_type == "character_dialogue")
30
+
31
+
32
  class NarrationBlock(BaseModel):
33
  """고정 지문 블록. gate가 있으면 신뢰 레벨에 따라 노출이 갈린다."""
34
 
 
39
  speaker: str = ""
40
  anchor: bool = False # True = 개입 포켓에서 LLM 변주 대상 앵커 대사
41
  gate: Literal["all", "trust_high"] = "all" # 〈표면·전원〉 / 〈심층·신뢰高 해금〉
42
+ # 화면 배치 역할 (연출 구조화). 미지정이면 적재 시 유도 규칙(derive_roles) 적용:
43
+ # lead_in = 발화 직전의 짧은 도입 prose (스테이지 화면에 얹힘) · aftermath = 예약
44
+ role: Optional[Literal["lead_in", "aftermath"]] = None
45
+
46
+
47
+ def derive_roles(narration: list[NarrationBlock]) -> None:
48
+ """role 미지정 블록에 유도 규칙 적용 (제자리 갱신 — 명시 role은 존중)."""
49
+ for i, b in enumerate(narration[:-1]):
50
+ if b.role is None and is_lead_in(b.type, b.text, narration[i + 1].type):
51
+ b.role = "lead_in"
52
 
53
 
54
  class Choice(BaseModel):
engine/schemas/validate.py CHANGED
@@ -119,6 +119,15 @@ def check_links(episodes: list[EpisodeFile], chapter, r_cards) -> tuple[list[str
119
  if not has_out:
120
  warnings.append(f"{c.id}: 나가는 링크 없음 (챕터 출구가 아니면 결함)")
121
 
 
 
 
 
 
 
 
 
 
122
  return errors, warnings
123
 
124
 
 
119
  if not has_out:
120
  warnings.append(f"{c.id}: 나가는 링크 없음 (챕터 출구가 아니면 결함)")
121
 
122
+ # 빈약 카드 — 화면 하나의 최소 밀도 미달 (작성 가이드: docs/contract/beatcard_guide.md)
123
+ MIN_CARD_CHARS = 200
124
+ for c in cards.values():
125
+ total = sum(len(b.text) for b in c.narration)
126
+ if total < MIN_CARD_CHARS:
127
+ warnings.append(
128
+ f"{c.id}: 지문 {total}자 — 화면 최소 권장({MIN_CARD_CHARS}자) 미달. "
129
+ f"보강하거나 이웃 카드와의 병합을 검토 (대표님 판단)")
130
+
131
  return errors, warnings
132
 
133
 
engine/services/__pycache__/paging.cpython-312.pyc CHANGED
Binary files a/engine/services/__pycache__/paging.cpython-312.pyc and b/engine/services/__pycache__/paging.cpython-312.pyc differ
 
engine/services/__pycache__/paging.cpython-314.pyc CHANGED
Binary files a/engine/services/__pycache__/paging.cpython-314.pyc and b/engine/services/__pycache__/paging.cpython-314.pyc differ
 
engine/services/paging.py CHANGED
@@ -6,11 +6,12 @@
6
  (카드당 정확히 1회 — 이중/누락 적용 금지)
7
  - 파티션은 콘텐츠(전역 카드 순서)의 순수 함수 — 유저 경로·상태와 무관해
8
  페이지 총수(진행도 분모)가 안정적이고, 복원 시에도 같은 페이지가 재구성된다
9
- - 병합은 선형 링크(card.next == 전역 다음 카드)로 이어진 구간에서만. 강제 분리:
10
  · 선택지/분기(next_options) 카드 뒤 (결정 지점)
11
  · solo 카드(연출 컷·채팅 앵커— 호출자가 지정) 앞뒤
12
  · 에피소드 경계
13
- - 소프트 : 누적 글자 chapter.page_target_chars 상이면 다음 카드부터 페이지
 
14
  """
15
 
16
  from __future__ import annotations
@@ -47,20 +48,23 @@ class Pager:
47
 
48
  # ── 정적 파티션 ─────────────────────────────────────────
49
  def _build(self) -> None:
50
- cards = self.repo.all_cards()
51
- parts: list[list[str]] = []
52
- cur: list[str] = []
53
- cur_chars = 0
54
  prev: Optional[BeatCard] = None
55
- for card in cards:
56
- if cur and self._break_between(prev, card, cur_chars):
57
- parts.append(cur)
58
- cur, cur_chars = [], 0
59
- cur.append(card.id)
60
- cur_chars += sum(len(b.text) for b in card.narration)
61
  prev = card
62
  if cur:
63
- parts.append(cur)
 
 
 
 
 
64
 
65
  self.partitions = parts
66
  self.part_of = {cid: i for i, part in enumerate(parts) for cid in part}
@@ -73,16 +77,48 @@ class Pager:
73
  seen += 1
74
  self._total = len(parts) + seen
75
 
76
- def _break_between(self, prev: BeatCard, card: BeatCard, cur_chars: int) -> bool:
77
  if prev.choices or prev.next_options:
78
  return True # 결정 지점 — 페이지는 선택지로 닫힌다
79
  if prev.next != card.id:
80
  return True # 비선형(분기 변형·점프·에피소드 참조) — 경로 안전을 위해 분리
81
  if prev.id in self.solo or card.id in self.solo:
82
  return True
83
- if prev.episode_id != card.episode_id:
84
- return True
85
- return cur_chars >= self.target
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
 
87
  # ── 표시 번호 (진행도) ──────────────────────────────────
88
  @property
 
6
  (카드당 정확히 1회 — 이중/누락 적용 금지)
7
  - 파티션은 콘텐츠(전역 카드 순서)의 순수 함수 — 유저 경로·상태와 무관해
8
  페이지 총수(진행도 분모)가 안정적이고, 복원 시에도 같은 페이지가 재구성된다
9
+ - 병합은 선형 링크(card.next == 전역 다음 카드)로 이어진 구간(run)에서만. 강제 분리:
10
  · 선택지/분기(next_options) 카드 뒤 (결정 지점)
11
  · solo 카드(연출 컷·채팅 앵커— 호출자가 지정) 앞뒤
12
  · 에피소드 경계
13
+ - 밀도 균형: run별 글자수 k = round(총/target)를 먼저 정하고,
14
+ 누적 글자수가 균등 경계(총·i/k)에 가장 가까운 카드 뒤에서 절단 — 그리디 과적 방지
15
  """
16
 
17
  from __future__ import annotations
 
48
 
49
  # ── 정적 파티션 ─────────────────────────────────────────
50
  def _build(self) -> None:
51
+ # 1) 구조적 브레이크로 run(병합 가능한 최대 구간)을 만들고
52
+ runs: list[list[BeatCard]] = []
53
+ cur: list[BeatCard] = []
 
54
  prev: Optional[BeatCard] = None
55
+ for card in self.repo.all_cards():
56
+ if cur and self._structural_break(prev, card):
57
+ runs.append(cur)
58
+ cur = []
59
+ cur.append(card)
 
60
  prev = card
61
  if cur:
62
+ runs.append(cur)
63
+
64
+ # 2) run별로 균형 분할
65
+ parts: list[list[str]] = []
66
+ for run in runs:
67
+ parts += [[c.id for c in part] for part in self._split_run(run)]
68
 
69
  self.partitions = parts
70
  self.part_of = {cid: i for i, part in enumerate(parts) for cid in part}
 
77
  seen += 1
78
  self._total = len(parts) + seen
79
 
80
+ def _structural_break(self, prev: BeatCard, card: BeatCard) -> bool:
81
  if prev.choices or prev.next_options:
82
  return True # 결정 지점 — 페이지는 선택지로 닫힌다
83
  if prev.next != card.id:
84
  return True # 비선형(분기 변형·점프·에피소드 참조) — 경로 안전을 위해 분리
85
  if prev.id in self.solo or card.id in self.solo:
86
  return True
87
+ return prev.episode_id != card.episode_id
88
+
89
+ @staticmethod
90
+ def _chars(card: BeatCard) -> int:
91
+ return sum(len(b.text) for b in card.narration)
92
+
93
+ def _split_run(self, run: list[BeatCard]) -> list[list[BeatCard]]:
94
+ """run을 k = round(총 글자수/target)개의 연속 구간으로 균등 분할 (결정적)."""
95
+ total = sum(self._chars(c) for c in run)
96
+ k = max(1, min(len(run), round(total / self.target)))
97
+ if k == 1:
98
+ return [run]
99
+ bounds = [total * i / k for i in range(1, k)] # 균등 절단 목표점
100
+ parts: list[list[BeatCard]] = []
101
+ cur: list[BeatCard] = []
102
+ acc = 0
103
+ bi = 0
104
+ for idx, card in enumerate(run):
105
+ cur.append(card)
106
+ acc += self._chars(card)
107
+ remaining_cards = len(run) - idx - 1
108
+ remaining_cuts = (k - 1) - bi
109
+ if bi >= len(bounds) or remaining_cards == 0:
110
+ continue
111
+ next_acc = acc + self._chars(run[idx + 1])
112
+ # 지금 자르는 게 다음 카드까지 끌고 가는 것보다 경계에 가깝거나,
113
+ # 남은 절단 수만큼의 카드밖에 안 남았으면 여기서 절단
114
+ if (abs(acc - bounds[bi]) <= abs(next_acc - bounds[bi])
115
+ or remaining_cards == remaining_cuts):
116
+ parts.append(cur)
117
+ cur = []
118
+ bi += 1
119
+ if cur:
120
+ parts.append(cur)
121
+ return parts
122
 
123
  # ── 표시 번호 (진행도) ──────────────────────────────────
124
  @property
scripts/beatcard_convert.py CHANGED
@@ -278,6 +278,11 @@ class EpisodeParser:
278
 
279
  flush_prose()
280
  close_result()
 
 
 
 
 
281
  # 스키마 기본값과 같은 키 제거 없이 그대로 두되, speaker/anchor 없는 블록은 기본값 생략
282
  narration = [
283
  {k: v for k, v in b.items() if not (k == "speaker" and not v)
 
278
 
279
  flush_prose()
280
  close_result()
281
+ # 화면 배치 역할 유도 — 규칙 권위는 engine.schemas.beatcard.is_lead_in (적재 시에도 동일 적용)
282
+ from engine.schemas.beatcard import is_lead_in
283
+ for i, b in enumerate(narration[:-1]):
284
+ if "role" not in b and is_lead_in(b["type"], b["text"], narration[i + 1]["type"]):
285
+ b["role"] = "lead_in"
286
  # 스키마 기본값과 같은 키 제거 없이 그대로 두되, speaker/anchor 없는 블록은 기본값 생략
287
  narration = [
288
  {k: v for k, v in b.items() if not (k == "speaker" and not v)
tests/__init__.py ADDED
File without changes
tests/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (153 Bytes). View file
 
tests/__pycache__/fakes.cpython-314.pyc ADDED
Binary file (3.43 kB). View file
 
tests/__pycache__/test_integration.cpython-314-pytest-9.1.1.pyc ADDED
Binary file (14.2 kB). View file
 
tests/__pycache__/test_paging.cpython-314-pytest-9.1.1.pyc ADDED
Binary file (43.9 kB). View file
 
tests/__pycache__/test_persistence.cpython-314-pytest-9.1.1.pyc ADDED
Binary file (29.3 kB). View file
 
tests/__pycache__/test_pocket.cpython-314-pytest-9.1.1.pyc ADDED
Binary file (19.3 kB). View file
 
tests/__pycache__/test_story.cpython-314-pytest-9.1.1.pyc ADDED
Binary file (20.8 kB). View file
 
tests/fakes.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """테스트 더블 — ScriptedLlm: 정해진 응답을 순서대로 내놓는 BaseLlm.
2
+
3
+ LLM 호출 없이 파이프라인(가드→캐릭터→디렉터→도구→state)을 결정적으로 검증한다.
4
+ 프로덕션 경로는 LiteLlm(engine/model.py) 그대로이며, 이 더블은 tests/ 전용이다.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import AsyncGenerator
10
+
11
+ from google.adk.models.base_llm import BaseLlm
12
+ from google.adk.models.llm_request import LlmRequest
13
+ from google.adk.models.llm_response import LlmResponse
14
+ from google.genai import types
15
+ from pydantic import ConfigDict
16
+
17
+
18
+ def text_response(text: str) -> LlmResponse:
19
+ return LlmResponse(content=types.Content(role="model", parts=[types.Part(text=text)]))
20
+
21
+
22
+ def tool_call(name: str, **args) -> types.Part:
23
+ return types.Part(function_call=types.FunctionCall(name=name, args=args))
24
+
25
+
26
+ def tool_response(*parts: types.Part) -> LlmResponse:
27
+ return LlmResponse(content=types.Content(role="model", parts=list(parts)))
28
+
29
+
30
+ class ScriptedLlm(BaseLlm):
31
+ model_config = ConfigDict(arbitrary_types_allowed=True)
32
+
33
+ model: str = "scripted"
34
+ script: list[LlmResponse] = []
35
+ requests: list[LlmRequest] = [] # 검증용: 실제로 주입된 instruction 확인
36
+
37
+ async def generate_content_async(
38
+ self, llm_request: LlmRequest, stream: bool = False
39
+ ) -> AsyncGenerator[LlmResponse, None]:
40
+ self.requests.append(llm_request)
41
+ if not self.script:
42
+ yield text_response("(대본 소진)")
43
+ return
44
+ yield self.script.pop(0)
tests/test_integration.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """M4 전체 관통 — 읽기 루프 → 개입 포켓(채팅) → 복귀 → 엔딩까지 한 번에."""
2
+
3
+ import json
4
+
5
+ import pytest
6
+
7
+ from engine.plugins.logging import TurnLoggingPlugin
8
+ from engine.repositories.content import ContentRepository
9
+ from engine.repositories.playlog import PlaylogRepository
10
+ from engine.services.pocket import PocketService
11
+ from engine.services.state import AXIS_DELTA
12
+ from engine.services.story import StoryEngine
13
+ from tests.fakes import ScriptedLlm, text_response, tool_call, tool_response
14
+
15
+
16
+ @pytest.fixture(scope="module")
17
+ def repo():
18
+ return ContentRepository("content")
19
+
20
+
21
+ @pytest.mark.asyncio
22
+ async def test_full_playthrough_with_pocket(repo, tmp_path):
23
+ """E06-09까지 읽고 → 포켓 채팅 2턴(수렴) → 상태 커밋 → 이어서 엔딩 도달."""
24
+ playlog = PlaylogRepository(tmp_path / "playlog.jsonl")
25
+ svc = PocketService(
26
+ repo,
27
+ plugins=[TurnLoggingPlugin(playlog)],
28
+ character_model=ScriptedLlm(script=[
29
+ text_response("…그래. 너와 나 사이의 일이야, 로라."),
30
+ text_response("착하지. 우리끼리만 간직하자."),
31
+ text_response("…이제 됐어. 아무 말도 필요 없어."),
32
+ ]),
33
+ director_model=ScriptedLlm(script=[
34
+ tool_response(tool_call("update_trust", direction="trust")),
35
+ text_response("계속"),
36
+ tool_response(tool_call("update_trust", direction="trust"),
37
+ tool_call("mark_beat", beat="둘만의 비밀 동조")),
38
+ text_response("계속"),
39
+ tool_response(tool_call("finish_pocket", reason="goal_reached")),
40
+ text_response("수렴"),
41
+ ]),
42
+ )
43
+
44
+ # 1) 읽기 루프: E06-09까지 신뢰 성향으로 진행
45
+ engine = StoryEngine(repo)
46
+ step = engine.start()
47
+ while engine.current_id != "E06-09":
48
+ if step.choices:
49
+ idx = next((i for i, c in enumerate(step.choices) if c.axis == "trust"),
50
+ next((i for i, c in enumerate(step.choices)
51
+ if c.axis in (None, "neutral")), 0))
52
+ _, step = engine.choose(idx)
53
+ else:
54
+ step = engine.advance()
55
+ trust_before = engine.state.trust_score
56
+
57
+ # 2) 개입 포켓: 자유채팅 3턴 → director 수렴 (최소 체류 3턴)
58
+ sid = await svc.open("E06-09", engine.state)
59
+ t1 = await svc.run_turn(sid, "…우리 둘만의 일이라는 거지?")
60
+ assert not t1.converged and "로라" in t1.reply
61
+ t2 = await svc.run_turn(sid, "알았어. 아무에게도 말하지 않을게.")
62
+ assert not t2.converged # 최소 체류 턴 미달
63
+ t3 = await svc.run_turn(sid, "…응. 약속할게.")
64
+ assert t3.converged and t3.reason == "goal_reached"
65
+
66
+ # 3) 복귀: 숨은 상태 커밋 (채팅 2턴 = trust +2×AXIS_DELTA)
67
+ log = await svc.close(sid, engine.state)
68
+ assert engine.state.trust_score == trust_before + 2 * AXIS_DELTA
69
+ assert log.beats_hit == ["둘만의 비밀 동조"]
70
+
71
+ # 4) 이어서 읽기 → 엔딩 (신뢰 성향 유지 → 영원)
72
+ while step.ending is None:
73
+ if step.choices:
74
+ idx = next((i for i, c in enumerate(step.choices) if c.axis == "trust"),
75
+ next((i for i, c in enumerate(step.choices)
76
+ if c.axis in (None, "neutral")), 0))
77
+ _, step = engine.choose(idx)
78
+ else:
79
+ step = engine.advance()
80
+ assert step.ending.id == "ending_eternal"
81
+ assert "E06-10" in engine.visited # 도달점은 채팅과 무관하게 통과
82
+
83
+ # 5) 턴 로그: 유저 입력/이벤트/턴 요약이 기록됨 (연구 데이터)
84
+ records = [json.loads(l) for l in (tmp_path / "playlog.jsonl").read_text().splitlines()]
85
+ kinds = {r["kind"] for r in records}
86
+ assert {"user_input", "event", "turn_summary"} <= kinds
87
+ tool_events = [r for r in records if r.get("tool_calls")]
88
+ assert any(c["tool"] == "finish_pocket" for r in tool_events for c in r["tool_calls"])
tests/test_paging.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """페이지 병합(Pager) 회귀 — 파티션 불변식 · 숨은 상태 등가성 · 복원 렌더.
2
+
3
+ 핵심 회귀: 병합해도 카드당 부수효과(쇠약도/신뢰축)는 정확히 1회 — 카드 단위 순수 걷기와
4
+ 최종 숨은 상태·엔딩이 완전히 일치해야 한다.
5
+ """
6
+
7
+ import pytest
8
+
9
+ from engine import router
10
+ from engine.repositories.content import ContentRepository
11
+ from engine.repositories.play_session import PlaySessionRepository
12
+ from engine.services.paging import Pager
13
+ from engine.services.story import StoryEngine
14
+
15
+
16
+ @pytest.fixture(scope="module")
17
+ def repo():
18
+ return ContentRepository("content")
19
+
20
+
21
+ @pytest.fixture(scope="module")
22
+ def pager(repo):
23
+ cuts = {cid for cid in router.IMAGE_MAP if repo.has_card(cid)}
24
+ solo = cuts | {c.id for c in repo.all_cards() if c.can_chat}
25
+ return Pager(repo, solo_ids=solo, cut_ids=cuts)
26
+
27
+
28
+ def pick(choices, policy):
29
+ idx = next((i for i, c in enumerate(choices) if c.axis == policy), None)
30
+ if idx is None:
31
+ idx = next((i for i, c in enumerate(choices) if c.axis in (None, "neutral")), 0)
32
+ return idx
33
+
34
+
35
+ def play_paged(repo, pager, policy):
36
+ """라우터와 동일한 페이지 걷기. (engine, [PageView…]) 반환."""
37
+ engine = StoryEngine(repo)
38
+ pages = [pager.complete(engine, engine.start())]
39
+ while pages[-1].ending is None:
40
+ page = pages[-1]
41
+ if page.choices:
42
+ _, step = engine.choose(pick(page.choices, policy))
43
+ else:
44
+ step = engine.advance()
45
+ pages.append(pager.complete(engine, step))
46
+ return engine, pages
47
+
48
+
49
+ def play_cards(repo, policy):
50
+ """카드 단위 순수 걷기 (기준 대조군)."""
51
+ engine = StoryEngine(repo)
52
+ step = engine.start()
53
+ while step.ending is None:
54
+ if step.choices:
55
+ _, step = engine.choose(pick(step.choices, policy))
56
+ else:
57
+ step = engine.advance()
58
+ return engine
59
+
60
+
61
+ # ── 파티션 불변식 ───────────────────────────────────────────
62
+ def test_partitions_cover_all_cards_once_in_order(repo, pager):
63
+ flat = [cid for part in pager.partitions for cid in part]
64
+ assert flat == [c.id for c in repo.all_cards()]
65
+
66
+
67
+ def test_partition_break_invariants(repo, pager):
68
+ for part in pager.partitions:
69
+ cards = [repo.get_card(cid) for cid in part]
70
+ for a, b in zip(cards, cards[1:]):
71
+ # 페이지 내부는 선형 링크 + 결정 지점/에피소드 경계 없음
72
+ assert a.next == b.id
73
+ assert not a.choices and not a.next_options
74
+ assert a.episode_id == b.episode_id
75
+ # solo(연출 컷·채팅 앵커)는 단독 페이지
76
+ for c in cards:
77
+ if c.id in pager.solo:
78
+ assert len(part) == 1
79
+
80
+
81
+ def test_balanced_density_band(repo, pager):
82
+ """균형 분할 — 병합(멀티카드) 페이지는 목표 대비 과적/빈약 없이 밴드 안에 든다."""
83
+ def chars(part):
84
+ return sum(len(b.text) for cid in part for b in repo.get_card(cid).narration)
85
+ multi = [chars(p) for p in pager.partitions if len(p) > 1]
86
+ assert multi, "병합 페이지가 있어야 한다"
87
+ assert max(multi) <= pager.target * 1.7 # 그리디 과적(기존 964자) 재발 방지
88
+ assert min(multi) >= pager.target * 0.55 # 파편 병합 실패 방지
89
+
90
+
91
+ def test_merging_actually_reduces_pages(repo, pager):
92
+ assert len(pager.partitions) < repo.total_cards # 61보다 적어야 병합 의미가 있다
93
+ assert pager.display_total == len(pager.partitions) + len(pager.cuts & set(pager.part_of))
94
+
95
+
96
+ # ── 숨은 상태 등가성 (핵심 회귀) ────────────────────────────
97
+ @pytest.mark.parametrize("policy", ["trust", "doubt", "neutral"])
98
+ def test_paged_walk_equals_card_walk(repo, pager, policy):
99
+ paged, _ = play_paged(repo, pager, policy)
100
+ plain = play_cards(repo, policy)
101
+ assert paged.ending.id == plain.ending.id
102
+ assert paged.state.snapshot() == plain.state.snapshot() # 부수효과 정확히 1회
103
+ assert paged.visited == plain.visited
104
+
105
+
106
+ # ── 진행도 표시 ─────────────────────────────────────────────
107
+ def test_display_index_monotonic_over_playthrough(repo, pager):
108
+ engine, pages = play_paged(repo, pager, "trust")
109
+ seen = [pager.display_index(p.last.id) for p in pages if p.ending is None]
110
+ assert seen == sorted(seen) # 진행도는 절대 뒤로 가지 않는다
111
+ assert seen[0] == 1
112
+ assert seen[-1] <= pager.display_total
113
+
114
+
115
+ # ── 복원 렌더 ───────────────────────────────────────────────
116
+ def test_render_current_matches_played_page(repo, pager):
117
+ mid = StoryEngine(repo)
118
+ mid_pages = [pager.complete(mid, mid.start())]
119
+ for _ in range(3):
120
+ mid_pages.append(pager.complete(mid, mid.advance() if not mid_pages[-1].choices
121
+ else mid.choose(pick(mid_pages[-1].choices, "trust"))[1]))
122
+ restored = StoryEngine.restore(repo, mid.snapshot())
123
+ view = pager.render_current(restored)
124
+ last_seen = mid_pages[-1]
125
+ assert [b.text for b in view.narration] == [b.text for b in last_seen.narration]
126
+ assert view.first.id == last_seen.first.id and view.last.id == last_seen.last.id
127
+
128
+
129
+ # ── 블록 역할 유도 (lead_in) ────────────────────────────────
130
+ def test_lead_in_roles_derived_on_load(repo):
131
+ """발화 직전의 짧은 prose는 lead_in — E04-10 '한 문장 페이지' 회귀."""
132
+ card = repo.get_card("E04-10")
133
+ assert card.narration[0].type == "prose"
134
+ assert card.narration[0].role == "lead_in" # 39자 리드인
135
+ assert card.narration[2].role is None # 여파 prose는 미지정
136
+
137
+ # 긴 prose(140자 초과)가 발화 앞에 있어도 리드인이 아니다 — E01-03 첫 블록
138
+ long_case = repo.get_card("E01-03")
139
+ assert long_case.narration[0].type == "prose"
140
+ assert len(long_case.narration[0].text) > 140
141
+ assert long_case.narration[1].type == "character_dialogue"
142
+ assert long_case.narration[0].role is None
143
+
144
+
145
+ def test_thin_card_warnings():
146
+ """C안 — 화면 최소 밀도 미달 카드는 validate 경고에 잡힌다."""
147
+ from pathlib import Path
148
+
149
+ from engine.schemas.validate import check_links, load_content
150
+ episodes, r_cards, chapter, *_ = load_content(Path("content"))
151
+ _, warnings = check_links(episodes, chapter, r_cards)
152
+ flagged = [w for w in warnings if "미달" in w]
153
+ assert any(w.startswith("E05-05") for w in flagged) # 96자 채팅 카드
154
+ assert any(w.startswith("E06-10C") for w in flagged) # 154자 분기 변형
155
+
156
+
157
+ # ── 라우터 레벨 ─────────────────────────────────────────────
158
+ @pytest.mark.asyncio
159
+ async def test_background_follows_place_tags_and_meta_has_intro(tmp_path, monkeypatch):
160
+ """장소 태그 → 배경 매핑이 완주 동안 반영되고, 인트로 시퀀스가 메타에 실린다."""
161
+ monkeypatch.setattr(router, "_playdb", PlaySessionRepository(tmp_path / "s.db"))
162
+ monkeypatch.setattr(router, "_sessions", {})
163
+ meta = await router.meta()
164
+ assert meta["intro_images"] == router.INTRO_IMAGES and len(meta["intro_images"]) == 3
165
+
166
+ data = await router.create_session()
167
+ sid, step = data["session_id"], data["step"]
168
+ seen = {step["background"]}
169
+ portraits = set()
170
+ while step["ending"] is None:
171
+ portraits |= {n["portrait"] for n in step["narration"]
172
+ if n["type"] == "character_dialogue" and n["portrait"]}
173
+ if step["choices"]:
174
+ step = (await router.choose(sid, router.ChooseBody(index=0)))["step"]
175
+ else:
176
+ step = (await router.advance(sid))["step"]
177
+ seen.add(step["background"])
178
+ # 침실·만찬홀(식당)·숲길(안개 숲)은 어느 경로든 지나간다 + 엔딩은 배경 없음
179
+ assert {"/assets/배경/침실.png", "/assets/배경/식당.png", "/assets/배경/안개 숲.png"} <= seen
180
+ assert step["background"] is None
181
+ # 화자 초상 — 카르밀라·아버지는 어느 경로든 발화한다 (VN 발화 연출)
182
+ assert {"/assets/인물/카르밀라.png", "/assets/인물/아버지.png"} <= portraits
183
+
184
+
185
+ @pytest.mark.asyncio
186
+ async def test_router_serves_merged_pages(tmp_path, monkeypatch):
187
+ monkeypatch.setattr(router, "_playdb", PlaySessionRepository(tmp_path / "s.db"))
188
+ monkeypatch.setattr(router, "_sessions", {})
189
+ data = await router.create_session()
190
+ sid, step = data["session_id"], data["step"]
191
+ total = step["progress"]["total"]
192
+ assert total == router._pager.display_total
193
+ clicks = 1
194
+ while step["ending"] is None:
195
+ if step["choices"]:
196
+ step = (await router.choose(sid, router.ChooseBody(index=0)))["step"]
197
+ else:
198
+ step = (await router.advance(sid))["step"]
199
+ clicks += 1
200
+ assert clicks < 61 # 카드 수보다 적은 넘김으로 완주 = 병합 동작
tests/test_persistence.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """세션 영속화 회귀 — 스냅샷/복원 + SQLite 저장소 + 라우터 재시작 복원.
2
+
3
+ 핵심 회귀: 복원은 _enter()를 거치지 않는다 — 쇠약도/신뢰축이 재적용되면 결함.
4
+ """
5
+
6
+ import pytest
7
+ from fastapi import HTTPException
8
+
9
+ from engine import router
10
+ from engine.repositories.content import ContentRepository
11
+ from engine.repositories.play_session import PlaySessionRepository
12
+ from engine.services.story import StoryEngine
13
+
14
+
15
+ @pytest.fixture(scope="module")
16
+ def repo():
17
+ return ContentRepository("content")
18
+
19
+
20
+ def play_until(repo, stop_id: str | None, policy: str = "trust") -> StoryEngine:
21
+ """stop_id 카드 도달(또는 None이면 엔딩)까지 진행."""
22
+ engine = StoryEngine(repo)
23
+ step = engine.start()
24
+ while step.ending is None and engine.current_id != stop_id:
25
+ if step.choices:
26
+ idx = next((i for i, c in enumerate(step.choices) if c.axis == policy),
27
+ next((i for i, c in enumerate(step.choices)
28
+ if c.axis in (None, "neutral")), 0))
29
+ _, step = engine.choose(idx)
30
+ else:
31
+ step = engine.advance()
32
+ return engine
33
+
34
+
35
+ # ── 스냅샷/복원 (엔진) ──────────────────────────────────────
36
+ def test_snapshot_restore_roundtrip(repo):
37
+ engine = play_until(repo, "E04-01")
38
+ snap = engine.snapshot()
39
+ restored = StoryEngine.restore(repo, snap)
40
+
41
+ assert restored.snapshot() == snap # 복원이 상태를 조금도 바꾸지 않는다
42
+ assert restored.current_id == engine.current_id
43
+ assert restored.visited == engine.visited
44
+ step = restored.current_step()
45
+ assert step.card.id == engine.current_id
46
+ assert step.ending is None
47
+
48
+
49
+ def test_restore_does_not_reapply_enter_side_effects(repo):
50
+ """핵심 회귀 — 복원 시 쇠약도·신뢰축이 재적용되면 안 된다 (_enter 우회 검증)."""
51
+ engine = play_until(repo, None) # 엔딩까지 = 쇠약도·축 이벤트 다수 누적
52
+ assert engine.state.frailty > 0 and engine.state.trust_score > 0
53
+
54
+ snap = engine.snapshot()
55
+ restored = StoryEngine.restore(repo, snap)
56
+ assert restored.state.frailty == engine.state.frailty
57
+ assert restored.state.trust_score == engine.state.trust_score
58
+ assert restored.state.doubt_score == engine.state.doubt_score
59
+ assert restored.state.axis_log == engine.state.axis_log
60
+
61
+ # 여러 번 복원해도 동일 (재적용이 있으면 여기서 어긋난다)
62
+ again = StoryEngine.restore(repo, restored.snapshot())
63
+ assert again.snapshot() == snap
64
+
65
+
66
+ def test_ending_session_restores_to_ending(repo):
67
+ engine = play_until(repo, None, policy="trust")
68
+ assert engine.ending is not None
69
+ restored = StoryEngine.restore(repo, engine.snapshot())
70
+ step = restored.current_step()
71
+ assert step.ending is not None and step.ending.id == engine.ending.id
72
+
73
+
74
+ # ── SQLite 저장소 ───────────────────────────────────────────
75
+ def test_play_session_repo_save_load(tmp_path, repo):
76
+ db = PlaySessionRepository(tmp_path / "sessions.db")
77
+ engine = play_until(repo, "E02-01")
78
+ db.save("sid1", engine.snapshot(), repo.content_version)
79
+
80
+ loaded = db.load("sid1", repo.content_version)
81
+ assert loaded == engine.snapshot()
82
+ assert db.load("없는sid", repo.content_version) is None
83
+
84
+
85
+ def test_content_version_mismatch_invalidates(tmp_path, repo):
86
+ db = PlaySessionRepository(tmp_path / "sessions.db")
87
+ db.save("sid1", {"story": {}, "hidden": {}}, "구버전해시")
88
+ assert db.load("sid1", repo.content_version) is None # 콘텐츠 갱신 → 무효화
89
+
90
+
91
+ def test_purge_removes_stale_sessions(tmp_path):
92
+ db = PlaySessionRepository(tmp_path / "sessions.db")
93
+ db.save("sid1", {"story": {}, "hidden": {}}, "v")
94
+ assert db.purge(days=30) == 0 # 방금 저장 — 남는다
95
+ assert db.purge(days=0) == 1 # 즉시 만료 취급 — 지워진다
96
+
97
+
98
+ # ── 라우터 (재시작 모사) ────────────────────────────────────
99
+ @pytest.mark.asyncio
100
+ async def test_router_restores_after_restart(tmp_path, monkeypatch):
101
+ monkeypatch.setattr(router, "_playdb", PlaySessionRepository(tmp_path / "s.db"))
102
+ monkeypatch.setattr(router, "_sessions", {})
103
+
104
+ data = await router.create_session()
105
+ sid = data["session_id"]
106
+ step = data["step"]
107
+ while not step["choices"] and not step["ending"]: # 첫 선택지까지 읽기
108
+ step = (await router.advance(sid))["step"]
109
+ step = (await router.choose(sid, router.ChooseBody(index=0)))["step"]
110
+ current_card = step["card"]["id"]
111
+
112
+ router._sessions.clear() # 서버 재시작 모사 (인메모리 캐시 소실)
113
+ got = await router.get_session(sid)
114
+ assert got["step"]["card"]["id"] == current_card
115
+ assert router._sessions[sid].pocket_sid is None # 포켓은 비영속 — 닫힌 채 복원
116
+
117
+
118
+ @pytest.mark.asyncio
119
+ async def test_router_unknown_sid_404(tmp_path, monkeypatch):
120
+ monkeypatch.setattr(router, "_playdb", PlaySessionRepository(tmp_path / "s.db"))
121
+ monkeypatch.setattr(router, "_sessions", {})
122
+ with pytest.raises(HTTPException) as exc:
123
+ await router.get_session("없는세션")
124
+ assert exc.value.status_code == 404
tests/test_pocket.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """M3 개입 포켓 파이프라인 테스트 — ScriptedLlm으로 결정적 검증."""
2
+
3
+ import pytest
4
+
5
+ from engine.repositories.content import ContentRepository
6
+ from engine.services.pocket import PocketService
7
+ from engine.services.state import AXIS_DELTA, HiddenState
8
+ from tests.fakes import ScriptedLlm, text_response, tool_call, tool_response
9
+
10
+
11
+ @pytest.fixture(scope="module")
12
+ def repo():
13
+ return ContentRepository("content")
14
+
15
+
16
+ def make_service(repo, char_script, dir_script):
17
+ char_llm = ScriptedLlm(script=list(char_script))
18
+ dir_llm = ScriptedLlm(script=list(dir_script))
19
+ return PocketService(repo, character_model=char_llm, director_model=dir_llm), char_llm, dir_llm
20
+
21
+
22
+ @pytest.mark.asyncio
23
+ async def test_turn_pipeline_moves_trust_and_converges(repo):
24
+ """정상 턴: 캐릭터 응답 + director의 update_trust → state 반영, 수렴 재검증."""
25
+ svc, char_llm, _ = make_service(
26
+ repo,
27
+ char_script=[text_response("…그래, 로라. 우리끼리만 간직하자.")],
28
+ dir_script=[
29
+ tool_response(tool_call("update_trust", direction="trust"),
30
+ tool_call("mark_beat", beat="둘만의 비밀 동조")),
31
+ text_response("판정 완료"),
32
+ ],
33
+ )
34
+ hidden = HiddenState()
35
+ sid = await svc.open("E06-09", hidden)
36
+ turn = await svc.run_turn(sid, "네 말이 맞아. 우리 둘만의 비밀로 하자.")
37
+
38
+ assert "간직하자" in turn.reply
39
+ assert turn.guard_flag is None
40
+ assert not turn.converged # finish_pocket 미호출 → 수렴 아님
41
+
42
+ log = await svc.close(sid, hidden)
43
+ assert hidden.trust_score == AXIS_DELTA # 커밋 경로로만 반영
44
+ assert log.beats_hit == ["둘만의 비밀 동조"]
45
+
46
+
47
+ @pytest.mark.asyncio
48
+ async def test_finish_pocket_verified_by_code(repo):
49
+ """director의 goal_reached 선언은 비트가 있어야만 인정된다 (코드 재검증)."""
50
+ svc, _, _ = make_service(
51
+ repo,
52
+ char_script=[text_response("…"), text_response("…"), text_response("…")],
53
+ dir_script=[
54
+ # 1턴: 비트 없이 goal_reached 선언 → 기각되어야 함
55
+ tool_response(tool_call("finish_pocket", reason="goal_reached")),
56
+ text_response("끝"),
57
+ # 2턴: 비트는 있지만 MIN_TURNS(3) 미달 → 기각
58
+ tool_response(tool_call("mark_beat", beat="핵심 비트"),
59
+ tool_call("finish_pocket", reason="goal_reached")),
60
+ text_response("끝"),
61
+ # 3턴: 최소 체류 충족 + 비트 있음 → 인정
62
+ tool_response(tool_call("finish_pocket", reason="goal_reached")),
63
+ text_response("끝"),
64
+ ],
65
+ )
66
+ sid = await svc.open("E06-09", HiddenState())
67
+ t1 = await svc.run_turn(sid, "응.")
68
+ assert not t1.converged # 비트 없음 → 기각
69
+
70
+ t2 = await svc.run_turn(sid, "그래.")
71
+ assert not t2.converged # 최소 체류 턴 미달 → 기각 (답변 직후 종료 버그 방지)
72
+
73
+ t3 = await svc.run_turn(sid, "좋아.")
74
+ assert t3.converged and t3.reason == "goal_reached"
75
+
76
+
77
+ @pytest.mark.asyncio
78
+ async def test_turn_cap_forces_convergence(repo):
79
+ """턴 상한 도달 시 무조건 강제 수렴 (무한 체류 방지)."""
80
+ cap = repo.chapter.pocket_max_turns
81
+ svc, _, _ = make_service(
82
+ repo,
83
+ char_script=[text_response("…") for _ in range(cap)],
84
+ dir_script=[text_response("계속") for _ in range(cap)],
85
+ )
86
+ sid = await svc.open("E06-09", HiddenState())
87
+ turn = None
88
+ for i in range(cap):
89
+ turn = await svc.run_turn(sid, f"{i}번째 말")
90
+ assert turn.converged and turn.reason == "turn_cap"
91
+
92
+
93
+ @pytest.mark.asyncio
94
+ async def test_identity_attack_sets_guard_directive(repo):
95
+ """정체 직격 → guard가 R-회피사다리 지시를 만들고 캐릭터 프롬프트에 주입된다."""
96
+ svc, char_llm, _ = make_service(
97
+ repo,
98
+ char_script=[text_response("…이상한 말을 하는구나, 로라.")],
99
+ dir_script=[tool_response(tool_call("update_trust", direction="doubt")),
100
+ text_response("판정")],
101
+ )
102
+ sid = await svc.open("E06-09", HiddenState())
103
+ turn = await svc.run_turn(sid, "너 정체가 뭐야? 흡혈귀지?")
104
+
105
+ assert turn.guard_flag == "identity_direct"
106
+ # 캐릭터에게 실제로 전달된 instruction에 회피사다리 지시가 포함됐는지
107
+ char_req = char_llm.requests[-1]
108
+ sys_text = char_req.config.system_instruction or ""
109
+ assert "회피사다리" in str(sys_text)
110
+ assert "발설하지 않는다" in str(sys_text)
111
+
112
+
113
+ @pytest.mark.asyncio
114
+ async def test_deep_confession_gated_by_trust(repo):
115
+ """E06-09 심층 대사(엔진 D)는 신뢰高 세션에서만 브리프에 실린다."""
116
+ async def brief_for(hidden):
117
+ svc, char_llm, _ = make_service(
118
+ repo,
119
+ char_script=[text_response("…")],
120
+ dir_script=[text_response("판정")],
121
+ )
122
+ sid = await svc.open("E06-09", hidden)
123
+ await svc.run_turn(sid, "무섭니…?")
124
+ return str(char_llm.requests[-1].config.system_instruction or "")
125
+
126
+ cold = await brief_for(HiddenState())
127
+ warm = await brief_for(HiddenState(trust_score=60))
128
+ deep = "시작이야" # 심층 고백의 핵심 구절
129
+ assert deep not in cold
130
+ assert deep in warm
131
+
132
+
133
+ @pytest.mark.asyncio
134
+ async def test_non_pocket_card_rejected(repo):
135
+ svc, _, _ = make_service(repo, [], [])
136
+ with pytest.raises(ValueError):
137
+ await svc.open("E01-01", HiddenState()) # 선택지·앵커 없는 캐논 카드
tests/test_story.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """M2 결정적 리딩 루프 회귀 테스트 — LLM 없이 전체 서사가 코드만으로 돈다."""
2
+
3
+ import pytest
4
+
5
+ from engine.repositories.content import ContentError, ContentRepository
6
+ from engine.services.state import HiddenState
7
+ from engine.services.story import StoryEngine, eval_condition
8
+
9
+
10
+ @pytest.fixture(scope="module")
11
+ def repo():
12
+ return ContentRepository("content")
13
+
14
+
15
+ def play(repo, policy: str) -> StoryEngine:
16
+ engine = StoryEngine(repo)
17
+ step = engine.start()
18
+ while step.ending is None:
19
+ if step.choices:
20
+ idx = next((i for i, c in enumerate(step.choices) if c.axis == policy),
21
+ next((i for i, c in enumerate(step.choices)
22
+ if c.axis in (None, "neutral")), 0))
23
+ _, step = engine.choose(idx)
24
+ else:
25
+ step = engine.advance()
26
+ return engine
27
+
28
+
29
+ # ── 엔딩 3종 도달 (수렴 회귀의 핵심) ─────────────────────────
30
+ def test_trust_policy_reaches_eternal(repo):
31
+ e = play(repo, "trust")
32
+ assert e.ending.id == "ending_eternal"
33
+
34
+
35
+ def test_doubt_policy_reaches_reckoning(repo):
36
+ e = play(repo, "doubt")
37
+ assert e.ending.id == "ending_reckoning"
38
+ assert e.ending.canonical # 처단이 원작 캐논
39
+
40
+
41
+ def test_neutral_policy_reaches_release(repo):
42
+ e = play(repo, "neutral")
43
+ assert e.ending.id == "ending_release"
44
+
45
+
46
+ # ── 캐논 보장 ────────────────────────────────────────────────
47
+ def test_reach_point_visited_on_every_policy(repo):
48
+ """E06-10(두 점 발견)은 어떤 성향이든 반드시 도달 (헌법 0번)."""
49
+ for policy in ("trust", "doubt", "neutral"):
50
+ e = play(repo, policy)
51
+ assert "E06-10" in e.visited, policy
52
+ assert e.state.frailty >= 1 # 쇠약도는 신뢰와 무관하게 진행
53
+
54
+
55
+ def test_awareness_established_at_entry(repo):
56
+ e = StoryEngine(repo)
57
+ e.start()
58
+ assert e.state.awareness_established # 엔진 A: 도입 1회 확립
59
+
60
+
61
+ def test_trust_path_commits_hidden_branch(repo):
62
+ """E06-09 동조(신뢰) → 도달점 통과 후 E06-10C(숨김) 분기가 예약된다."""
63
+ e = play(repo, "trust")
64
+ assert "E06-10C" in e.visited
65
+ assert "E06-10B" not in e.visited
66
+
67
+
68
+ # ── 숨은 상태 규칙 ───────────────────────────────────────────
69
+ def test_frailty_is_monotonic():
70
+ s = HiddenState()
71
+ s.advance_frailty()
72
+ with pytest.raises(ValueError):
73
+ s.advance_frailty(-1)
74
+
75
+
76
+ def test_trust_levels():
77
+ s = HiddenState()
78
+ assert s.trust_level == "neutral"
79
+ s.trust_score = 40
80
+ assert s.trust_level == "trust_high" and s.deep_unlock
81
+ s.doubt_score = 80
82
+ assert s.trust_level == "doubt_high"
83
+
84
+
85
+ def test_condition_parser_rejects_arbitrary_code():
86
+ s = HiddenState()
87
+ assert eval_condition("default", s)
88
+ assert not eval_condition("doubt_score >= 30", s)
89
+ s.doubt_score = 30
90
+ assert eval_condition("doubt_score >= 30", s)
91
+ with pytest.raises(ValueError):
92
+ eval_condition("__import__('os')", s)
93
+
94
+
95
+ # ── 스포일러 필터 ────────────────────────────────────────────
96
+ def test_spoiler_filter_blocks_future_cards(repo):
97
+ with pytest.raises(ContentError):
98
+ repo.get_card("E06-10", current_id="E01-01")
99
+ assert repo.get_card("E01-01", current_id="E06-10").id == "E01-01"
100
+
101
+
102
+ def test_deep_gate_hidden_until_trust_high(repo):
103
+ """E06-09 심층 고백(엔진 D)은 신뢰高에서만 노출."""
104
+ card = repo.get_card("E06-09")
105
+ gated = [b for b in card.narration if b.gate == "trust_high"]
106
+ assert gated, "E06-09에 심층 게이트 블록이 있어야 함"
107
+
108
+ cold = StoryEngine(repo)
109
+ cold.current_id = "E06-09"
110
+ assert all(b.gate == "all" for b in cold.visible_narration(card))
111
+
112
+ warm = StoryEngine(repo, HiddenState(trust_score=60))
113
+ assert any(b.gate == "trust_high" for b in warm.visible_narration(card))
web/index.html CHANGED
@@ -37,8 +37,11 @@
37
  #progress-label { position: fixed; top: 10px; right: 16px; color: var(--gold-dim);
38
  font-size: .74rem; letter-spacing: .22em; opacity: .85; z-index: 40; }
39
 
40
- /* ── 본문 ── */
41
- #stage { max-width: 680px; margin: 0 auto; padding: 54px 24px 40vh; min-height: 100%; }
 
 
 
42
  .ep-header { text-align: center; color: var(--gold-dim); font-size: .82rem;
43
  letter-spacing: .35em; margin: 26px 0 6px; }
44
  .card-title { text-align: center; color: var(--gold); font-size: 1.05rem;
@@ -51,13 +54,31 @@
51
  margin: 2.4em 0; line-height: 2.1; word-break: keep-all; }
52
  .center-line::before { content: "❝ "; color: var(--gold-dim); }
53
  .center-line::after { content: " ❞"; color: var(--gold-dim); }
54
- /* ── 캐릭터 발화 (비주얼 노벨 참고: 플래이 이미지/7.png) ──
55
- 초상 일러스트 장식 이름표 대사. 초상 없는 화자는 이름표+대사만 */
56
- .vn-block { margin: 2.6em auto; text-align: center; }
57
- .vn-portrait { display: block; margin: 0 auto 16px;
58
- max-width: min(80vw, 430px); max-height: 46vh; object-fit: contain;
59
- border: 1px solid rgba(201,168,106,.28);
60
- box-shadow: 0 0 70px rgba(0,0,0,.85); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  .vn-name { display: inline-block; position: relative; margin-bottom: 14px;
62
  padding: 5px 30px; font-size: .9rem; color: var(--gold);
63
  letter-spacing: .3em; text-indent: .3em;
@@ -69,11 +90,6 @@
69
  .vn-name::after { left: 100%; margin-left: 10px; transform: scaleX(-1); }
70
  .vn-line { font-size: 1.06rem; color: #e5dfd2; line-height: 2.05;
71
  word-break: keep-all; margin: .35em 0; }
72
- .vn-block.player { text-align: right; }
73
- .vn-block.player .vn-name { padding: 4px 18px; font-size: .8rem;
74
- color: var(--text-dim); border-color: rgba(143,140,133,.4); }
75
- .vn-block.player .vn-name::before, .vn-block.player .vn-name::after { display: none; }
76
- .vn-block.player .vn-line { color: var(--text-dim); font-size: 1rem; }
77
  .stage-dir { color: var(--text-dim); font-style: italic; font-size: .92rem;
78
  text-align: center; margin: 1.4em 0; }
79
  .result-block { margin: 1.6em 0; padding: 1em 1.2em; background: rgba(201,168,106,.05);
@@ -211,7 +227,7 @@
211
  <div id="intro-hint">— 눌러서 계속 —</div>
212
  </div>
213
 
214
- <div id="stage"><div id="page"></div></div>
215
 
216
  <div id="nav-left" class="nav-zone" title="이전"></div>
217
  <div id="nav-right" class="nav-zone" title="다음"></div>
@@ -231,8 +247,9 @@
231
 
232
  <script>
233
  let SID = null, STEP = null, lastEpisode = null, busy = false;
234
- let history = [], cursor = -1; // 뷰 히스토리 — {html, progress, ep} (읽기 전용 되돌아보기)
235
  let pendingStep = null; // 다음 넘김에 표시할 step (선택 결과 뒤 · 연출 컷 뒤 본문)
 
236
  let chatOpen = false;
237
 
238
  const $ = (id) => document.getElementById(id);
@@ -324,23 +341,27 @@ function block(b) {
324
  return `<div class="prose fade">${esc(b.text)}</div>`;
325
  }
326
 
327
- function vnBlock(spk, portrait, lines) {
328
- const player = PLAYER_SPEAKERS.has(spk);
329
- let h = `<div class="vn-block fade${player ? ' player' : ''}">`;
330
- if (portrait && !player)
331
- h += `<img class="vn-portrait" src="${encodeURI(portrait)}" alt="${esc(spk)}">`;
332
- h += `<div class="vn-name">${esc(player ? '나' : spk)}</div>`;
333
- for (const t of lines) h += `<div class="vn-line">“${esc(t)}”</div>`;
334
- return h + `</div>`;
335
  }
336
 
337
- function narrationHtml(blocks) {
338
- // 연속된 같은 화자의 발화는 VN 블록으로. 초상은 페이지당 화자별 1회만
339
- let out = '';
340
- const portraitShown = new Set();
 
 
 
 
 
 
 
 
341
  for (let i = 0; i < blocks.length;) {
342
  const b = blocks[i];
343
- if (b.type !== 'character_dialogue') { out += block(b); i++; continue; }
344
  const spk = b.speaker, lines = [];
345
  let portrait = null;
346
  while (i < blocks.length && blocks[i].type === 'character_dialogue'
@@ -349,21 +370,68 @@ function narrationHtml(blocks) {
349
  portrait = portrait || blocks[i].portrait;
350
  i++;
351
  }
352
- if (portrait && portraitShown.has(spk)) portrait = null;
353
- if (portrait) portraitShown.add(spk);
354
- out += vnBlock(spk, portrait, lines);
355
  }
356
- return out;
357
  }
358
 
359
- function pageHtml(step, fresh) {
 
 
 
 
 
360
  const c = step.card;
361
- let html = '';
362
- if (fresh || c.episode !== lastEpisode) html += `<div class="ep-header fade">${esc(c.episode_title || c.episode)}</div>`;
363
- html += `<div class="card-title fade">${esc(c.title)}</div>`;
364
- html += narrationHtml(step.narration);
365
- html += `<div id="choice-area">${choicesHtml(step)}</div>`;
366
- return html;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
367
  }
368
 
369
  function choicesHtml(step) {
@@ -381,14 +449,16 @@ function setProgress(p, epLabel) {
381
  }
382
 
383
  function showStep(step, fresh) {
384
- STEP = step; pendingStep = null;
385
  if (step.ending) return renderEnding(step.ending);
386
  if (step.image) { // 연출 컷 = 자체 번호를 가진 독립 페이지, 본문은 다음 넘김에
387
  pushCutPage(step);
388
  pendingStep = {...step, image: null};
389
  return;
390
  }
391
- pushPage(step, fresh);
 
 
392
  }
393
 
394
  function pushCutPage(step) { // 이미지 페이지 번호 = 본문 번호 - 1 (서버 계약)
@@ -398,11 +468,6 @@ function pushCutPage(step) { // 이미지 페이지 번호 = 본문 번
398
  step.card.episode_title, step.background);
399
  }
400
 
401
- function pushPage(step, fresh) {
402
- lastEpisode = step.card.episode;
403
- pushEntry(pageHtml(step, fresh), step.progress, step.card.episode_title, step.background);
404
- }
405
-
406
  function pushEntry(html, progress, ep, bg) {
407
  $('page').innerHTML = html;
408
  history.push({html, progress, ep, bg});
@@ -451,6 +516,11 @@ async function go(dir) {
451
  renderEntry(cursor);
452
  return;
453
  }
 
 
 
 
 
454
  if (pendingStep) { // 선택 결과 확인 후 다음 장
455
  const s = pendingStep; pendingStep = null;
456
  showStep(s);
 
37
  #progress-label { position: fixed; top: 10px; right: 16px; color: var(--gold-dim);
38
  font-size: .74rem; letter-spacing: .22em; opacity: .85; z-index: 40; }
39
 
40
+ /* ── 본문 — 화면 단위 읽기: 한 화면은 뷰포트 안에 들어간다 (스크롤 대신 페이지 넘김) ── */
41
+ #stage { max-width: 680px; margin: 0 auto; padding: 54px 24px 96px;
42
+ min-height: 100%; position: relative; }
43
+ #page-measure { position: absolute; left: 24px; right: 24px; top: 54px;
44
+ visibility: hidden; pointer-events: none; }
45
  .ep-header { text-align: center; color: var(--gold-dim); font-size: .82rem;
46
  letter-spacing: .35em; margin: 26px 0 6px; }
47
  .card-title { text-align: center; color: var(--gold); font-size: 1.05rem;
 
54
  margin: 2.4em 0; line-height: 2.1; word-break: keep-all; }
55
  .center-line::before { content: "❝ "; color: var(--gold-dim); }
56
  .center-line::after { content: " ❞"; color: var(--gold-dim); }
57
+ /* ── 캐릭터 발화 서사 발화는 컴팩트: 화자명 · 우 대사 (책의 흐름) ── */
58
+ .char-line { margin: 1.2em 0; padding-left: 1em; border-left: 2px solid var(--gold-dim);
59
+ color: #cfc4ad; word-break: keep-all; }
60
+ .char-line .spk { color: var(--gold-dim); font-size: .85rem; margin-right: .6em; }
61
+
62
+ /* ── VN 스테이지 — 선택지·채팅이 있는 페이지: 초상이 프레임을 채우고
63
+ 하단 패널에 이름표·대사·선택지가 겹쳐 얹힘 (참고: 플래이 이미지/7.png) ── */
64
+ .vn-stage { position: relative; margin: 1.2em auto 0; height: min(76vh, 860px);
65
+ overflow: hidden; border: 1px solid rgba(201,168,106,.28);
66
+ box-shadow: 0 0 80px rgba(0,0,0,.9); }
67
+ .vn-stage-art { position: absolute; inset: 0; width: 100%; height: 100%;
68
+ object-fit: cover; object-position: top center; }
69
+ .vn-stage::after { content: ""; position: absolute; inset: 0;
70
+ background: linear-gradient(rgba(11,14,20,.12) 0%, transparent 22%,
71
+ transparent 40%, rgba(11,14,20,.96) 82%); }
72
+ .vn-lead { position: absolute; top: 0; left: 0; right: 0; z-index: 1;
73
+ padding: 16px 22px 26px; font-size: .96rem; color: var(--text);
74
+ word-break: keep-all; text-shadow: 0 1px 8px rgba(0,0,0,.95);
75
+ background: linear-gradient(rgba(11,14,20,.82), transparent); }
76
+ .vn-stage-panel { position: absolute; left: 0; right: 0; bottom: 0; z-index: 1;
77
+ padding: 0 22px 20px; text-align: center; }
78
+ .vn-stage-panel .vn-line { font-size: 1.02rem; line-height: 1.9; margin: .3em 0;
79
+ text-shadow: 0 1px 8px rgba(0,0,0,.9); }
80
+ .vn-stage-panel #choice-area { margin: 14px auto 0; max-width: 470px; }
81
+ .vn-stage-panel #choice-area button { padding: 13px 10px; background: rgba(11,14,20,.35); }
82
  .vn-name { display: inline-block; position: relative; margin-bottom: 14px;
83
  padding: 5px 30px; font-size: .9rem; color: var(--gold);
84
  letter-spacing: .3em; text-indent: .3em;
 
90
  .vn-name::after { left: 100%; margin-left: 10px; transform: scaleX(-1); }
91
  .vn-line { font-size: 1.06rem; color: #e5dfd2; line-height: 2.05;
92
  word-break: keep-all; margin: .35em 0; }
 
 
 
 
 
93
  .stage-dir { color: var(--text-dim); font-style: italic; font-size: .92rem;
94
  text-align: center; margin: 1.4em 0; }
95
  .result-block { margin: 1.6em 0; padding: 1em 1.2em; background: rgba(201,168,106,.05);
 
227
  <div id="intro-hint">— 눌러서 계속 —</div>
228
  </div>
229
 
230
+ <div id="stage"><div id="page"></div><div id="page-measure" aria-hidden="true"></div></div>
231
 
232
  <div id="nav-left" class="nav-zone" title="이전"></div>
233
  <div id="nav-right" class="nav-zone" title="다음"></div>
 
247
 
248
  <script>
249
  let SID = null, STEP = null, lastEpisode = null, busy = false;
250
+ let history = [], cursor = -1; // 뷰 히스토리 — {html, progress, ep, bg} (읽기 전용 되돌아보기)
251
  let pendingStep = null; // 다음 넘김에 표시할 step (선택 결과 뒤 · 연출 컷 뒤 본문)
252
+ let pendingScreens = []; // 현재 서버 페이지의 남은 화면들 (뷰포트 분할분)
253
  let chatOpen = false;
254
 
255
  const $ = (id) => document.getElementById(id);
 
341
  return `<div class="prose fade">${esc(b.text)}</div>`;
342
  }
343
 
344
+ function charLines(g) { // 서사 속 발화 — 좌 화자명 · 우 대사 (책의 흐름)
345
+ const name = PLAYER_SPEAKERS.has(g.spk) ? '나' : g.spk;
346
+ return g.lines.map(t =>
347
+ `<div class="char-line fade"><span class="spk">${esc(name)}</span>“${esc(t)}”</div>`).join('');
 
 
 
 
348
  }
349
 
350
+ function vnStage(g, choices, lead) { // 초상이 프레임을 채우고 하단 패널에 대사+선택지
351
+ let h = `<div class="vn-stage fade">` +
352
+ `<img class="vn-stage-art" src="${encodeURI(g.portrait)}" alt="${esc(g.spk)}">`;
353
+ if (lead) h += `<div class="vn-lead">${esc(lead)}</div>`; // 리드인 — 이미지 상단에
354
+ h += `<div class="vn-stage-panel"><div class="vn-name">${esc(g.spk)}</div>`;
355
+ for (const t of g.lines) h += `<div class="vn-line">“${esc(t)}”</div>`;
356
+ return h + `<div id="choice-area">${choices}</div></div></div>`;
357
+ }
358
+
359
+ function groupNarration(blocks) {
360
+ // 연속된 같은 화자의 발화는 한 그룹으로
361
+ const groups = [];
362
  for (let i = 0; i < blocks.length;) {
363
  const b = blocks[i];
364
+ if (b.type !== 'character_dialogue') { groups.push({block: b}); i++; continue; }
365
  const spk = b.speaker, lines = [];
366
  let portrait = null;
367
  while (i < blocks.length && blocks[i].type === 'character_dialogue'
 
370
  portrait = portrait || blocks[i].portrait;
371
  i++;
372
  }
373
+ groups.push({spk, portrait, lines});
 
 
374
  }
375
+ return groups;
376
  }
377
 
378
+ /* 서버 페이지 → 화면들 (스크롤 없는 넘김).
379
+ - 타이틀은 에피소드 첫 페이지의 화(話) 헤더만 — 카드 소제목은 표시하지 않는다 (책의 흐름)
380
+ - 서사 속 발화는 char-line으로 본문에 흡수, 스테이지(초상 화면)는
381
+ 인터랙션 페이지(선택지·채팅)의 마지막 초상 발화에만 (단독 화면)
382
+ - 나머지 블록은 실측(#page-measure)으로 뷰포트 예산 안에서 채운다 */
383
+ function buildScreens(step, fresh) {
384
  const c = step.card;
385
+ let head = '';
386
+ if (fresh || c.episode !== lastEpisode)
387
+ head += `<div class="ep-header fade">${esc(c.episode_title || c.episode)}</div>`;
388
+ lastEpisode = c.episode;
389
+
390
+ const groups = groupNarration(step.narration);
391
+ const choices = choicesHtml(step);
392
+ let stageIdx = -1; // 인터랙션 페이지의 마지막 초상 발화만 스테이지
393
+ if (step.can_chat || step.choices.length) {
394
+ for (let k = groups.length - 1; k >= 0; k--) {
395
+ const g = groups[k];
396
+ if (g.lines && g.portrait && !PLAYER_SPEAKERS.has(g.spk)) { stageIdx = k; break; }
397
+ }
398
+ }
399
+
400
+ const meas = $('page-measure');
401
+ const budget = Math.max((window.innerHeight || 900) - 190, 320);
402
+ const screens = [];
403
+ let cur = head;
404
+ const flush = () => { if (cur.trim()) screens.push(cur); cur = ''; };
405
+
406
+ const stageIsLast = stageIdx === groups.length - 1; // 마지막 그룹일 때만 선택지 내장
407
+ // 스테이지 직전의 리드인(role=lead_in 단일 블록)은 스테이지 화면에 얹는다 — 파편 화면 방지
408
+ let leadIdx = -1;
409
+ if (stageIdx > 0) {
410
+ const pg = groups[stageIdx - 1];
411
+ if (pg.block && pg.block.role === 'lead_in') leadIdx = stageIdx - 1;
412
+ }
413
+ groups.forEach((g, k) => {
414
+ if (k === leadIdx) return; // 스테이지에 포함
415
+ if (k === stageIdx) { // 스테이지 = 단독 화면
416
+ flush();
417
+ screens.push(vnStage(g, stageIsLast ? choices : '',
418
+ leadIdx >= 0 ? groups[leadIdx].block.text : null));
419
+ return;
420
+ }
421
+ const html = g.lines ? charLines(g) : block(g.block);
422
+ meas.innerHTML = cur + html;
423
+ if (cur.trim() && meas.offsetHeight > budget) flush();
424
+ cur += html;
425
+ });
426
+ if (choices && !stageIsLast) { // 그 외에는 선택지를 마지막 화면 하단에
427
+ const area = `<div id="choice-area">${choices}</div>`;
428
+ meas.innerHTML = cur + area;
429
+ if (cur.trim() && meas.offsetHeight > budget) flush();
430
+ cur += area;
431
+ }
432
+ flush();
433
+ meas.innerHTML = '';
434
+ return screens.length ? screens : [head];
435
  }
436
 
437
  function choicesHtml(step) {
 
449
  }
450
 
451
  function showStep(step, fresh) {
452
+ STEP = step; pendingStep = null; pendingScreens = [];
453
  if (step.ending) return renderEnding(step.ending);
454
  if (step.image) { // 연출 컷 = 자체 번호를 가진 독립 페이지, 본문은 다음 넘김에
455
  pushCutPage(step);
456
  pendingStep = {...step, image: null};
457
  return;
458
  }
459
+ const screens = buildScreens(step, fresh);
460
+ pushEntry(screens[0], step.progress, step.card.episode_title, step.background);
461
+ pendingScreens = screens.slice(1); // 나머지는 페이지 넘김으로
462
  }
463
 
464
  function pushCutPage(step) { // 이미지 페이지 번호 = 본문 번호 - 1 (서버 계약)
 
468
  step.card.episode_title, step.background);
469
  }
470
 
 
 
 
 
 
471
  function pushEntry(html, progress, ep, bg) {
472
  $('page').innerHTML = html;
473
  history.push({html, progress, ep, bg});
 
516
  renderEntry(cursor);
517
  return;
518
  }
519
+ if (pendingScreens.length) { // 같은 서버 페이지의 다음 화면
520
+ pushEntry(pendingScreens.shift(), STEP.progress,
521
+ STEP.card.episode_title, STEP.background);
522
+ return;
523
+ }
524
  if (pendingStep) { // 선택 결과 확인 후 다음 장
525
  const s = pendingStep; pendingStep = null;
526
  showStep(s);