File size: 11,979 Bytes
f646e0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d46a91
f646e0b
7d46a91
f646e0b
 
 
 
 
 
 
 
 
 
 
 
4105b78
f646e0b
 
e68cec5
 
 
 
 
 
 
 
 
 
 
 
6e20b9b
 
e68cec5
 
 
 
 
 
 
 
 
 
 
 
 
f646e0b
 
 
 
c609294
 
 
7d46a91
 
c609294
f646e0b
e68cec5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f646e0b
 
 
 
 
 
 
 
 
7d46a91
f646e0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d46a91
f646e0b
 
 
 
 
 
 
 
 
 
 
7d46a91
 
f646e0b
e68cec5
7d46a91
 
 
 
f646e0b
7d46a91
 
 
e68cec5
6e20b9b
e68cec5
 
6e20b9b
7d46a91
 
 
e68cec5
7d46a91
f646e0b
 
 
 
 
 
 
 
7d46a91
f646e0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d46a91
f646e0b
 
 
 
 
 
 
 
 
 
 
 
 
7d46a91
 
f646e0b
 
 
 
 
 
 
 
 
 
 
7d46a91
f646e0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e68cec5
 
f646e0b
 
 
 
 
f8d9250
 
 
f646e0b
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
"""FastAPI μ—”λ“œν¬μΈνŠΈ β€” HTTP만 λ‹΄λ‹Ή (계약: docs/contract/demo_api.md).

μ„œμ‚¬ νŒλ‹¨μ€ μ „λΆ€ services에 μœ„μž„ν•œλ‹€. μˆ¨μ€ μƒνƒœλŠ” μ–΄λ–€ 응닡에도 μ‹£μ§€ μ•ŠλŠ”λ‹€.
μ‹€ν–‰: .venv/bin/uvicorn engine.router:app --port 8777
"""

from __future__ import annotations

import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional

from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel

from engine.plugins.logging import TurnLoggingPlugin
from engine.repositories.content import ContentRepository
from engine.repositories.play_session import PlaySessionRepository
from engine.repositories.playlog import PlaylogRepository
from engine.services.paging import Pager, PageView
from engine.services.pocket import PocketService
from engine.services.story import StoryEngine

ROOT = Path(__file__).resolve().parent.parent

# μ—°μΆœ 이미지 (λŒ€ν‘œλ‹˜ μ‚°μΆœ 에셋 β€” 읽기 μ „μš©). 맀핑은 μ—°μΆœ κ²°μ •μ΄λ―€λ‘œ μ—¬κΈ°(ν”„λ ˆμ  ν…Œμ΄μ…˜ 계측)에 λ‘”λ‹€.
# 데λͺ¨/배포용으둜 IMAGE_MAP이 μ“°λŠ” 6μž₯만 web/assets/에 슬림 볡사 (원본 95MB ν΄λ”λŠ” λŸ°νƒ€μž„ λΆˆν•„μš”).
# 원본 κ°±μ‹  μ‹œ: scripts/sync_assets.py μž¬μ‹€ν–‰ (μ›μ²œμ€ μΉ΄λ₯΄λ°€λΌ/…/이미지/)
ASSET_DIR = ROOT / "web" / "assets"
COVER_IMAGE = "/assets/ν”Œλž˜μ΄ 이미지/1.png"
IMAGE_MAP = {
    "E01-07": "/assets/ν”Œλž˜μ΄ 이미지/4.png",   # 6μ„Έμ˜ λ°€ β€” "κ·Έλ…€λŠ” 미동도 없이 λ‚˜λ₯Ό λ‚΄λ €λ‹€λ³΄μ•˜λ‹€"
    "E03-03": "/assets/인물/μΉ΄λ₯΄λ°€λΌ.png",      # 재회 β€” 같은 꿈의 λŒ€λ©΄
    "E06-09": "/assets/인물/μΉ΄λ₯΄λ°€λΌ κ°€λ©΄.png", # λ‘˜λ§Œμ˜ λΉ„λ°€ β€” κ°€μž₯ μœ„ν—˜ν•œ 닀정함
    # E05-02(μ„±)Β·E06-10(μΉ¨μ‹€) 컷은 제거 β€” μž₯μ†Œ λ°°κ²½κ³Ό 쀑볡이라 μ»· νŽ˜μ΄μ§€κ°€ ꡰ더더기 (μœ μ € κ²°μ •)
}

# 인트둜 μ‹œν€€μŠ€ β€” ν‘œμ§€ "밀을 μ—°λ‹€" λ’€, μƒˆ μ‹œμž‘μ—λ§Œ (이어 μ½κΈ°λŠ” κ±΄λ„ˆλœ€)
INTRO_IMAGES = [
    "/assets/μ˜€λ…Έλ§ˆxμ†Œλ½_λ°©ν–₯/κ°œμš”2.png",  # μž‘ν’ˆ μ†Œκ°œ
    "/assets/μ˜€λ…Έλ§ˆxμ†Œλ½_λ°©ν–₯/κ°œμš”3.png",  # λ“±μž₯인물 μ†Œκ°œ
    "/assets/ν”Œλž˜μ΄ 이미지/2.png",         # 1μž₯ μ‹œμž‘
]

# ν™”μž μ΄ˆμƒ (λΉ„μ£Όμ–Ό 노벨 λ°œν™” β€” μ°Έκ³  μ—°μΆœ: ν”Œλž˜μ΄ 이미지/7.png). character_dialogue의
# speaker κΈ°μ€€. λ§€ν•‘ μ—†λŠ” ν™”μž(ν•˜μΈΒ·λ°±μž‘λΆ€μΈΒ·μ•½μž₯수 λ“±)λŠ” μ΄λ¦„ν‘œλ§Œ ν‘œμ‹œλœλ‹€
SPEAKER_PORTRAIT = {
    "μΉ΄λ₯΄λ°€λΌ": "/assets/인물/μΉ΄λ₯΄λ°€λΌ.png",
    "아버지": "/assets/인물/아버지.png",
    # λ§ˆλ‹΄Β·λ§ˆλ“œλͺ¨μ•„저은 μ „λ©΄ μŠ€ν…Œμ΄μ§€ λŒ€μƒμ—μ„œ μ œμ™Έ (μœ μ € κ²°μ • β€” 앙상블 μž₯면은
    # λ³Έλ¬Έ νλ¦„μœΌλ‘œ. 에셋 맀핑도 μΆ”μ •μ΄μ—ˆμŒ). ν•„μš” μ‹œ μ—¬κΈ° ν•œ μ€„λ‘œ 볡원
}

# μž₯μ†Œλ³„ λ°°κ²½ (λΉ„μ£Όμ–Ό 노벨) β€” μΉ΄λ“œ νƒœκ·Έ `#μž₯μ†Œ/X` κΈ°μ€€. λ§€ν•‘ μ—†λŠ” μž₯μ†Œ(μ •μ›Β·ν™”λž‘Β·μ„œμž¬ λ“±)λŠ”
# 직전 λ°°κ²½ μœ μ§€. 에셋이 생기면 μ—¬κΈ° ν•œ 쀄 μΆ”κ°€ + scripts/sync_assets.py κ°±μ‹ 
BACKGROUND_MAP = {
    "μΉ¨μ‹€": "/assets/λ°°κ²½/μΉ¨μ‹€.png",
    "λ§Œμ°¬ν™€": "/assets/λ°°κ²½/식당.png",
    "숲길": "/assets/λ°°κ²½/μ•ˆκ°œ 숲.png",
    "ν•΄μž": "/assets/λ°°κ²½/μ„±.png",
    "νν—ˆ": "/assets/λ°°κ²½/νν—ˆμ„±λ‹Ή.png",
    "μ˜ˆλ°°λ‹Ή": "/assets/λ°°κ²½/νν—ˆμ„±λ‹Ή.png",
}

app = FastAPI(title="carmilla-demo")

_repo = ContentRepository(ROOT / "content")

# νŽ˜μ΄μ§€ 병합 β€” μ—¬λŸ¬ μΉ΄λ“œλ₯Ό ν•œ ν™”λ©΄μœΌλ‘œ (밀도: chapter.page_target_chars).
# μ—°μΆœ μ»·Β·μ±„νŒ… μ•΅μ»€λŠ” 단독 νŽ˜μ΄μ§€. 컷도 ν‘œμ‹œ 1νŽ˜μ΄μ§€(ν”„λ‘ νŠΈκ°€ 이미지 μΊ”λ²„μŠ€ λ‹¨κ³„λ‘œ λ Œλ” β€”
# 단독 μΊ‘μ…˜ ν™”λ©΄ μ—†μŒ). νŒŒν‹°μ…˜μ€ 결정적(경둜 무관)
_CUT_IDS = {cid for cid in IMAGE_MAP if _repo.has_card(cid)}
_SOLO_IDS = _CUT_IDS | {c.id for c in _repo.all_cards() if c.can_chat}
_pager = Pager(_repo, solo_ids=_SOLO_IDS, cut_ids=set())


def _partition_backgrounds() -> list[Optional[str]]:
    """νŒŒν‹°μ…˜λ³„ 유효 λ°°κ²½ β€” μΉ΄λ“œμ˜ 첫 `#μž₯μ†Œ/` νƒœκ·Έκ°€ λ§€ν•‘λ˜λ©΄ κ°±μ‹ , μ•„λ‹ˆλ©΄ 직전 λ°°κ²½ μœ μ§€.
    νŒŒν‹°μ…˜ μˆœμ„œ(μ „μ—­)의 순수 ν•¨μˆ˜λΌ κ²½λ‘œΒ·λ³΅μ›κ³Ό λ¬΄κ΄€ν•˜κ²Œ 결정적."""
    bgs: list[Optional[str]] = []
    current: Optional[str] = None
    for part in _pager.partitions:
        for cid in part:
            place = next((t.split("/", 1)[1] for t in _repo.get_card(cid).tags
                          if t.startswith("#μž₯μ†Œ/")), None)
            if place and place in BACKGROUND_MAP:
                current = BACKGROUND_MAP[place]
        bgs.append(current)
    return bgs


_PART_BG = _partition_backgrounds()

_pocket = PocketService(
    _repo, plugins=[TurnLoggingPlugin(PlaylogRepository(ROOT / "logs" / "playlog.jsonl"))])
_playdb = PlaySessionRepository(ROOT / "data" / "sessions.db")
_playdb.purge(days=30)


@dataclass
class PlaySession:
    engine: StoryEngine
    last_step: PageView
    pocket_sid: Optional[str] = None
    episode_titles: dict = field(default_factory=dict)


_sessions: dict[str, PlaySession] = {}


class ChooseBody(BaseModel):
    index: int


class TurnBody(BaseModel):
    text: str


def _get(sid: str) -> PlaySession:
    ps = _sessions.get(sid)
    if ps is None:
        ps = _restore(sid)  # μ„œλ²„ μž¬μ‹œμž‘ ν›„ β€” μ €μž₯μ†Œμ—μ„œ 볡원
    if ps is None:
        raise HTTPException(404, "μ„Έμ…˜ μ—†μŒ")
    return ps


def _restore(sid: str) -> Optional[PlaySession]:
    """μ €μž₯ μŠ€λƒ…μƒ·μ—μ„œ μ„Έμ…˜ 볡원. 포켓은 폐기(λΉ„μ˜μ† β€” μœ μ € κ²°μ •), 읽기 ν™”λ©΄λΆ€ν„°."""
    payload = _playdb.load(sid, _repo.content_version)
    if payload is None:
        return None
    engine = StoryEngine.restore(_repo, payload)
    ps = PlaySession(engine=engine, last_step=_pager.render_current(engine),
                     episode_titles=_episode_titles())
    _sessions[sid] = ps
    return ps


def _save(sid: str, ps: PlaySession) -> None:
    """write-through β€” μ˜μ† μƒνƒœκ°€ λ°”λ€ŒλŠ” λͺ¨λ“  μ§€μ μ—μ„œ μ¦‰μ‹œ μ €μž₯."""
    _playdb.save(sid, ps.engine.snapshot(), _repo.content_version)


def _step_json(ps: PlaySession) -> dict:
    page = ps.last_step
    if page.ending is not None:
        return {"card": None, "narration": [], "choices": [], "can_chat": False,
                "image": None, "background": None,  # 엔딩은 배경을 κ±·μ–΄λ‚Έλ‹€
                "progress": {"index": _pager.display_total, "total": _pager.display_total},
                "ending": {"id": page.ending.id, "label": page.ending.label,
                           "description": page.ending.description}}
    first, last = page.first, page.last
    return {
        # idλŠ” νŽ˜μ΄μ§€ 끝 μΉ΄λ“œ(μ„ νƒΒ·μ±„νŒ…Β·μ§„ν–‰ κΈ°μ€€), 제λͺ©Β·μ—ν”Όμ†Œλ“œΒ·μ»·μ€ 첫 μΉ΄λ“œ κΈ°μ€€
        "card": {"id": last.id, "title": first.title, "episode": first.episode_id,
                 "episode_title": ps.episode_titles.get(first.episode_id, first.episode_id)},
        "narration": [{"type": b.type, "text": b.text, "speaker": b.speaker,
                       "role": b.role, "card": cid,
                       "portrait": (SPEAKER_PORTRAIT.get(b.speaker)
                                    if b.type == "character_dialogue" else None)}
                      for b, cid in zip(page.narration, page.narration_cards)],
        "choices": [{"label": c.label} for c in page.choices],
        "can_chat": page.can_chat,
        "image": IMAGE_MAP.get(first.id),
        "background": _PART_BG[_pager.part_of[last.id]],
        "progress": {"index": _pager.display_index(last.id), "total": _pager.display_total},
        "ending": None,
    }


# ── μ„Έμ…˜ ────────────────────────────────────────────────────
@app.post("/api/session")
async def create_session():
    engine = StoryEngine(_repo)
    ps = PlaySession(engine=engine, last_step=_pager.complete(engine, engine.start()),
                     episode_titles=_episode_titles())
    sid = uuid.uuid4().hex[:12]
    _sessions[sid] = ps
    _save(sid, ps)
    return {"session_id": sid, "step": _step_json(ps)}


def _episode_titles() -> dict:
    from engine.schemas.validate import load_content
    episodes, *_ = load_content(_repo.root)
    return {ep.episode.id: ep.episode.title for ep in episodes}


@app.get("/api/session/{sid}")
async def get_session(sid: str):
    return {"step": _step_json(_get(sid))}


# ── 리딩 ────────────────────────────────────────────────────
@app.post("/api/session/{sid}/advance")
async def advance(sid: str):
    ps = _get(sid)
    if ps.pocket_sid:
        raise HTTPException(409, "포켓 λŒ€ν™” 쀑 β€” λ¨Όμ € 닫을 것")
    if ps.last_step.ending or ps.last_step.choices:
        raise HTTPException(409, "advance λΆˆκ°€ μƒνƒœ")
    ps.last_step = _pager.complete(ps.engine, ps.engine.advance())
    _save(sid, ps)
    return {"step": _step_json(ps)}


@app.post("/api/session/{sid}/choose")
async def choose(sid: str, body: ChooseBody):
    ps = _get(sid)
    if ps.pocket_sid:
        raise HTTPException(409, "포켓 λŒ€ν™” 쀑 β€” λ¨Όμ € 닫을 것")
    if ps.last_step.ending or not ps.last_step.choices:
        raise HTTPException(409, "선택 λΆˆκ°€ μƒνƒœ")
    if not 0 <= body.index < len(ps.last_step.choices):
        raise HTTPException(422, "선택지 λ²”μœ„ λ°–")
    picked, step = ps.engine.choose(body.index)
    ps.last_step = _pager.complete(ps.engine, step)
    _save(sid, ps)
    return {"picked": {"label": picked.label, "result": picked.result},
            "step": _step_json(ps)}


# ── κ°œμž… 포켓 ───────────────────────────────────────────────
@app.post("/api/session/{sid}/pocket/open")
async def pocket_open(sid: str):
    ps = _get(sid)
    if ps.pocket_sid:
        return {"opened": True}
    if ps.last_step.ending or not ps.last_step.can_chat:
        raise HTTPException(409, "이 μΉ΄λ“œλŠ” μžμœ μ±„νŒ… 포켓이 μ•„λ‹˜")
    ps.pocket_sid = await _pocket.open(ps.engine.current_id, ps.engine.state)
    return {"opened": True}


@app.post("/api/session/{sid}/pocket/turn")
async def pocket_turn(sid: str, body: TurnBody):
    ps = _get(sid)
    if not ps.pocket_sid:
        raise HTTPException(409, "포켓이 μ—΄λ € μžˆμ§€ μ•ŠμŒ")
    turn = await _pocket.run_turn(ps.pocket_sid, body.text)
    if turn.converged:  # 수렴 β†’ μžλ™ 컀밋·볡귀
        await _pocket.close(ps.pocket_sid, ps.engine.state)
        ps.pocket_sid = None
        _save(sid, ps)  # 포켓 μ»€λ°‹μœΌλ‘œ μˆ¨μ€ μƒνƒœ λ³€κ²½
    return {"reply": turn.reply, "converged": turn.converged,
            "reason": turn.reason, "turn_no": turn.turn_no}


@app.post("/api/session/{sid}/pocket/close")
async def pocket_close(sid: str):
    ps = _get(sid)
    if ps.pocket_sid:
        await _pocket.close(ps.pocket_sid, ps.engine.state)
        ps.pocket_sid = None
        _save(sid, ps)
    return {"closed": True}


# ── 메타 (ν‘œμ§€ λ“± ν”„λ ˆμ  ν…Œμ΄μ…˜ μƒμˆ˜) ────────────────────────
@app.get("/api/meta")
async def meta():
    return {"cover_image": COVER_IMAGE, "title": "μΉ΄λ₯΄λ°€λΌ", "subtitle": "μž λ“€μ§€ μ•ŠλŠ” λ°€ Β· λ„μž… 아크",
            "intro_images": INTRO_IMAGES}


# ── 정적 ν”„λ‘ νŠΈ ─────────────────────────────────────────────
@app.get("/")
async def index():
    # no-cache: λ§€ μ ‘μ†λ§ˆλ‹€ μž¬κ²€μ¦ β€” 배포 ν›„ λΈŒλΌμš°μ €κ°€ μ˜› HTML을 계속 λ³΄μ—¬μ£ΌλŠ” 사고 λ°©μ§€
    return FileResponse(ROOT / "web" / "index.html",
                        headers={"Cache-Control": "no-cache"})


app.mount("/static", StaticFiles(directory=ROOT / "web"), name="static")
app.mount("/assets", StaticFiles(directory=ASSET_DIR), name="assets")