diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000000000000000000000000000000000000..0ea3fd4c8a4978921dd8b32e8ff42e470450fab4 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,235 @@ +# sorac 카르밀라 — 전체 구조 명세서 + +> 이 문서 하나로 "무슨 파일이 어디 있고, 어떤 역할이며, 어떻게 맞물려 도는지"를 파악할 수 있게 쓴 명세서. +> 마지막 갱신: 2026-07-03 (MVP M0~M6 완료 시점) + +--- + +## 0. 한 문장 요약 + +**비트카드(대표님이 쓴 서사 원고)를 YAML로 변환해 적재하고, 파이썬 상태머신이 "책 읽기"를, +google-adk LLM 파이프라인이 "카르밀라와의 대화"를 담당하며, 웹 리더가 그걸 화면에 그린다.** + +``` +[대표님 비트카드 MD] ──변환──▶ [content/ YAML] ──적재──▶ ┌─ StoryEngine (읽기: 결정적 코드) + └─ PocketService (대화: ADK LLM) + │ + [web/index.html] ◀──HTTP── [router.py] +``` + +핵심 설계 원칙 (CLAUDE.md): +- **서사 진행은 코드가, 대사 생성만 LLM이** 한다 — 스토리가 산으로 가지 않게 +- **숨은 상태(신뢰/의심/쇠약)는 유저에게 절대 숫자로 노출하지 않는다** +- **콘텐츠(YAML)와 코드의 분리** — 이야기 수정은 콘텐츠 쪽에서만 + +--- + +## 1. 루트 폴더 지도 + +| 경로 | 누가 만든 것 | 역할 | +|---|---|---| +| `CLAUDE.md` | 공동 | 개발 헌법. 아키텍처 규칙·금지사항. Claude Code가 매 세션 읽음 | +| `engine/` | Claude | **엔진 본체** (파이썬 패키지) — 아래 §3 | +| `content/` | 변환기 산출물 | **엔진이 읽는 서사 데이터** (YAML) — 아래 §2 | +| `web/` | Claude | 웹 리더 프론트 (단일 HTML) | +| `scripts/` | Claude | 개발용 스크립트 (변환기, CLI 플레이어) | +| `tests/` | Claude | 자동 테스트 18개 | +| `docs/` | Claude | 계획서·API 계약·본 명세서 | +| `logs/` | 런타임 생성 | 플레이 턴 로그 (연구 데이터, git 제외) | +| `data/` | 런타임 생성 | 플레이 세션 영속화 SQLite (`sessions.db`, git 제외) | +| `.env` | 유저 | OPENAI_API_KEY (git 제외 — 절대 커밋 금지) | +| `.venv/` | 설치 산출물 | 파이썬 가상환경 (google-adk 2.3.0 등) | +| `카르밀라/` | 대표님 | **콘텐츠 원천** — 비트카드 MD·원고·기획·이미지 | +| `reference/` | 대표님(프로토타입) | 비-ADK 개념검증 엔진. **참고용, 실행 안 함** | +| `requirements.txt` / `pyproject.toml` | Claude | 의존성 목록 / pytest 설정 | + +--- + +## 2. content/ — 엔진이 읽는 서사 데이터 (3레이어) + +**엔진의 유일한 서사 입력.** 원천은 `카르밀라/0701_카르밀라/비트카드/`의 MD 파일(대표님 소유)이고, +여기 있는 YAML은 `scripts/beatcard_convert.py`가 만든 **파생물**이다. 이야기를 고치려면 +MD를 고치고 변환기를 다시 돌린다 (YAML 직접 수정 금지). + +| 파일 | 레이어 | 내용 | +|---|---|---| +| `content/beatcards/E01.yaml` ~ `E06.yaml` | 내러티브 | **비트카드 61장.** 카드 = 화면 한 장. 지문 블록(prose/중앙대사/인물대사/무대지시), 선택지(라벨·결과산문·신뢰축·분기), next 링크, 태그 | +| `content/beatcards/R-회피사다리.yaml` | 내러티브 | 유저가 정체를 직격했을 때 카르밀라의 3단계 회피 대응 (재사용 카드) | +| `content/beatcards/R-약점반응.yaml` | 내러티브 | 성물·마늘 등 약점 언급 시 반응 원칙 | +| `content/chapters/carmilla_chapter1.yaml` | 챕터 메타 | 시작 카드(E01-01), 범위(E01~E06), **엔딩 3종 규칙**, 캐논 규칙, R카드 등록, 채팅 턴 상한(6) | +| `content/characters.yaml` | 정체성 | 인물 시트 3명(로라=플레이어, 카르밀라, 아버지). 성격·화법·지식경계 | + +**카드 한 장의 구조 (예)**: +```yaml +- id: E06-09 # 카드 ID + title: 둘만의 비밀 + canon: true # 원작 뼈대 (불변) + next: E06-10 # 다음 카드 + axis: null # 카드 진입 시 신뢰축 이동 (trust/doubt/null) + frailty: false # 이 카드에서 쇠약도 +1 여부 + narration: # 지문 블록들 (화면에 그대로 출력) + - {type: prose, text: "..."} + - {type: dialogue_center, text: "죽음은 오히려 축복일 수도 있어."} + - {type: dialogue_center, text: "...시작이야.", gate: trust_high} # 신뢰高 전용(심층 해금) + - {type: character_dialogue, speaker: 카르밀라, text: "...", anchor: true} # 채팅 변주 기준 + choices: + - {label: 둘만의 비밀에 동조한다, axis: trust, result: "...", next: null} + - {label: 그래도 누군가에게 알려야..., axis: doubt, result: "...", next: null} +``` + +--- + +## 3. engine/ — 엔진 본체 + +### 3-1. 최상위 파일 + +| 파일 | 역할 | +|---|---| +| `model.py` | LLM 설정. 루트 `.env`를 로딩하고 LiteLLM으로 모델 초기화 (`LLM_MODEL`/`CHARACTER_MODEL` env로 교체 가능, 기본 openai/gpt-5.4-nano) | +| `agent.py` | **ADK 파이프라인 조립**: `pocket_session = Sequential[로더 → 가드 → 카르밀라 → 디렉터]`. `adk web`용 `root_agent`도 여기서 노출 | +| `app.py` | ADK `App`(이름+루트에이전트+플러그인) + `Runner`(세션 서비스) 생성 | +| `router.py` | **FastAPI 서버.** HTTP 엔드포인트(세션/advance/choose/pocket) + 웹 리더·이미지 서빙. 연출 이미지 매핑(`IMAGE_MAP`)도 여기 | + +### 3-2. services/ — 비즈니스 로직 (게임 규칙의 심장) + +| 파일 | 역할 | +|---|---| +| `story.py` | **읽기 상태머신.** 카드 전이(next), 선택지 반영, 심층 게이트 노출 판정, **엔딩 조건식 평가**(전용 파서 — eval 금지). 도달점 보장(preset_branch) 로직 포함 | +| `state.py` | **숨은 상태 3값.** 신뢰축 시소(trust/doubt, 이벤트당 ±12), 쇠약도(단조 증가만 허용), 앎(도입 1회 확립). 신뢰 레벨 환산(±12/±36 경계 → 5단계) — 이 레벨이 카르밀라의 "결"과 심층 해금을 결정 | +| `pocket.py` | **채팅 세션 관리.** 포켓 열기(카르밀라 카드만)/턴 실행/닫기(상태 커밋). **수렴 재검증**: LLM이 "끝내자"고 해도 코드가 검사 — 최소 3턴 체류, 비트 없는 종료 기각, 6턴 강제 수렴 | +| `paging.py` | **페이지 병합.** 여러 카드를 한 화면으로 — 결정적 파티션(선형 링크 구간만, 선택지·연출 컷·채팅 앵커·에피소드 경계에서 분리, `page_target_chars` 소프트 캡). 부수효과는 여전히 story.py가 카드당 1회 | +| `retrieval.py` | (2단계 스텁) GraphRAG 검색 자리. 스포일러 필터 강제 지점 | + +### 3-3. sub_agents/ + agents/ — ADK 대화 파이프라인 (채팅 한 턴에 4명이 순서대로 일함) + +``` +유저 입력 → ① pocket_context_loader → ② input_guard → ③ carmilla → ④ director → 응답 +``` + +| 순서 | 파일 | 타입 | 하는 일 | +|---|---|---|---| +| ① | `agents/pocket_context_loader.py` | 코드(BaseAgent) | 현재 카드의 지문·앵커대사·신뢰 레벨별 "결"을 세션에 적재. **캐릭터용 브리프와 디렉터용 노트를 분리** (스포일러 누수 방지) | +| ② | `agents/input_guard.py` | 코드(BaseAgent) | 유저 입력 검사: 정체 직격("흡혈귀지?") → R-회피사다리 단계 지시, 약점(십자가 등) → R-약점반응 지시, 메타 발화(AI/게임) → 세계관 복귀 지시 | +| ③ | `sub_agents/character/agent.py` | **LLM** | 카르밀라 응답 생성. instruction = `instructions/character_carmilla.md` + 인물시트 + 장면브리프 + 가드지시 | +| ④ | `sub_agents/director/agent.py` | **LLM** | 심판. 유저에게 안 보임. 도구 호출로만 기록: 신뢰축 이동/목표 비트 달성/수렴 선언 | + +### 3-4. tools/state/ — 디렉터가 쓰는 도구 3종 (1파일 1함수) + +| 파일 | 기능 | +|---|---| +| `update_trust.py` | 이번 턴 대화로 신뢰/의심 축을 ±12 이동 | +| `mark_beat.py` | 장면 목표 비트 달성 기록 | +| `finish_pocket.py` | 수렴 선언(escalate) — 단 **최종 결정은 pocket.py 코드가 재검증** | + +### 3-5. 나머지 + +| 경로 | 역할 | +|---|---| +| `schemas/` | 콘텐츠 YAML의 Pydantic 스키마(beatcard/chapter/character) + **validate CLI** (`python -m engine.schemas.validate content/` — 스키마·링크 무결성·도달성 검사) | +| `repositories/content.py` | YAML → 메모리 적재. **스포일러 필터**(현재 카드 이후 조회 차단). 카드 순서/전체 수(진행도용). 콘텐츠 버전 스탬프(`content_version`) | +| `repositories/playlog.py` | 턴 로그 jsonl 저장 (`logs/playlog.jsonl`, 연구 데이터) | +| `repositories/play_session.py` | **세션 영속화** — 서사 위치+숨은 상태 스냅샷을 SQLite(`data/sessions.db`)에 매 변경 즉시 저장. 서버 재시작 후 복원(포켓 채팅은 비영속·폐기). 콘텐츠 버전 불일치 세션은 무효화 | +| `plugins/logging.py` | ADK 플러그인 — 채팅의 유저입력/응답/도구호출/상태변화를 자동 기록 | +| `instructions/` | LLM 프롬프트 원문 (카르밀라 인격 규칙, 디렉터 판정 절차). **말투·행동 규칙을 고치려면 여기** | +| `callbacks/` | (빈 스캐폴드) before_model 스포일러 최종 방어선 자리 — 2단계 | + +--- + +## 4. web/ — 웹 리더 (프론트엔드) + +`web/index.html` 단일 파일 (바닐라 JS, 빌드 없음). router.py가 서빙. + +- **읽기**: 페이지 단위 페이징 — 한 페이지는 여러 카드를 병합(서버 `paging.py`, 목표 500자). 우측 클릭/→ = 다음, 좌측 클릭/← = 이전(읽기 전용 되돌아보기 — 선택 번복 불가). 연출 컷(이미지)은 자체 번호를 가진 독립 페이지 — 본문과 똑같이 넘기고 되돌아본다 +- **이어 읽기**: 세션 ID를 `localStorage`에 보관 — 새로고침·서버 재시작 후에도 현재 페이지부터 재개 (표지 버튼이 "밤을 잇는다"로 바뀜). 엔딩에서 재시작하면 폐기 +- **선택지**: 본문 아래 중앙 1열. 선택 결과 산문 표시 후 우측 클릭으로 진행 +- **채팅**: 카르밀라 카드에서 "…말을 건다" → 하단 드로어. 수렴 시 여운(3.4초) 후 자동 복귀 +- **연출**: 상단 진행 바(n/총페이지 = 카드 61 + 컷 5 = 66), 중요 장면 이미지 페이지, 표지 +- 이미지 원본: `카르밀라/0630…/이미지/` (router의 `/assets`로 서빙, 매핑은 `IMAGE_MAP`) + +--- + +## 5. scripts/ · tests/ · docs/ + +| 경로 | 역할 | +|---|---| +| `scripts/beatcard_convert.py` | **비트카드 MD → content/ YAML 변환기.** 선택지-결과 매칭, 분기 해석, 도달점 우회 방지. 콘텐츠 갱신 시 재실행 | +| `scripts/play.py` | 터미널 플레이어. `--policy trust/doubt/neutral --quiet`로 엔딩 3종 자동 검증 가능 | +| `tests/` (18개) | `test_story.py` 읽기/엔딩/스포일러 · `test_pocket.py` 채팅 파이프라인(가짜 LLM으로 결정적 검증) · `test_integration.py` 읽기→채팅→엔딩 전체 관통 · `fakes.py` 스크립트 LLM 테스트 더블 | +| `docs/plans/mvp.md` | 마일스톤 M0~M6 계획·상태·결정 로그 | +| `docs/contract/demo_api.md` | 웹 리더 ↔ 서버 HTTP API 계약 | +| `docs/ARCHITECTURE.md` | 본 문서 | + +--- + +## 6. 런타임 흐름 (실제로 어떻게 도나) + +### 읽기 턴 (LLM 없음, 0원) +``` +[폰/브라우저] 우측 클릭 + → POST /api/session/{id}/advance (router.py) + → StoryEngine.advance() (story.py) + 카드 next 따라 이동 · 쇠약도/신뢰축 반영 · 심층 게이트 판정 + → 카드 JSON 응답 → 화면 렌더 +``` + +### 선택 턴 (LLM 없음) +``` +선택지 클릭 → POST /choose {index} + → StoryEngine.choose(): 신뢰축 ±12 · 분기(next/preset) 결정 · 결과 산문 반환 +``` + +### 채팅 턴 (LLM 2회 호출 — 카르밀라 1 + 디렉터 1) +``` +"…말을 건다" → POST /pocket/open → PocketService.open() (카르밀라 카드 검증 + 상태 시딩) +메시지 전송 → POST /pocket/turn + → ADK Runner: 로더 → 가드 → 카르밀라(LLM) → 디렉터(LLM+도구) + → PocketService가 수렴 재검증 (3턴 미만 기각 / 6턴 강제 종료) + → 수렴 시: 숨은 상태를 본편에 커밋하고 읽기로 복귀 +``` + +### 엔딩 (LLM 없음) +``` +E06 끝(→E07 참조) 도달 → story.py가 ending_rules 평가: + doubt ≥ 30 → ① 처단 (원작 캐논) + trust ≥ 60 & doubt<20 → ② 영원 (다크 로맨스) + 그 외 → ③ 구원 (이별) +``` + +--- + +## 7. 실행 명령 모음 + +```bash +# 웹 리더 (폰 접속 허용) +.venv/bin/uvicorn engine.router:app --host 0.0.0.0 --port 8777 + +# 터미널 플레이 / 자동 엔딩 검증 +.venv/bin/python scripts/play.py +.venv/bin/python scripts/play.py --policy trust --quiet + +# 콘텐츠 갱신 (비트카드 MD 수정 후) +.venv/bin/python scripts/beatcard_convert.py "카르밀라/0701_카르밀라/비트카드" -o content/beatcards +.venv/bin/python -m engine.schemas.validate content/ + +# 테스트 +.venv/bin/python -m pytest tests/ -q +``` + +--- + +## 8. "이걸 바꾸고 싶으면 어디를 보나" 치트시트 + +| 하고 싶은 것 | 보는 곳 | +|---|---| +| 이야기 본문·선택지 수정 | 비트카드 MD (`카르밀라/0701…/비트카드/`) → 변환기 재실행 | +| 카르밀라 말투·행동 규칙 | `engine/instructions/character_carmilla.md` | +| 대화 종료(수렴) 판정 기준 | `engine/instructions/director.md` + `engine/services/pocket.py` (MIN_TURNS 등) | +| 엔딩 조건 숫자 | `content/chapters/carmilla_chapter1.yaml` (ending_rules) | +| 신뢰축 이동량·레벨 경계 | `engine/services/state.py` (AXIS_DELTA, trust_level) | +| 채팅 턴 상한 | `content/chapters/carmilla_chapter1.yaml` (pocket_max_turns) | +| 페이지당 글 밀도 | `content/chapters/carmilla_chapter1.yaml`에 `page_target_chars` (미설정 시 500) | +| 연출 이미지 추가·교체 | `engine/router.py` (IMAGE_MAP) | +| 화면 디자인·인터랙션 | `web/index.html` | +| 세션 저장·복원 정책 (TTL, 버전 무효화) | `engine/repositories/play_session.py` + `engine/router.py` (`_save`/`_restore`) | +| 정체직격/약점 트리거 단어 | `engine/agents/input_guard.py` (TRIGGERS) | +| LLM 모델 교체 | `.env`에 `LLM_MODEL=...` / `CHARACTER_MODEL=...` | diff --git a/docs/contract/demo_api.md b/docs/contract/demo_api.md new file mode 100644 index 0000000000000000000000000000000000000000..7f69274e1241bcbcf0bcd67a3f492e76e43b9a75 --- /dev/null +++ b/docs/contract/demo_api.md @@ -0,0 +1,73 @@ +# 데모 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만 + ], + "choices": [{"label": "눈을 뜬다"}], // 없으면 [] + "can_chat": true, // 개입 포켓(자유채팅) 가능 페이지 + "image": "/assets/플래이 이미지/4.png", // 연출 컷 (없으면 null). 매핑: router IMAGE_MAP + "progress": {"index": 5, "total": 44}, // 읽기 진행도 (페이지 단위) + // 페이지 = 여러 카드 병합(services/paging.py, 결정적 + // 파티션 · 밀도는 chapter.page_target_chars 기본 500자). + // 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"}` | 표지 등 프레젠테이션 상수 | +| `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`에 기록 (연구 데이터) diff --git a/docs/plans/mvp.md b/docs/plans/mvp.md new file mode 100644 index 0000000000000000000000000000000000000000..4a20942dfb05b2e9210dfd2daca6ff8c74174a00 --- /dev/null +++ b/docs/plans/mvp.md @@ -0,0 +1,92 @@ +# 카르밀라 인터랙티브 노벨 — MVP 구현 계획 + +> 승인일: 2026-07-03 · 상태: **M0~M6 전체 완료 = MVP 완성** (2026-07-03, feature/mvp-m1-m4) +> 실행: `.venv/bin/uvicorn engine.router:app --port 8777` → http://localhost:8777 (웹 리더) +> `.venv/bin/python scripts/play.py` (CLI) · `adk web .` → engine (포켓 단독) +> LLM: 루트 `.env`의 OPENAI_API_KEY 자동 로딩. 실 LLM으로 회피사다리·심층해금·수렴 E2E 검증 완료 +> follow-up: SequentialAgent → google.adk.workflow 마이그레이션 검토 · E06-09 유보(중립) 3안 +> 선택지 미노출 · R-약점반응 구조화(현재 raw 주입) · 타 화자(아버지 등) 채팅 포켓 · 세션 영속화 +> 원칙: CLAUDE.md 준수 (google-adk 공식 패턴, 서사 진행은 코드가·대사는 LLM이, 스포일러 필터 강제) + +## MVP 정의 + +**E01~E06(챕터1)을 처음부터 끝까지 플레이 가능한 수직 슬라이스.** + +- 카드 단위 읽기(L0, 캐논 고정) → 선택지/자유채팅 개입 포켓(L1) → 숨은 상태 누적 → 엔딩 3종 수렴(L2) +- 엔진: google-adk. 실행: CLI + `adk web`. 리딩 UI는 MVP 이후(M5) +- GraphRAG/Neo4j는 MVP 제외 — `kb_query`는 pre_kb 유지, repository 인터페이스만 준비 +- `reference/`는 개념 검증 프로토타입. **참고만 하고 코드는 ADK로 새로 구축** (유저 확정) + +## 데이터 모델 (A안 아키텍처 — reference v10에서 검증된 개념) + +| 레이어 | 파일 | 내용 | +|---|---|---| +| 정체성 (불변) | `content/characters.yaml` | 인물 시트(ENOS), player=laura, 지식경계, kb_query 훅 | +| 챕터 메타 | `content/chapters/*.yaml` | entry/exit beats, beat_range, 엔딩 3종 규칙, R카드 등록 | +| 내러티브 | `content/beatcards/E0*/*.yaml` | 카드: narration 블록/choices/next/canon/복선 태그/연출 | + +- **콘텐츠 원천(권위)**: `카르밀라/0701_카르밀라/비트카드/` MD (대표님 소유) +- 변환 YAML은 파생물: `scripts/beatcard_convert.py`(개발팀 소유)로 생성 +- 숨은 상태 3값 (P0 4종 엔진 명세): **신뢰축**(시소) · **쇠약도**(단조 증가, 신뢰와 독립) · **앎**(유저, 도입 1회 확립 후 불변). 숫자/게이지 유저 비노출 +- 엔딩 규칙: `doubt≥30→처단(캐논)` / `trust≥60∧doubt<20→영원` / `default→구원` + +## 코드 vs LLM 경계 + +| 개념 | 구현 | 타입 | +|---|---|---| +| 카드 진행(next 링크, canon 읽기) | `services/story.py` 상태머신 | 결정적 코드 | +| 선택지 → 신뢰축 반영 | 선택 매핑 + state 커밋 | 결정적 코드 | +| 자유채팅 개입 포켓 | guard → character → director | ADK 파이프라인 | +| R-회피사다리/약점반응 | guard 규칙 + character instruction 주입 | BaseAgent + instruction | +| 숨은 상태 | session.state + FunctionTool → 포켓 종료 시 커밋 | ADK state | +| 수렴 판정 | director 구조화 출력 + `services/pocket.py` 코드 재검증 | LlmAgent + 코드 | +| 엔딩 분기 | ending_rules 조건식 평가 | 결정적 코드 | +| 스포일러 방어 | `beat_id ≤ 현재` 필터 + before_model 콜백 | 콜백 | + +## 개입 포켓 파이프라인 (escalate 격리 규칙 적용) + +``` +pocket_session (SequentialAgent) +├── pocket_context_loader ← BaseAgent: 카드 브리프 + 신뢰 레벨 결 로드 +├── turn_loop (LoopAgent max=카드별 상한) +│ ├── input_guard ← BaseAgent: 세계관 이탈/정체직격/약점 감지 +│ ├── character ← LlmAgent: 카르밀라 (시트+화법6규칙+R카드+신뢰별 결) +│ └── director ← LlmAgent: 비트 달성/이탈/신뢰반영/수렴 → escalate +└── pocket_closer (LoopAgent max=1) ← 강제 수렴 내레이션 + 상태 커밋 → 다음 카드 복귀 +``` + +## 마일스톤 + +- **M0 — 스캐폴드** (0.5일): git init(로컬만, 원격 푸시는 추후) · engine/ 뼈대 · 의존성 · CLAUDE.md 데이터 모델 섹션 갱신 · 본 문서 +- **M1 — 콘텐츠 계약** (1일): 비트카드/챕터/캐릭터 Pydantic 스키마 + `python -m engine.schemas.validate content/` · 변환 스크립트 · next 링크 무결성 검사(고아/끊긴 체인). 검증 기준: 기존 61카드 통과 +- **M2 — 결정적 리딩 루프** (1일): entry_beat부터 canon 페이징 · 선택지 신뢰축 반영 · 쇠약도 단조 증가 · **LLM 없이** CLI로 E01~E06 통독 + 엔딩 3종 도달 확인 +- **M3 — ADK 개입 포켓** (2~3일, 핵심): 턴 파이프라인 · 카르밀라 instruction · director 구조화 판정(FunctionTool→state) · guard · `adk web`으로 포켓 1개(E06-09 권장) 집중 검증 +- **M4 — 전체 관통** (1~2일): 리딩 루프 ↔ 포켓 연결 · E01→엔딩 전체 플레이 · 턴 로깅 플러그인 · 시연(CLI + adk web) +- **M5 — MVP 이후**: FastAPI router + 리딩 UI · GraphRAG(kb_query 활성화) · 평가 하네스 · style_critic 루프 + +## 폴더 구조 + +``` +sorac/ +├── engine/ +│ ├── agent.py / app.py / model.py / router.py +│ ├── schemas/ beatcard.py · chapter.py · character.py · validate.py +│ ├── services/ story.py · pocket.py · state.py · retrieval.py(2단계 스텁) +│ ├── repositories/ content.py · playlog.py +│ ├── agents/ input_guard.py · pocket_context_loader.py +│ ├── sub_agents/ character/ · director/ +│ ├── tools/ state/ (update_trust.py, mark_beat.py) · retrieval/(스텁) +│ ├── callbacks/ · plugins/logging.py · instructions/ +├── content/ beatcards/ · chapters/ · characters.yaml (M1에서 적재) +├── scripts/ beatcard_convert.py +├── tests/ +└── docs/plans/mvp.md (본 문서) +``` + +## 결정 로그 + +- 데모 프론트: 엔진 우선, UI 최소 (유저 확정) +- E01_구현계획.drawio 무시 (유저 지시) — 씬 설계 근거로 쓰지 않음 +- 비트카드 MD가 콘텐츠 권위, reference 내 YAML은 파생물 (승인) +- CLAUDE.md "YAML 4종" 섹션 → A안 3레이어로 갱신 (승인) +- E07~E15: MD만 존재, MVP 범위 밖 diff --git a/docs/plans/persistence.md b/docs/plans/persistence.md new file mode 100644 index 0000000000000000000000000000000000000000..b1cb2529d853074f9709a2db4b8547b683d8010e --- /dev/null +++ b/docs/plans/persistence.md @@ -0,0 +1,112 @@ +# 인터스티셜 히스토리 + 세션 영속화 — 구현 계획 + +> 작성일: 2026-07-06 · 상태: **구현 완료** (2026-07-06, feature/persistence) +> 검증: pytest 26건 통과(신규 8건 포함) + 서버 재시작 e2e (E01-11 진행 → kill/재기동 → +> GET 복원 동일 카드 → advance 계속 진행 · 미존재 세션 404) +> 후속 수정 (2026-07-06, 유저 실플레이 피드백 2회): 오버레이 방식 자체를 폐기 — +> 연출 컷을 **자체 번호를 가진 독립 페이지**로 (예: 6 본문 → 7 컷 → 8 본문, total 66 = 61+5). +> 번호는 서버(router `_page_index`)가 권위, 컷 페이지 번호 = progress.index - 1. +> 프론트의 오버레이/pendingPage/클릭가드 로직 전부 제거 → 일반 페이지 흐름으로 단순화. +> Node DOM 스텁 시뮬레이션으로 정/역방향·좌클릭 통과 검증 +> 선행 결정 (유저 확정): +> - 포켓(채팅) 중간 상태는 영속화하지 않는다 — 서버 재시작/복원 시 포켓은 버리고 읽기 화면으로 +> - 복원 범위는 "현재 페이지부터 이어 읽기"만 — 뒤로가기 히스토리는 새로고침 시 포기 +> 권장안 채택: SQLite + 스냅샷 + 매 변경 즉시 쓰기(write-through) + localStorage 세션 ID + 콘텐츠 버전 스탬프로 무효화 + +## 문제 정의 + +1. **인터스티셜 뒤로가기 버그**: 연출 컷이 히스토리의 페이지가 아니라 일회성 오버레이라서, + 뒤로 넘기면 이미지가 건너뛰어지고 이전 스크립트가 나온다. 의도는 정방향과 동일하게 + "스크립트 → 이미지 페이지 → 스크립트"가 역방향에서도 유지되는 것. +2. **영속화 부재**: 플레이 세션이 3층 모두 휘발성 — + `router._sessions`(파이썬 dict) · ADK `InMemorySessionService` · 브라우저 `SID`(JS 변수). + 새로고침이면 세션 ID를 잃고, 서버 재시작이면 전 유저 진행이 사라진다. + CLAUDE.md 권위 원칙("현재 유저의 서사 위치 권위: DB `play_session.current_state_id`")이 예정한 DB가 아직 없다. + +## 설계 핵심 + +### 복원의 함정 — `_enter()` 부수효과 + +`StoryEngine._enter()`는 진입 시 쇠약도 증가·축 적용·앎 확립을 수행한다. +복원을 `_enter(current_id)` 재호출로 구현하면 **상태가 이중 적용**된다. +따라서 복원은 부수효과 없는 전용 경로로: 필드를 직접 복원하고, +현재 카드의 StepResult는 렌더 전용 메서드로 재구성한다 (`_visible_narration` + `card.choices`는 순수 조회). + +### 스냅샷 페이로드 (JSON 직렬화 — 전 필드 int/str/bool/list) + +```json +{ + "story": {"current_id": "E04-07", "visited": [...], "preset_branch": null, "ending_id": null}, + "hidden": {"trust_score": 24, "doubt_score": 12, "frailty": 3, + "awareness_established": true, "axis_log": [...]} +} +``` + +- `ending_id`: 엔딩 도달 세션도 복원 대상 (새로고침 시 엔딩 화면 유지). 복원 시 `chapter.ending_rules`에서 id로 역조회 +- 포켓 진행 중 저장분은 없다 — 포켓 안의 증분은 close 커밋 후에만 영속 상태에 반영 (기존 단일 경로 유지). + 포켓 도중 서버가 죽으면 그 대화의 신뢰축 증분은 유실된다 (승인된 트레이드오프) +- 기존 `HiddenState.to_session()`은 포켓 전용(부분·키 상이)이므로 재사용하지 않고 스냅샷 메서드를 별도 추가 + +### 콘텐츠 버전 스탬프 + +- `ContentRepository`에 `content_version` 프로퍼티: `chapter.id` + 전역 순서의 카드 ID 목록을 sha256 해시 +- 저장 시 스탬프, 복원 시 불일치면 **무효화**(404 → 프론트가 새 세션 시작). + 카드 본문만 바뀐 경우는 잡지 못하지만(ID/순서 동일) 복원이 깨지지는 않으므로 데모 수용 + +### 저장 시점 (write-through) + +영속 상태가 바뀌는 모든 지점에서 즉시 저장 — `POST /session`(생성) · `advance` · `choose` · +`pocket/turn`(수렴 커밋 시) · `pocket/close`. `pocket/open`은 영속 상태 무변경이라 저장 없음. + +### 동시성·수명 + +- uvicorn 워커 1개 전제 유지 (인메모리 캐시 + ADK InMemory 세션 때문). SQLite는 WAL 모드 +- 같은 세션 동시 접근은 마지막 쓰기 승리 (데모 수용) +- 서버 기동 시 30일 미갱신 세션 purge + +## 변경 파일 목록 + +### A. 인터스티셜 히스토리 (버그 수정 — 프론트 단독) + +| 파일 | 변경 | +|---|---| +| `web/index.html` | 히스토리 항목을 2종으로 타입화: `{kind:'page', html, progress, ep}` · `{kind:'inter', image, caption, progress, ep}`. ① `showStep()`: `step.image` 있으면 inter 항목을 먼저 push하고 오버레이 표시, 오버레이 클릭 시 page 항목 push + 렌더 ② `go()`: 커서 이동을 공통 `renderEntry(cursor)`로 통일 — inter 항목이면 오버레이 표시, page 항목이면 HTML 복원 + 오버레이 닫기 ③ 브라우징 중 오버레이 클릭 = `go(+1)` (오버레이가 내비 존을 덮으므로; ← 키는 계속 동작) ④ `choose()`의 스냅샷 갱신(`history[cursor].html`)은 page 항목에만 닿으므로 그대로 | + +### B. 영속화 + +| 파일 | 변경 | +|---|---| +| `engine/services/state.py` | `HiddenState.snapshot() -> dict` / `HiddenState.from_snapshot(d)` 추가 (전 필드. `to_session()`과 별개 — 포켓 경로 불변) | +| `engine/services/story.py` | ① `snapshot() -> dict` (story 파트) ② `StoryEngine.restore(repo, snap) -> StoryEngine` 클래스메서드 — **부수효과 없이** 필드 직접 복원 ③ `current_step() -> StepResult` — 현재 카드/엔딩의 StepResult를 렌더 전용으로 재구성 | +| `engine/repositories/content.py` | `content_version` 프로퍼티 (sha256 스탬프) | +| `engine/repositories/play_session.py` | **신규.** stdlib `sqlite3`, WAL. 테이블 `play_session(sid TEXT PK, payload TEXT, content_version TEXT, updated_at TEXT)`. 메서드: `save(sid, payload, version)` / `load(sid, version) -> dict | None`(버전 불일치 시 None) / `purge(days=30)` | +| `engine/router.py` | ① `PlaySessionRepository(ROOT/"data"/"sessions.db")` 생성 + 기동 시 `purge()` ② `_sessions` dict는 캐시로 유지, `_get()` 미스 시 저장소에서 로드 → `StoryEngine.restore` + `current_step()`으로 PlaySession 재구성 (`pocket_sid=None` — 포켓 폐기) ③ 저장 헬퍼 `_save(sid, ps)`를 생성/advance/choose/pocket 수렴/close에 write-through | +| `web/index.html` | ① 기동 시 `localStorage.carmilla_sid` 있으면 `GET /api/session/{sid}` 시도 — 성공 시 표지 버튼이 "이어 읽기"로 동작(새 세션 생성 생략, 현재 페이지부터), 404면 sid 폐기 후 기존 흐름 ② 세션 생성 시 sid를 localStorage에 저장 ③ 엔딩 "다시, 처음의 밤으로" 클릭 시 sid 제거 후 reload | +| `.gitignore` | `data/` 추가 | +| `tests/test_persistence.py` | **신규.** ① 스냅샷→복원 라운드트립: 몇 카드 진행 후 복원했을 때 `current_id`/숨은 상태/진행도 동일 ② **복원이 쇠약도·축을 재적용하지 않음** (핵심 회귀) ③ 엔딩 세션 복원 ④ 저장소 save/load + 버전 불일치 시 None ⑤ 라우터 레벨: `_sessions` 캐시 비운 뒤(재시작 모사) GET으로 복원되고 포켓은 닫혀 있음 | +| `docs/contract/demo_api.md` | `GET /api/session/{sid}`가 복원 진입점임을 명시 (404 = 미존재 또는 콘텐츠 버전 불일치 → 새 세션 생성할 것). 포켓 비영속 명시 | +| `docs/plans/persistence.md` | 본 문서 | + +### 변경하지 않는 것 + +- `engine/app.py` — ADK `InMemorySessionService` 유지 (포켓 비영속 결정에 따라 `SqliteSessionService` 불필요) +- `engine/services/pocket.py` — 포켓 커밋 단일 경로 불변 +- `content/` — 무변경 (콘텐츠 커밋 금지 규칙) + +## 작업 순서 + +1. **P1 — 인터스티셜 히스토리** (`web/index.html` 단독, 서버 무관) → 브라우저에서 정/역방향 수동 확인 +2. **P2 — 스냅샷/복원 엔진** (`state.py`, `story.py`) + 단위 테스트 (부수효과 미재적용 회귀 포함) +3. **P3 — SQLite 저장소 + 라우터 write-through/복원** (`play_session.py`, `content.py`, `router.py`) + 라우터 테스트 +4. **P4 — 프론트 이어 읽기** (`web/index.html` localStorage) → 새로고침·서버 재시작 후 이어 읽기 수동 확인 +5. **P5 — 문서 갱신** (`demo_api.md`, ARCHITECTURE.md 치트시트 1줄) + +검증: `pytest tests/ -q` 전건 통과 + 수동 시나리오 3종 +(① 이미지 페이지 뒤로가기/앞으로가기 ② 새로고침 후 이어 읽기 ③ uvicorn 재시작 후 이어 읽기 — 포켓 중이었다면 읽기 화면으로). + +## 리스크 / 한계 (승인된 트레이드오프 포함) + +- 포켓 도중 서버 사망 시 그 대화의 신뢰축 증분 유실 (포켓 비영속 결정의 귀결) +- 새로고침 시 뒤로가기 히스토리·선택 결과 산문은 소실, 현재 페이지부터 (결정 ⑤) +- 카드 본문만 바뀐 콘텐츠 갱신은 버전 스탬프가 감지 못함 (ID/순서 동일 시) — 복원은 안 깨짐 +- 멀티 워커/멀티 프로세스는 여전히 비지원 (ADK 세션·캐시가 인메모리) diff --git a/engine/__pycache__/agent.cpython-312.pyc b/engine/__pycache__/agent.cpython-312.pyc index 4e12ba1c970c990b00b89b6c35ee317e3631d86f..37479793c8d1f3b9a69a20cd09092edb3a86f87b 100644 Binary files a/engine/__pycache__/agent.cpython-312.pyc and b/engine/__pycache__/agent.cpython-312.pyc differ diff --git a/engine/__pycache__/agent.cpython-314.pyc b/engine/__pycache__/agent.cpython-314.pyc index 2f4c8e7af7382f2f21541d29ff9cbe58769f61d6..b506c2b4d5deb4fc5b41eafbe24aaec17747cfe2 100644 Binary files a/engine/__pycache__/agent.cpython-314.pyc and b/engine/__pycache__/agent.cpython-314.pyc differ diff --git a/engine/__pycache__/app.cpython-312.pyc b/engine/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..909b818abfc3e1b37773b706331204ddfdd7bf0f Binary files /dev/null and b/engine/__pycache__/app.cpython-312.pyc differ diff --git a/engine/__pycache__/app.cpython-314.pyc b/engine/__pycache__/app.cpython-314.pyc index 379f5a6f6ef4948ecfe329443c8c7bc88b6ea7dd..c5055f2c84ffa2c117a843b295136ab2fbedc270 100644 Binary files a/engine/__pycache__/app.cpython-314.pyc and b/engine/__pycache__/app.cpython-314.pyc differ diff --git a/engine/__pycache__/model.cpython-312.pyc b/engine/__pycache__/model.cpython-312.pyc index e13f9d7c32e2c6f3e8de9e19411033bff37d1ae9..89fa8d03d4ab6fa78ee091a5bccc59bcdf2ff615 100644 Binary files a/engine/__pycache__/model.cpython-312.pyc and b/engine/__pycache__/model.cpython-312.pyc differ diff --git a/engine/__pycache__/model.cpython-314.pyc b/engine/__pycache__/model.cpython-314.pyc index 59e4326035929caf3666e28036c3d7bce8889f2d..62b2a40552b721a26a35254d39c9b00272a36d26 100644 Binary files a/engine/__pycache__/model.cpython-314.pyc and b/engine/__pycache__/model.cpython-314.pyc differ diff --git a/engine/__pycache__/router.cpython-312.pyc b/engine/__pycache__/router.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d520d5f6528499082849d2b6a4e150cabb4b3f74 Binary files /dev/null and b/engine/__pycache__/router.cpython-312.pyc differ diff --git a/engine/__pycache__/router.cpython-314.pyc b/engine/__pycache__/router.cpython-314.pyc index 5c83973f4abb28f1055379f5d5ecd4ee7008fe7e..168fcb5033eb93f717916cc8492ade8c995d4e41 100644 Binary files a/engine/__pycache__/router.cpython-314.pyc and b/engine/__pycache__/router.cpython-314.pyc differ diff --git a/engine/agents/__pycache__/input_guard.cpython-312.pyc b/engine/agents/__pycache__/input_guard.cpython-312.pyc index 05717c99b3108e1eb866c9742d3b48be5810051b..eb6a89af1012814c319a9d5f95be5f3990d2f61e 100644 Binary files a/engine/agents/__pycache__/input_guard.cpython-312.pyc and b/engine/agents/__pycache__/input_guard.cpython-312.pyc differ diff --git a/engine/agents/__pycache__/input_guard.cpython-314.pyc b/engine/agents/__pycache__/input_guard.cpython-314.pyc index ab73c853a62259f2a15060d8d84feefb16f8186e..1192d6f3b68c2bd5be79a5c98c2e2cf1f30811e1 100644 Binary files a/engine/agents/__pycache__/input_guard.cpython-314.pyc and b/engine/agents/__pycache__/input_guard.cpython-314.pyc differ diff --git a/engine/agents/__pycache__/pocket_context_loader.cpython-312.pyc b/engine/agents/__pycache__/pocket_context_loader.cpython-312.pyc index d7c6d5bd53e30acfa1d30e842120a895547759c1..bec9839cc16a7d10e13dc0d16ee258c3c8909412 100644 Binary files a/engine/agents/__pycache__/pocket_context_loader.cpython-312.pyc and b/engine/agents/__pycache__/pocket_context_loader.cpython-312.pyc differ diff --git a/engine/agents/__pycache__/pocket_context_loader.cpython-314.pyc b/engine/agents/__pycache__/pocket_context_loader.cpython-314.pyc index 8f1a300555dca18493026d7fcbd3f4f8f886f890..ed9c85abd393e6fae939a868ac16cbb40975a957 100644 Binary files a/engine/agents/__pycache__/pocket_context_loader.cpython-314.pyc and b/engine/agents/__pycache__/pocket_context_loader.cpython-314.pyc differ diff --git a/engine/plugins/__pycache__/__init__.cpython-312.pyc b/engine/plugins/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e07e5f7be491db4bcb1201e96d87254e42779215 Binary files /dev/null and b/engine/plugins/__pycache__/__init__.cpython-312.pyc differ diff --git a/engine/plugins/__pycache__/logging.cpython-312.pyc b/engine/plugins/__pycache__/logging.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7abf8408eb1d6734099a21eb1af4335198f4f74c Binary files /dev/null and b/engine/plugins/__pycache__/logging.cpython-312.pyc differ diff --git a/engine/plugins/__pycache__/logging.cpython-314.pyc b/engine/plugins/__pycache__/logging.cpython-314.pyc index 5372da7f456aaa96c89b44bf0d90d64c98fc4341..a183f46edb96c63dd4af850c4568fb7928123cbc 100644 Binary files a/engine/plugins/__pycache__/logging.cpython-314.pyc and b/engine/plugins/__pycache__/logging.cpython-314.pyc differ diff --git a/engine/repositories/__pycache__/content.cpython-312.pyc b/engine/repositories/__pycache__/content.cpython-312.pyc index 37cac653ac9c6d5b86ceb66c73fca77cd668f21f..e573692313713668bd249fbcb0b4da6610466f1f 100644 Binary files a/engine/repositories/__pycache__/content.cpython-312.pyc and b/engine/repositories/__pycache__/content.cpython-312.pyc differ diff --git a/engine/repositories/__pycache__/content.cpython-314.pyc b/engine/repositories/__pycache__/content.cpython-314.pyc index d75d64b6ce22f411b1acf3031b21f78f7355cb6b..cfd27de0484f260ab78134f392180d70137fb6f3 100644 Binary files a/engine/repositories/__pycache__/content.cpython-314.pyc and b/engine/repositories/__pycache__/content.cpython-314.pyc differ diff --git a/engine/repositories/__pycache__/play_session.cpython-312.pyc b/engine/repositories/__pycache__/play_session.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5706780b71bdb6931d5673eb7e0e887e95e4fa56 Binary files /dev/null and b/engine/repositories/__pycache__/play_session.cpython-312.pyc differ diff --git a/engine/repositories/__pycache__/play_session.cpython-314.pyc b/engine/repositories/__pycache__/play_session.cpython-314.pyc index 325220cb8bbc5a7d1d91bb5cf8a85057d3c48643..ab2e9c2e685048065fac092009514d2c61cfdef1 100644 Binary files a/engine/repositories/__pycache__/play_session.cpython-314.pyc and b/engine/repositories/__pycache__/play_session.cpython-314.pyc differ diff --git a/engine/repositories/__pycache__/playlog.cpython-312.pyc b/engine/repositories/__pycache__/playlog.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2bf0a017c17e5dfef39a0354b7285a941771c135 Binary files /dev/null and b/engine/repositories/__pycache__/playlog.cpython-312.pyc differ diff --git a/engine/repositories/__pycache__/playlog.cpython-314.pyc b/engine/repositories/__pycache__/playlog.cpython-314.pyc index ee93e629846e4f09568f25312a859c3a33bf83fd..54c0e705e90f2e81d1af523205f2e259f22b51e0 100644 Binary files a/engine/repositories/__pycache__/playlog.cpython-314.pyc and b/engine/repositories/__pycache__/playlog.cpython-314.pyc differ diff --git a/engine/repositories/content.py b/engine/repositories/content.py index 1bb36e1b9d06c255447061e34982e5952399b4ad..4ad865f24d9969c0ada428817297e29734bf735c 100644 --- a/engine/repositories/content.py +++ b/engine/repositories/content.py @@ -75,6 +75,10 @@ class ContentRepository: def has_card(self, card_id: str) -> bool: return card_id in self._cards + def all_cards(self) -> list[BeatCard]: + """전역 순서의 전체 카드 (페이지 파티션 등 정적 계산용 — 스포일러 필터 대상 아님).""" + return [self._cards[cid] for cid in sorted(self._order, key=self._order.get)] + def cards_upto(self, current_id: str) -> list[BeatCard]: """현재 시점까지의 카드 전부 (컨텍스트 주입용).""" cut = self.order(current_id) diff --git a/engine/router.py b/engine/router.py index 00cdcbb78eac5e0e3dcdb867a8d552cd0c40bfa6..765d43c1effc42c9d6acada13b2a9149f0121e5d 100644 --- a/engine/router.py +++ b/engine/router.py @@ -6,7 +6,6 @@ from __future__ import annotations -import bisect import uuid from dataclasses import dataclass, field from pathlib import Path @@ -21,8 +20,9 @@ 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 StepResult, StoryEngine +from engine.services.story import StoryEngine ROOT = Path(__file__).resolve().parent.parent @@ -43,19 +43,12 @@ app = FastAPI(title="carmilla-demo") _repo = ContentRepository(ROOT / "content") -# 연출 컷은 독립 페이지로 센다 (표시용 번호 — 프레젠테이션 결정이라 IMAGE_MAP과 같은 계층). -# 예: 6(본문) → 7(이미지) → 8(본문). total = 카드 수 + 이미지 수 -_IMAGE_ORDERS = sorted(_repo.order(cid) for cid in IMAGE_MAP if _repo.has_card(cid)) -_TOTAL_PAGES = _repo.total_cards + len(_IMAGE_ORDERS) +# 페이지 병합 — 여러 카드를 한 화면으로 (밀도: chapter.page_target_chars, 기본 500자). +# 연출 컷·채팅 앵커는 단독 페이지, 컷은 표시 2페이지(컷+본문). 파티션은 결정적(경로 무관) +_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=_CUT_IDS) - -def _page_index(card_id: str) -> int: - """카드 본문 페이지의 표시 번호 (앞선 이미지 페이지들 + 자기 이미지 페이지만큼 밀림). - 이미지 카드의 이미지 페이지 번호는 이 값 - 1 (클라이언트가 유도).""" - order = _repo.order(card_id) - images_before = bisect.bisect_left(_IMAGE_ORDERS, order) - own_image = 1 if card_id in IMAGE_MAP else 0 - return order + 1 + images_before + own_image _pocket = PocketService( _repo, plugins=[TurnLoggingPlugin(PlaylogRepository(ROOT / "logs" / "playlog.jsonl"))]) _playdb = PlaySessionRepository(ROOT / "data" / "sessions.db") @@ -65,7 +58,7 @@ _playdb.purge(days=30) @dataclass class PlaySession: engine: StoryEngine - last_step: StepResult + last_step: PageView pocket_sid: Optional[str] = None episode_titles: dict = field(default_factory=dict) @@ -96,7 +89,7 @@ def _restore(sid: str) -> Optional[PlaySession]: if payload is None: return None engine = StoryEngine.restore(_repo, payload) - ps = PlaySession(engine=engine, last_step=engine.current_step(), + ps = PlaySession(engine=engine, last_step=_pager.render_current(engine), episode_titles=_episode_titles()) _sessions[sid] = ps return ps @@ -108,23 +101,24 @@ def _save(sid: str, ps: PlaySession) -> None: def _step_json(ps: PlaySession) -> dict: - step = ps.last_step - if step.ending is not None: + page = ps.last_step + if page.ending is not None: return {"card": None, "narration": [], "choices": [], "can_chat": False, "image": None, - "progress": {"index": _TOTAL_PAGES, "total": _TOTAL_PAGES}, - "ending": {"id": step.ending.id, "label": step.ending.label, - "description": step.ending.description}} - card = step.card + "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 { - "card": {"id": card.id, "title": card.title, "episode": card.episode_id, - "episode_title": ps.episode_titles.get(card.episode_id, card.episode_id)}, + # 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} - for b in step.narration], - "choices": [{"label": c.label} for c in step.choices], - "can_chat": card.can_chat, - "image": IMAGE_MAP.get(card.id), - "progress": {"index": _page_index(card.id), "total": _TOTAL_PAGES}, + for b in page.narration], + "choices": [{"label": c.label} for c in page.choices], + "can_chat": page.can_chat, + "image": IMAGE_MAP.get(first.id), + "progress": {"index": _pager.display_index(last.id), "total": _pager.display_total}, "ending": None, } @@ -133,7 +127,7 @@ def _step_json(ps: PlaySession) -> dict: @app.post("/api/session") async def create_session(): engine = StoryEngine(_repo) - ps = PlaySession(engine=engine, last_step=engine.start(), + ps = PlaySession(engine=engine, last_step=_pager.complete(engine, engine.start()), episode_titles=_episode_titles()) sid = uuid.uuid4().hex[:12] _sessions[sid] = ps @@ -160,7 +154,7 @@ async def advance(sid: str): raise HTTPException(409, "포켓 대화 중 — 먼저 닫을 것") if ps.last_step.ending or ps.last_step.choices: raise HTTPException(409, "advance 불가 상태") - ps.last_step = ps.engine.advance() + ps.last_step = _pager.complete(ps.engine, ps.engine.advance()) _save(sid, ps) return {"step": _step_json(ps)} @@ -174,7 +168,8 @@ async def choose(sid: str, body: ChooseBody): raise HTTPException(409, "선택 불가 상태") if not 0 <= body.index < len(ps.last_step.choices): raise HTTPException(422, "선택지 범위 밖") - picked, ps.last_step = ps.engine.choose(body.index) + 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)} @@ -186,7 +181,7 @@ async def pocket_open(sid: str): ps = _get(sid) if ps.pocket_sid: return {"opened": True} - if ps.last_step.ending or not (ps.last_step.card and ps.last_step.card.can_chat): + 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} diff --git a/engine/schemas/__pycache__/beatcard.cpython-312.pyc b/engine/schemas/__pycache__/beatcard.cpython-312.pyc index b610313a0712a790bbb8acfe47a7e104f92cabe2..35dc27ed93363e0c3d676bd3348048a8d5e5cec8 100644 Binary files a/engine/schemas/__pycache__/beatcard.cpython-312.pyc and b/engine/schemas/__pycache__/beatcard.cpython-312.pyc differ diff --git a/engine/schemas/__pycache__/beatcard.cpython-314.pyc b/engine/schemas/__pycache__/beatcard.cpython-314.pyc index 8417c5c224d4dc01f754dbac650c1fc5b7059414..35d877fdae35f444f61fc420fda8fdd41428e8a4 100644 Binary files a/engine/schemas/__pycache__/beatcard.cpython-314.pyc and b/engine/schemas/__pycache__/beatcard.cpython-314.pyc differ diff --git a/engine/schemas/__pycache__/chapter.cpython-312.pyc b/engine/schemas/__pycache__/chapter.cpython-312.pyc index 61658d76a4bcfd1c5895e9a8469dcb657c08a976..3ecb6594e42ad610ec6e377f0a4d6f973cf5c120 100644 Binary files a/engine/schemas/__pycache__/chapter.cpython-312.pyc and b/engine/schemas/__pycache__/chapter.cpython-312.pyc differ diff --git a/engine/schemas/__pycache__/chapter.cpython-314.pyc b/engine/schemas/__pycache__/chapter.cpython-314.pyc index d57ba007074ea34dbacd00f4c7f595e45762a6d6..6562a0691967a3b87269203493fa2680fd3729a9 100644 Binary files a/engine/schemas/__pycache__/chapter.cpython-314.pyc and b/engine/schemas/__pycache__/chapter.cpython-314.pyc differ diff --git a/engine/schemas/__pycache__/character.cpython-312.pyc b/engine/schemas/__pycache__/character.cpython-312.pyc index 1b8e547e04a4b023dd84e7dad99e03199202eb8f..03c0730ff03f21197d7c9521f0d4b2f5cc55fba1 100644 Binary files a/engine/schemas/__pycache__/character.cpython-312.pyc and b/engine/schemas/__pycache__/character.cpython-312.pyc differ diff --git a/engine/schemas/__pycache__/character.cpython-314.pyc b/engine/schemas/__pycache__/character.cpython-314.pyc index 9e48d4a510c863e4d0c8b1e0f5e91fefb3878d32..7f0189daaddf3e49ee58d745d82b9c9cedd36aec 100644 Binary files a/engine/schemas/__pycache__/character.cpython-314.pyc and b/engine/schemas/__pycache__/character.cpython-314.pyc differ diff --git a/engine/schemas/__pycache__/validate.cpython-312.pyc b/engine/schemas/__pycache__/validate.cpython-312.pyc index e44ac2e3369f67ea418f7c19d758bdbe32c2fbf3..71abd2f3689826e1b6b0f60da8754e70212bf4c5 100644 Binary files a/engine/schemas/__pycache__/validate.cpython-312.pyc and b/engine/schemas/__pycache__/validate.cpython-312.pyc differ diff --git a/engine/schemas/__pycache__/validate.cpython-314.pyc b/engine/schemas/__pycache__/validate.cpython-314.pyc index 1114d47d0f3290e738b0cdf7d9e0ffab418bfdce..93b366060bbb90c55d38565886b8c1b35379b9de 100644 Binary files a/engine/schemas/__pycache__/validate.cpython-314.pyc and b/engine/schemas/__pycache__/validate.cpython-314.pyc differ diff --git a/engine/schemas/chapter.py b/engine/schemas/chapter.py index caad02adfa44688de6875371fa8f64eca964ff62..008fcfd9fa62d6eb0d61a009e6f4011a0bbe329f 100644 --- a/engine/schemas/chapter.py +++ b/engine/schemas/chapter.py @@ -49,6 +49,7 @@ class Chapter(BaseModel): canon_rules: list[str] = Field(default_factory=list) r_cards: list[RCardRef] = Field(default_factory=list) pocket_max_turns: int = 6 # 개입 포켓 자유채팅 턴 상한 (강제 수렴) + page_target_chars: int = 500 # 페이지 병합 목표 글자 수 (넘으면 다음 카드부터 새 페이지) class ChapterFile(BaseModel): diff --git a/engine/services/__pycache__/paging.cpython-312.pyc b/engine/services/__pycache__/paging.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bdf688aadb3bcf95bd304e6ab989b628e7a3a01e Binary files /dev/null and b/engine/services/__pycache__/paging.cpython-312.pyc differ diff --git a/engine/services/__pycache__/paging.cpython-314.pyc b/engine/services/__pycache__/paging.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c1e653068e5340a07fd660042029082c579164c4 Binary files /dev/null and b/engine/services/__pycache__/paging.cpython-314.pyc differ diff --git a/engine/services/__pycache__/pocket.cpython-312.pyc b/engine/services/__pycache__/pocket.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..33d35090a2f8a68211042cebf5e34973674c5ccc Binary files /dev/null and b/engine/services/__pycache__/pocket.cpython-312.pyc differ diff --git a/engine/services/__pycache__/pocket.cpython-314.pyc b/engine/services/__pycache__/pocket.cpython-314.pyc index 20a4db9d9476bdac33370f14e8f9c94344021bd7..b6d3ed284ad7ef0510fca3ed4663ebb80d0c2f8f 100644 Binary files a/engine/services/__pycache__/pocket.cpython-314.pyc and b/engine/services/__pycache__/pocket.cpython-314.pyc differ diff --git a/engine/services/__pycache__/state.cpython-312.pyc b/engine/services/__pycache__/state.cpython-312.pyc index 5f522b8490c38015b5694ed4dba265abf660da63..5ed444b763eceac0be27774451367e0343fa171f 100644 Binary files a/engine/services/__pycache__/state.cpython-312.pyc and b/engine/services/__pycache__/state.cpython-312.pyc differ diff --git a/engine/services/__pycache__/state.cpython-314.pyc b/engine/services/__pycache__/state.cpython-314.pyc index 8c74d59fe9f2fbab8bff8db9b1c7e0dd74672edd..dc4f989a9196681b746360f6e5c4e0dc0670c4a4 100644 Binary files a/engine/services/__pycache__/state.cpython-314.pyc and b/engine/services/__pycache__/state.cpython-314.pyc differ diff --git a/engine/services/__pycache__/story.cpython-312.pyc b/engine/services/__pycache__/story.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..75154716e953b1c1bbccf040dc3746934d6fdadc Binary files /dev/null and b/engine/services/__pycache__/story.cpython-312.pyc differ diff --git a/engine/services/__pycache__/story.cpython-314.pyc b/engine/services/__pycache__/story.cpython-314.pyc index 824f94fd036cbee1ab153d6c4a14f0c8b5dfd9f7..0113ac5b1a6c5c979c073a9d11f321cb15e65487 100644 Binary files a/engine/services/__pycache__/story.cpython-314.pyc and b/engine/services/__pycache__/story.cpython-314.pyc differ diff --git a/engine/services/paging.py b/engine/services/paging.py new file mode 100644 index 0000000000000000000000000000000000000000..57817b2d98bf109c1fa8e4b8973cc9152ac6164a --- /dev/null +++ b/engine/services/paging.py @@ -0,0 +1,124 @@ +"""페이지 병합 — 여러 비트카드를 한 화면(페이지)으로 묶는 결정적 파티션. + +원칙: +- 서사 진행·숨은 상태의 권위는 StoryEngine이 유지한다. Pager는 engine.advance()를 + 페이지 경계까지 반복 호출해 지문을 병합할 뿐, 부수효과를 직접 만들지 않는다 + (카드당 정확히 1회 — 이중/누락 적용 금지) +- 파티션은 콘텐츠(전역 카드 순서)의 순수 함수 — 유저 경로·상태와 무관해 + 페이지 총수(진행도 분모)가 안정적이고, 복원 시에도 같은 페이지가 재구성된다 +- 병합은 선형 링크(card.next == 전역 다음 카드)로 이어진 구간에서만. 강제 분리: + · 선택지/분기(next_options) 카드 뒤 (결정 지점) + · solo 카드(연출 컷·채팅 앵커— 호출자가 지정) 앞뒤 + · 에피소드 경계 +- 소프트 캡: 누적 글자 수가 chapter.page_target_chars 이상이면 다음 카드부터 새 페이지 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional + +from engine.repositories.content import ContentRepository +from engine.schemas.beatcard import BeatCard, Choice, NarrationBlock +from engine.schemas.chapter import EndingRule +from engine.services.story import StepResult, StoryEngine + + +@dataclass +class PageView: + """한 페이지의 렌더 출력 — 라우터가 그대로 JSON으로 만든다.""" + first: Optional[BeatCard] = None # 페이지 제목·에피소드·연출 컷의 기준 + last: Optional[BeatCard] = None # 선택지·채팅·진행 위치(current_id)의 기준 + narration: list[NarrationBlock] = field(default_factory=list) + choices: list[Choice] = field(default_factory=list) + can_chat: bool = False + ending: Optional[EndingRule] = None + + +class Pager: + def __init__(self, repo: ContentRepository, solo_ids: set[str], + cut_ids: set[str], target_chars: int | None = None): + """solo_ids: 단독 페이지 강제 카드(연출 컷+채팅 앵커) · cut_ids: 연출 컷(표시 2페이지).""" + self.repo = repo + self.solo = set(solo_ids) + self.cuts = set(cut_ids) + self.target = target_chars or repo.chapter.page_target_chars + self._build() + + # ── 정적 파티션 ───────────────────────────────────────── + def _build(self) -> None: + cards = self.repo.all_cards() + parts: list[list[str]] = [] + cur: list[str] = [] + cur_chars = 0 + prev: Optional[BeatCard] = None + for card in cards: + if cur and self._break_between(prev, card, cur_chars): + parts.append(cur) + cur, cur_chars = [], 0 + cur.append(card.id) + cur_chars += sum(len(b.text) for b in card.narration) + prev = card + if cur: + parts.append(cur) + + self.partitions = parts + self.part_of = {cid: i for i, part in enumerate(parts) for cid in part} + # 표시 번호: 파티션 = 1페이지, 연출 컷 파티션 = 2페이지(컷+본문) + self._cuts_before = [] + seen = 0 + for part in parts: + self._cuts_before.append(seen) + if part[0] in self.cuts: + seen += 1 + self._total = len(parts) + seen + + def _break_between(self, prev: BeatCard, card: BeatCard, cur_chars: int) -> bool: + if prev.choices or prev.next_options: + return True # 결정 지점 — 페이지는 선택지로 닫힌다 + if prev.next != card.id: + return True # 비선형(분기 변형·점프·에피소드 참조) — 경로 안전을 위해 분리 + if prev.id in self.solo or card.id in self.solo: + return True + if prev.episode_id != card.episode_id: + return True + return cur_chars >= self.target + + # ── 표시 번호 (진행도) ────────────────────────────────── + @property + def display_total(self) -> int: + return self._total + + def display_index(self, card_id: str) -> int: + """카드가 속한 페이지의 본문 표시 번호. 컷 페이지 번호는 이 값 - 1 (기존 계약 유지).""" + p = self.part_of[card_id] + own_cut = 1 if self.partitions[p][0] in self.cuts else 0 + return p + self._cuts_before[p] + own_cut + 1 + + # ── 런타임: 페이지 채우기 (부수효과는 engine.advance가 카드당 1회) ── + def complete(self, engine: StoryEngine, step: StepResult) -> PageView: + """advance/choose/start 직후의 step(페이지 첫 카드)을 파티션 끝까지 채운다.""" + if step.ending: + return PageView(ending=step.ending) + first = step.card + part = self.partitions[self.part_of[first.id]] + narration = list(step.narration) + while engine.current_id != part[-1]: + step = engine.advance() + if step.ending: # 파티션 내 링크는 챕터 안 — 방어적 처리 + return PageView(ending=step.ending) + narration += step.narration + last = step.card + return PageView(first=first, last=last, narration=narration, + choices=list(last.choices), can_chat=last.can_chat) + + # ── 런타임: 복원 렌더 (부수효과 없음) ─────────────────── + def render_current(self, engine: StoryEngine) -> PageView: + """현재 카드(항상 파티션 끝)가 속한 페이지 전체를 재구성 — 이어 읽기 화면.""" + if engine.ending: + return PageView(ending=engine.ending) + part = self.partitions[self.part_of[engine.current_id]] + cards = [self.repo.get_card(cid) for cid in part] + narration = [b for c in cards for b in engine.visible_narration(c)] + return PageView(first=cards[0], last=cards[-1], narration=narration, + choices=list(cards[-1].choices), can_chat=cards[-1].can_chat) diff --git a/engine/services/story.py b/engine/services/story.py index 1717064b7764f2a745ba9f75638207eed122f709..306b1713d2cb6be152e76df1f72886ef89f243d9 100644 --- a/engine/services/story.py +++ b/engine/services/story.py @@ -91,7 +91,7 @@ class StoryEngine: if self.ending: return StepResult(ending=self.ending) card = self.repo.get_card(self.current_id) - return StepResult(card=card, narration=self._visible_narration(card), + return StepResult(card=card, narration=self.visible_narration(card), choices=list(card.choices), is_pocket=card.is_pocket) # ── 진행 ──────────────────────────────────────────────── @@ -114,7 +114,7 @@ class StoryEngine: if card.axis and not any(ch.axis for ch in card.choices): self.state.apply_axis(card.axis, source=card_id) - return StepResult(card=card, narration=self._visible_narration(card), + return StepResult(card=card, narration=self.visible_narration(card), choices=list(card.choices), is_pocket=card.is_pocket) def advance(self) -> StepResult: @@ -164,7 +164,7 @@ class StoryEngine: return StepResult(ending=rule) raise RuntimeError("default 엔딩 규칙 없음 — 콘텐츠 결함") - # ── 지문 게이트 (엔진 D: 심층 해금) ───────────────────── - def _visible_narration(self, card: BeatCard) -> list[NarrationBlock]: + # ── 지문 게이트 (엔진 D: 심층 해금) — Pager 등 렌더 계층도 사용 ── + def visible_narration(self, card: BeatCard) -> list[NarrationBlock]: return [b for b in card.narration if b.gate == "all" or (b.gate == "trust_high" and self.state.deep_unlock)] diff --git a/engine/sub_agents/character/__pycache__/agent.cpython-312.pyc b/engine/sub_agents/character/__pycache__/agent.cpython-312.pyc index a85e0d3a124de2f239fa3e1da972e291ad095232..57f127712ebae07a955795f853aaf67af0a35f45 100644 Binary files a/engine/sub_agents/character/__pycache__/agent.cpython-312.pyc and b/engine/sub_agents/character/__pycache__/agent.cpython-312.pyc differ diff --git a/engine/sub_agents/character/__pycache__/agent.cpython-314.pyc b/engine/sub_agents/character/__pycache__/agent.cpython-314.pyc index 98c9f9efed83da0acb0394ddcda1ddcd8dbb6707..c8484d5bd2c8c8a7fc4afe0efd2b3ea78f09d880 100644 Binary files a/engine/sub_agents/character/__pycache__/agent.cpython-314.pyc and b/engine/sub_agents/character/__pycache__/agent.cpython-314.pyc differ diff --git a/engine/sub_agents/director/__pycache__/agent.cpython-312.pyc b/engine/sub_agents/director/__pycache__/agent.cpython-312.pyc index 1c69db1ddf1faa5bdab9b016938b46b3bb5b812d..59766cf7548c382958427cb9f3936e96f66ae864 100644 Binary files a/engine/sub_agents/director/__pycache__/agent.cpython-312.pyc and b/engine/sub_agents/director/__pycache__/agent.cpython-312.pyc differ diff --git a/engine/sub_agents/director/__pycache__/agent.cpython-314.pyc b/engine/sub_agents/director/__pycache__/agent.cpython-314.pyc index 2328c2d830770ce6506eb9109058da6f2642fd09..82f7253830086127482eb6baf39249038de4a7ed 100644 Binary files a/engine/sub_agents/director/__pycache__/agent.cpython-314.pyc and b/engine/sub_agents/director/__pycache__/agent.cpython-314.pyc differ diff --git a/engine/tools/state/__pycache__/finish_pocket.cpython-312.pyc b/engine/tools/state/__pycache__/finish_pocket.cpython-312.pyc index d37795a311fe440d732a7fcde9e598acc5e6cfd2..7fa7e48b41424f8cdb708081e03584c3f0bd2af6 100644 Binary files a/engine/tools/state/__pycache__/finish_pocket.cpython-312.pyc and b/engine/tools/state/__pycache__/finish_pocket.cpython-312.pyc differ diff --git a/engine/tools/state/__pycache__/finish_pocket.cpython-314.pyc b/engine/tools/state/__pycache__/finish_pocket.cpython-314.pyc index 75b71732d2a357b9fa49aad6ae696497426d793e..df23273619b306d3e8f78ec269025cd344947425 100644 Binary files a/engine/tools/state/__pycache__/finish_pocket.cpython-314.pyc and b/engine/tools/state/__pycache__/finish_pocket.cpython-314.pyc differ diff --git a/engine/tools/state/__pycache__/mark_beat.cpython-312.pyc b/engine/tools/state/__pycache__/mark_beat.cpython-312.pyc index 3be7a90a6d5377598385144a7b807872da338ba1..4d8db538f0f7a9f325eef51ea24a9676aebab711 100644 Binary files a/engine/tools/state/__pycache__/mark_beat.cpython-312.pyc and b/engine/tools/state/__pycache__/mark_beat.cpython-312.pyc differ diff --git a/engine/tools/state/__pycache__/mark_beat.cpython-314.pyc b/engine/tools/state/__pycache__/mark_beat.cpython-314.pyc index 22eab7e11bb4667abc81a5a1d0c3fb5b1040839a..0e7a3656d8e5fff6c157dc5517f6af8f6de13158 100644 Binary files a/engine/tools/state/__pycache__/mark_beat.cpython-314.pyc and b/engine/tools/state/__pycache__/mark_beat.cpython-314.pyc differ diff --git a/engine/tools/state/__pycache__/update_trust.cpython-312.pyc b/engine/tools/state/__pycache__/update_trust.cpython-312.pyc index bf5f7d4c513852d90c49b6e7fbb52ee3d8837208..1aeba878f1f407649141e43cb0f2a131b9f2fa8a 100644 Binary files a/engine/tools/state/__pycache__/update_trust.cpython-312.pyc and b/engine/tools/state/__pycache__/update_trust.cpython-312.pyc differ diff --git a/engine/tools/state/__pycache__/update_trust.cpython-314.pyc b/engine/tools/state/__pycache__/update_trust.cpython-314.pyc index 1cb3d56ae41f58e092d0b603c4f7ce62b3bcd200..4e532c2180ca0f91a18919d1a8ab201d1a11eeef 100644 Binary files a/engine/tools/state/__pycache__/update_trust.cpython-314.pyc and b/engine/tools/state/__pycache__/update_trust.cpython-314.pyc differ