Junhoee commited on
Commit
e68cec5
Β·
verified Β·
1 Parent(s): 7d46a91

Upload 140 files

Browse files
.gitattributes CHANGED
@@ -39,3 +39,12 @@ web/assets/인물/μΉ΄λ₯΄λ°€λΌ[[:space:]]κ°€λ©΄.png filter=lfs diff=lfs merge=lf
39
  web/assets/인물/μΉ΄λ₯΄λ°€λΌ.png filter=lfs diff=lfs merge=lfs -text
40
  web/assets/ν”Œλž˜μ΄[[:space:]]이미지/1.png filter=lfs diff=lfs merge=lfs -text
41
  web/assets/ν”Œλž˜μ΄[[:space:]]이미지/4.png filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
39
  web/assets/인물/μΉ΄λ₯΄λ°€λΌ.png filter=lfs diff=lfs merge=lfs -text
40
  web/assets/ν”Œλž˜μ΄[[:space:]]이미지/1.png filter=lfs diff=lfs merge=lfs -text
41
  web/assets/ν”Œλž˜μ΄[[:space:]]이미지/4.png filter=lfs diff=lfs merge=lfs -text
42
+ web/assets/λ°°κ²½/식당.png filter=lfs diff=lfs merge=lfs -text
43
+ web/assets/λ°°κ²½/μ•ˆκ°œ[[:space:]]숲.png filter=lfs diff=lfs merge=lfs -text
44
+ web/assets/λ°°κ²½/νν—ˆμ„±λ‹Ή.png filter=lfs diff=lfs merge=lfs -text
45
+ web/assets/μ˜€λ…Έλ§ˆxμ†Œλ½_λ°©ν–₯/κ°œμš”2.png filter=lfs diff=lfs merge=lfs -text
46
+ web/assets/μ˜€λ…Έλ§ˆxμ†Œλ½_λ°©ν–₯/κ°œμš”3.png filter=lfs diff=lfs merge=lfs -text
47
+ web/assets/인물/가정ꡐ사.png filter=lfs diff=lfs merge=lfs -text
48
+ web/assets/인물/보λͺ¨.png filter=lfs diff=lfs merge=lfs -text
49
+ web/assets/인물/아버지.png filter=lfs diff=lfs merge=lfs -text
50
+ web/assets/ν”Œλž˜μ΄[[:space:]]이미지/2.png filter=lfs diff=lfs merge=lfs -text
content/.DS_Store ADDED
Binary file (6.15 kB). View file
 
content/beatcards/E01.yaml CHANGED
@@ -104,7 +104,7 @@ cards:
104
 
105
  *β€” (μ˜€ν›„μ˜ ν•΄μž μ‚°μ±…. μœ μ €κ°€ 무엇을 묻든 μ•„λ²„μ§€λŠ” μžμƒν•˜κ²Œ μ‘ν•œλ‹€. μ–΄λ¨Έλ‹ˆΒ·μ™Έλ‘œμ›€μœΌλ‘œ ν™”μ œκ°€ λ‹ΏμœΌλ©΄, κ·ΈλŠ” 닡을 λ‹€ μž‡μ§€ λͺ»ν•œλ‹€.)*'
106
  - type: character_dialogue
107
- speaker: μœ μ €
108
  text: μ–΄λ¨Έλ‹ˆλŠ”β€¦ μ–΄λ–€ λΆ„μ΄μ…¨μ–΄μš”?
109
  anchor: true
110
  - type: prose
 
104
 
105
  *β€” (μ˜€ν›„μ˜ ν•΄μž μ‚°μ±…. μœ μ €κ°€ 무엇을 묻든 μ•„λ²„μ§€λŠ” μžμƒν•˜κ²Œ μ‘ν•œλ‹€. μ–΄λ¨Έλ‹ˆΒ·μ™Έλ‘œμ›€μœΌλ‘œ ν™”μ œκ°€ λ‹ΏμœΌλ©΄, κ·ΈλŠ” 닡을 λ‹€ μž‡μ§€ λͺ»ν•œλ‹€.)*'
106
  - type: character_dialogue
107
+ speaker: 둜라
108
  text: μ–΄λ¨Έλ‹ˆλŠ”β€¦ μ–΄λ–€ λΆ„μ΄μ…¨μ–΄μš”?
109
  anchor: true
110
  - type: prose
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/router.py CHANGED
@@ -39,6 +39,33 @@ IMAGE_MAP = {
39
  "E06-10": "/assets/λ°°κ²½/μΉ¨μ‹€.png", # 도달점 β€” 두 점 발견
40
  }
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  app = FastAPI(title="carmilla-demo")
43
 
44
  _repo = ContentRepository(ROOT / "content")
@@ -49,6 +76,24 @@ _CUT_IDS = {cid for cid in IMAGE_MAP if _repo.has_card(cid)}
49
  _SOLO_IDS = _CUT_IDS | {c.id for c in _repo.all_cards() if c.can_chat}
50
  _pager = Pager(_repo, solo_ids=_SOLO_IDS, cut_ids=_CUT_IDS)
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  _pocket = PocketService(
53
  _repo, plugins=[TurnLoggingPlugin(PlaylogRepository(ROOT / "logs" / "playlog.jsonl"))])
54
  _playdb = PlaySessionRepository(ROOT / "data" / "sessions.db")
@@ -104,7 +149,7 @@ def _step_json(ps: PlaySession) -> dict:
104
  page = ps.last_step
105
  if page.ending is not None:
106
  return {"card": None, "narration": [], "choices": [], "can_chat": False,
107
- "image": None,
108
  "progress": {"index": _pager.display_total, "total": _pager.display_total},
109
  "ending": {"id": page.ending.id, "label": page.ending.label,
110
  "description": page.ending.description}}
@@ -113,11 +158,14 @@ def _step_json(ps: PlaySession) -> dict:
113
  # idλŠ” νŽ˜μ΄μ§€ 끝 μΉ΄λ“œ(μ„ νƒΒ·μ±„νŒ…Β·μ§„ν–‰ κΈ°μ€€), 제λͺ©Β·μ—ν”Όμ†Œλ“œΒ·μ»·μ€ 첫 μΉ΄λ“œ κΈ°μ€€
114
  "card": {"id": last.id, "title": first.title, "episode": first.episode_id,
115
  "episode_title": ps.episode_titles.get(first.episode_id, first.episode_id)},
116
- "narration": [{"type": b.type, "text": b.text, "speaker": b.speaker}
 
 
117
  for b in page.narration],
118
  "choices": [{"label": c.label} for c in page.choices],
119
  "can_chat": page.can_chat,
120
  "image": IMAGE_MAP.get(first.id),
 
121
  "progress": {"index": _pager.display_index(last.id), "total": _pager.display_total},
122
  "ending": None,
123
  }
@@ -214,7 +262,8 @@ async def pocket_close(sid: str):
214
  # ── 메타 (ν‘œμ§€ λ“± ν”„λ ˆμ  ν…Œμ΄μ…˜ μƒμˆ˜) ────────────────────────
215
  @app.get("/api/meta")
216
  async def meta():
217
- return {"cover_image": COVER_IMAGE, "title": "μΉ΄λ₯΄λ°€λΌ", "subtitle": "μž λ“€μ§€ μ•ŠλŠ” λ°€ Β· λ„μž… 아크"}
 
218
 
219
 
220
  # ── 정적 ν”„λ‘ νŠΈ ─────────────────────────────────────────────
 
39
  "E06-10": "/assets/λ°°κ²½/μΉ¨μ‹€.png", # 도달점 β€” 두 점 발견
40
  }
41
 
42
+ # 인트둜 μ‹œν€€μŠ€ β€” ν‘œμ§€ "밀을 μ—°λ‹€" λ’€, μƒˆ μ‹œμž‘μ—λ§Œ (이어 μ½κΈ°λŠ” κ±΄λ„ˆλœ€)
43
+ INTRO_IMAGES = [
44
+ "/assets/μ˜€λ…Έλ§ˆxμ†Œλ½_λ°©ν–₯/κ°œμš”2.png", # μž‘ν’ˆ μ†Œκ°œ
45
+ "/assets/μ˜€λ…Έλ§ˆxμ†Œλ½_λ°©ν–₯/κ°œμš”3.png", # λ“±μž₯인물 μ†Œκ°œ
46
+ "/assets/ν”Œλž˜μ΄ 이미지/2.png", # 1μž₯ μ‹œμž‘
47
+ ]
48
+
49
+ # ν™”μž μ΄ˆμƒ (λΉ„μ£Όμ–Ό 노벨 λ°œν™” β€” μ°Έκ³  μ—°μΆœ: ν”Œλž˜μ΄ 이미지/7.png). character_dialogue의
50
+ # speaker κΈ°μ€€. λ§€ν•‘ μ—†λŠ” ν™”μž(ν•˜μΈΒ·λ°±μž‘λΆ€μΈΒ·μ•½μž₯수 λ“±)λŠ” μ΄λ¦„ν‘œλ§Œ ν‘œμ‹œλœλ‹€
51
+ SPEAKER_PORTRAIT = {
52
+ "μΉ΄λ₯΄λ°€λΌ": "/assets/인물/μΉ΄λ₯΄λ°€λΌ.png",
53
+ "아버지": "/assets/인물/아버지.png",
54
+ "λ§ˆλ“œλͺ¨μ•„μ €": "/assets/인물/가정ꡐ사.png",
55
+ "λ§ˆλ‹΄": "/assets/인물/보λͺ¨.png",
56
+ }
57
+
58
+ # μž₯μ†Œλ³„ λ°°κ²½ (λΉ„μ£Όμ–Ό 노벨) β€” μΉ΄λ“œ νƒœκ·Έ `#μž₯μ†Œ/X` κΈ°μ€€. λ§€ν•‘ μ—†λŠ” μž₯μ†Œ(μ •μ›Β·ν™”λž‘Β·μ„œμž¬ λ“±)λŠ”
59
+ # 직전 λ°°κ²½ μœ μ§€. 에셋이 생기면 μ—¬κΈ° ν•œ 쀄 μΆ”κ°€ + scripts/sync_assets.py κ°±μ‹ 
60
+ BACKGROUND_MAP = {
61
+ "μΉ¨μ‹€": "/assets/λ°°κ²½/μΉ¨μ‹€.png",
62
+ "λ§Œμ°¬ν™€": "/assets/λ°°κ²½/식당.png",
63
+ "숲길": "/assets/λ°°κ²½/μ•ˆκ°œ 숲.png",
64
+ "ν•΄μž": "/assets/λ°°κ²½/μ„±.png",
65
+ "νν—ˆ": "/assets/λ°°κ²½/νν—ˆμ„±λ‹Ή.png",
66
+ "μ˜ˆλ°°λ‹Ή": "/assets/λ°°κ²½/νν—ˆμ„±λ‹Ή.png",
67
+ }
68
+
69
  app = FastAPI(title="carmilla-demo")
70
 
71
  _repo = ContentRepository(ROOT / "content")
 
76
  _SOLO_IDS = _CUT_IDS | {c.id for c in _repo.all_cards() if c.can_chat}
77
  _pager = Pager(_repo, solo_ids=_SOLO_IDS, cut_ids=_CUT_IDS)
78
 
79
+
80
+ def _partition_backgrounds() -> list[Optional[str]]:
81
+ """νŒŒν‹°μ…˜λ³„ 유효 λ°°κ²½ β€” μΉ΄λ“œμ˜ 첫 `#μž₯μ†Œ/` νƒœκ·Έκ°€ λ§€ν•‘λ˜λ©΄ κ°±μ‹ , μ•„λ‹ˆλ©΄ 직전 λ°°κ²½ μœ μ§€.
82
+ νŒŒν‹°μ…˜ μˆœμ„œ(μ „μ—­)의 순수 ν•¨μˆ˜λΌ κ²½λ‘œΒ·λ³΅μ›κ³Ό λ¬΄κ΄€ν•˜κ²Œ 결정적."""
83
+ bgs: list[Optional[str]] = []
84
+ current: Optional[str] = None
85
+ for part in _pager.partitions:
86
+ for cid in part:
87
+ place = next((t.split("/", 1)[1] for t in _repo.get_card(cid).tags
88
+ if t.startswith("#μž₯μ†Œ/")), None)
89
+ if place and place in BACKGROUND_MAP:
90
+ current = BACKGROUND_MAP[place]
91
+ bgs.append(current)
92
+ return bgs
93
+
94
+
95
+ _PART_BG = _partition_backgrounds()
96
+
97
  _pocket = PocketService(
98
  _repo, plugins=[TurnLoggingPlugin(PlaylogRepository(ROOT / "logs" / "playlog.jsonl"))])
99
  _playdb = PlaySessionRepository(ROOT / "data" / "sessions.db")
 
149
  page = ps.last_step
150
  if page.ending is not None:
151
  return {"card": None, "narration": [], "choices": [], "can_chat": False,
152
+ "image": None, "background": None, # 엔딩은 배경을 κ±·μ–΄λ‚Έλ‹€
153
  "progress": {"index": _pager.display_total, "total": _pager.display_total},
154
  "ending": {"id": page.ending.id, "label": page.ending.label,
155
  "description": page.ending.description}}
 
158
  # idλŠ” νŽ˜μ΄μ§€ 끝 μΉ΄λ“œ(μ„ νƒΒ·μ±„νŒ…Β·μ§„ν–‰ κΈ°μ€€), 제λͺ©Β·μ—ν”Όμ†Œλ“œΒ·μ»·μ€ 첫 μΉ΄λ“œ κΈ°μ€€
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],
165
  "choices": [{"label": c.label} for c in page.choices],
166
  "can_chat": page.can_chat,
167
  "image": IMAGE_MAP.get(first.id),
168
+ "background": _PART_BG[_pager.part_of[last.id]],
169
  "progress": {"index": _pager.display_index(last.id), "total": _pager.display_total},
170
  "ending": None,
171
  }
 
262
  # ── 메타 (ν‘œμ§€ λ“± ν”„λ ˆμ  ν…Œμ΄μ…˜ μƒμˆ˜) ────────────────────────
263
  @app.get("/api/meta")
264
  async def meta():
265
+ return {"cover_image": COVER_IMAGE, "title": "μΉ΄λ₯΄λ°€λΌ", "subtitle": "μž λ“€μ§€ μ•ŠλŠ” λ°€ Β· λ„μž… 아크",
266
+ "intro_images": INTRO_IMAGES}
267
 
268
 
269
  # ── 정적 ν”„λ‘ νŠΈ ─────────────────────────────────────────────
engine/schemas/__pycache__/chapter.cpython-312.pyc CHANGED
Binary files a/engine/schemas/__pycache__/chapter.cpython-312.pyc and b/engine/schemas/__pycache__/chapter.cpython-312.pyc differ
 
engine/schemas/__pycache__/chapter.cpython-314.pyc CHANGED
Binary files a/engine/schemas/__pycache__/chapter.cpython-314.pyc and b/engine/schemas/__pycache__/chapter.cpython-314.pyc differ
 
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/__pycache__/story.cpython-312.pyc CHANGED
Binary files a/engine/services/__pycache__/story.cpython-312.pyc and b/engine/services/__pycache__/story.cpython-312.pyc differ
 
engine/services/__pycache__/story.cpython-314.pyc CHANGED
Binary files a/engine/services/__pycache__/story.cpython-314.pyc and b/engine/services/__pycache__/story.cpython-314.pyc differ
 
scripts/beatcard_convert.py ADDED
@@ -0,0 +1,397 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """λΉ„νŠΈμΉ΄λ“œ MD β†’ content/ YAML λ³€ν™˜κΈ° (κ°œλ°œνŒ€ μ†Œμœ ).
2
+
3
+ μ‚¬μš©λ²•:
4
+ .venv/bin/python scripts/beatcard_convert.py <λΉ„νŠΈμΉ΄λ“œ_폴더> -o content/beatcards
5
+
6
+ - μž…λ ₯: μΉ΄λ₯΄λ°€λΌ/0701_μΉ΄λ₯΄λ°€λΌ/λΉ„νŠΈμΉ΄λ“œ/ (κΆŒμœ„, λŒ€ν‘œλ‹˜ μ†Œμœ  β€” λ‚΄μš© λ³€ν˜• κΈˆμ§€)
7
+ - 좜λ ₯: content/beatcards/E##.yaml + R-*.yaml
8
+ - ꡬ쑰 λ³€ν™˜λ§Œ ν•œλ‹€. 맀칭이 λΆˆν™•μ‹€ν•œ 선택지 κ²°κ³ΌλŠ” 리포트둜 ν‘œμ‹œν•˜κ³  axis=None으둜 남긴닀.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import re
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ import yaml
19
+
20
+ # engine.schemas둜 μ¦‰μ‹œ κ²€μ¦ν•˜κΈ° μœ„ν•΄ μ €μž₯μ†Œ 루트λ₯Ό κ²½λ‘œμ— μΆ”κ°€
21
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
22
+
23
+ from engine.schemas.beatcard import EpisodeFile, RCard # noqa: E402
24
+
25
+ AXIS_MAP = {"μ‹ λ’°+": "trust", "μ˜μ‹¬+": "doubt", "쀑립": "neutral",
26
+ "흘림": "neutral", "μˆ˜λ™": "neutral"}
27
+ GATE_MARKS = {"ν‘œλ©΄": "all", "심측": "trust_high"}
28
+
29
+ RE_CARD_HEADER = re.compile(r"^##\s+(?:β˜…\s*)?([A-Z]\d{2}-\d{2}[A-Z]?)\s*[Β·Β·]\s*(?:β˜…\s*)?([^(\n]+?)(?:\s*\(([^)]*)\))?\s*$", re.M)
30
+ RE_TAG = re.compile(r"`(#[^`]+)`")
31
+ RE_NEXT = re.compile(r"Β·\s*β†’\s*([\wκ°€-힣/\-]+)")
32
+ RE_CHOICE_HEADER = re.compile(r"^\*\*β–·\s*(.+?)\*\*", re.M)
33
+ RE_CHOICE_LABEL = re.compile(r"\[([^\]]+)\]")
34
+ RE_CHOICE_MOD = re.compile(r"γ€”([^〕]+)〕")
35
+ RE_CENTER = re.compile(r"❝\s*(.+?)\s*❞", re.S)
36
+ RE_CHAR_DIALOGUE = re.compile(r"^>\s*([κ°€-힣A-Za-zΒ·\s]{1,20}?):\s*[γ€Œ\"](.+?)[」\"]\s*$")
37
+ RE_OLD_RESULT = re.compile(r"^-\s*\*([^:*]+):\*\s*(.+)$")
38
+ RE_NEW_RESULT = re.compile(r"^γ€ˆ([^·〉]+)\s*Β·\s*([^〉]*)〉\s*(.*)$")
39
+ RE_RESULT_NEXT = re.compile(r"β†’\s*\*{0,2}([A-Z]\d{2}-\d{2}[A-Z]?)\*{0,2}")
40
+ RE_BRANCH_MAP = re.compile(r"([κ°€-힣]+)\s*β†’\s*\*\*([A-Z]\d{2}-\d{2}[A-Z]?)\*\*")
41
+ RE_RCARD_REF = re.compile(r"R-[κ°€-힣\w]+")
42
+ RE_SECTION_STOP = re.compile(r"^(?:β–Έ\s*λ…ΈνŠΈ:|🎬|πŸ–Ό)")
43
+ RE_STAGE = re.compile(r"^###\s*(\d)단계\s*[—–-]\s*(.+)$", re.M)
44
+
45
+
46
+ def _bigrams(s: str) -> set[str]:
47
+ s = re.sub(r"[^\wκ°€-힣]", "", s)
48
+ return {s[i:i + 2] for i in range(len(s) - 1)} | set(s)
49
+
50
+
51
+ def _match_score(label: str, hint: str, text: str) -> float:
52
+ """선택지 라벨 ↔ κ²°κ³Ό 블둝 μœ μ‚¬λ„ (라벨힌트 가쀑 + λ³Έλ¬Έ μ•žλΆ€λΆ„)."""
53
+ lb = _bigrams(label)
54
+ return 3 * len(lb & _bigrams(hint)) + len(lb & _bigrams(text[:60]))
55
+
56
+
57
+ class EpisodeParser:
58
+ def parse(self, text: str, source: str) -> tuple[dict, list[str]]:
59
+ warnings: list[str] = []
60
+ m = re.search(r"^#\s+(E\d{2})\s+λΉ„νŠΈ\s*μΉ΄λ“œ\s*[—–-]\s*(.+?)\s*(?:\(v.*)?$", text, re.M)
61
+ ep_id, ep_title = (m.group(1), m.group(2).strip()) if m else ("UNKNOWN", "")
62
+
63
+ cards = []
64
+ headers = list(RE_CARD_HEADER.finditer(text))
65
+ for i, h in enumerate(headers):
66
+ body = text[h.end(): headers[i + 1].start() if i + 1 < len(headers) else len(text)]
67
+ card, ws = self._parse_card(h.group(1), h.group(2).strip(), (h.group(3) or "").strip(), body)
68
+ cards.append(card)
69
+ warnings += [f"{card['id']}: {w}" for w in ws]
70
+
71
+ self._resolve_branches(cards, warnings)
72
+ self._demote_reach_skips(cards, warnings)
73
+ return {"episode": {"id": ep_id, "title": ep_title, "source": source}, "cards": cards}, warnings
74
+
75
+ # ── μΉ΄λ“œ ν•œ μž₯ ──────────────────────────────────────────
76
+ def _parse_card(self, cid: str, title: str, subtitle: str, body: str) -> tuple[dict, list[str]]:
77
+ ws: list[str] = []
78
+ lines = body.split("\n")
79
+
80
+ # νƒœκ·Έ 라인 (첫 '>' 라인)
81
+ tag_line = next((l for l in lines if l.strip().startswith(">") and "`#" in l), "")
82
+ tags = RE_TAG.findall(tag_line)
83
+
84
+ nxt, next_options = None, []
85
+ nm = RE_NEXT.search(tag_line)
86
+ if nm:
87
+ raw = nm.group(1)
88
+ if "/" in raw: # E06-10B/C β†’ [E06-10B, E06-10C]
89
+ head, *rest = raw.split("/")
90
+ prefix = head[: head.rfind(head.lstrip("A-Z0-9-")) ] if False else head
91
+ base = re.match(r"([A-Z]\d{2}-\d{2})", head)
92
+ next_options = [head] + [(base.group(1) + r) if base and len(r) <= 2 else r for r in rest]
93
+ else:
94
+ nxt = raw
95
+
96
+ # μΆ•/μ‡ μ•½ νƒœκ·Έ
97
+ axis = None
98
+ axis_tags = [t for t in tags if t.startswith("#μΆ•/")]
99
+ if axis_tags:
100
+ v = axis_tags[0][len("#μΆ•/"):]
101
+ axis = AXIS_MAP.get(v) # "μ‹ λ’°+Β·μ˜μ‹¬+" 같은 볡합값 β†’ None (선택지가 κ²°μ •)
102
+ frailty = any(t.startswith("#λ©”μ»€λ‹ˆμ¦˜/쇠약도") for t in tags)
103
+ canon = any(t.startswith("#캐논/") for t in tags)
104
+ status = next((t.split("/", 1)[1] for t in tags if t.startswith("#μƒνƒœ/")), "μ΄ˆμ•ˆ")
105
+
106
+ # λ³Έλ¬Έ μ ˆλ‹¨ (λ…ΈνŠΈ/μ—°μΆœ/이미지 뢄리)
107
+ note = self._grab(body, r"β–Έ\s*λ…ΈνŠΈ:\s*(.+?)(?=\n🎬|\nπŸ–Ό|\n---|\Z)")
108
+ direction = self._grab(body, r"🎬\s*\*\*μ—°μΆœ 포인트:\*\*\s*(.+?)(?=\nπŸ–Ό|\n---|\Z)")
109
+ image = self._grab(body, r"πŸ–Ό\s*\*\*μž₯λ©΄ 이미지:\*\*\s*(.+?)(?=\n---|\Z)")
110
+
111
+ # 선택지 라벨
112
+ choices: list[dict] = []
113
+ ch = RE_CHOICE_HEADER.search(body)
114
+ if ch:
115
+ seg = ch.group(1)
116
+ mods = RE_CHOICE_MOD.findall(seg)
117
+ for lab in RE_CHOICE_LABEL.findall(seg):
118
+ choices.append({"label": lab.strip(), "modifiers": mods, "axis": None,
119
+ "result": "", "next": None})
120
+
121
+ # λ‚΄λ ˆμ΄μ…˜ + κ²°κ³Ό 블둝
122
+ narration, results = self._parse_blocks(lines, tag_line, ws)
123
+
124
+ # κ²°κ³Ό 블둝 ↔ 선택지 λ§€μΉ­
125
+ if choices and results:
126
+ self._match_results(choices, results, ws)
127
+ elif results and not choices:
128
+ ws.append(f"κ²°κ³Ό 블둝 {len(results)}κ°œκ°€ 선택지 없이 쑴재")
129
+
130
+ # λΆ„κΈ° λ§€ν•‘ 라인 (μˆ¨κΉ€ β†’ **E06-10C**)은 전체 μΉ΄λ“œ νŒŒμ‹± ν›„ ν›„μ²˜λ¦¬ (타깃 μΉ΄λ“œ λ³Έλ¬Έ ν•„μš”)
131
+ branch_hints = RE_BRANCH_MAP.findall(body)
132
+
133
+ r_refs = sorted(set(RE_RCARD_REF.findall(body)))
134
+
135
+ card = {
136
+ "id": cid, "title": title, "subtitle": subtitle, "tags": tags,
137
+ "status": status, "canon": canon, "next": nxt, "next_options": next_options,
138
+ "axis": axis, "frailty": frailty, "narration": narration, "choices": choices,
139
+ "r_card_refs": r_refs, "note": note, "direction": direction, "image": image,
140
+ }
141
+ if branch_hints:
142
+ card["_branch_hints"] = branch_hints # ν›„μ²˜λ¦¬μš© μž„μ‹œ ν‚€ (μ €μž₯ μ „ 제거)
143
+ return card, ws
144
+
145
+ def _demote_reach_skips(self, cards: list[dict], warnings: list[str]):
146
+ """canon 도달점 우회 λ°©μ§€ β€” 선택지 nextκ°€ ν•˜λ₯˜ λΆ„κΈ°μΉ΄λ“œμ˜ μ˜΅μ…˜μ΄λ©΄
147
+ preset_branch둜 κ°•λ“±ν•˜κ³  정상 경둜(card.next)λ₯Ό λ”°λ₯΄κ²Œ ν•œλ‹€."""
148
+ by_id = {c["id"]: c for c in cards}
149
+ for card in cards:
150
+ for ch in card["choices"]:
151
+ target = ch.get("next")
152
+ if not target:
153
+ continue
154
+ cur, hops = card, 0
155
+ while cur and cur.get("next") in by_id and hops < 6:
156
+ cur = by_id[cur["next"]]
157
+ hops += 1
158
+ if target in cur.get("next_options", []) and cur["id"] != target:
159
+ ch["next"] = None
160
+ ch["preset_branch"] = target
161
+ warnings.append(
162
+ f"{card['id']}: '{ch['label'][:14]}…' next={target}λŠ” "
163
+ f"도달점 {cur['id']} 우회 β†’ preset_branch둜 κ°•λ“±")
164
+ break
165
+
166
+ def _resolve_branches(self, cards: list[dict], warnings: list[str]):
167
+ """λΆ„κΈ° λ§€ν•‘ ν›„μ²˜λ¦¬ β€” 타깃 μΉ΄λ“œμ˜ 첫 μ§€λ¬Έκ³Ό 선택지 라벨을 λŒ€μ‘°ν•΄ 1:1 λ°°μ •."""
168
+ by_id = {c["id"]: c for c in cards}
169
+ for card in cards:
170
+ hints = card.pop("_branch_hints", None)
171
+ if not hints:
172
+ continue
173
+ used = set()
174
+ for hint, target in hints:
175
+ tgt = by_id.get(target)
176
+ tgt_text = tgt["narration"][0]["text"] if tgt and tgt["narration"] else ""
177
+ cands = [c for i, c in enumerate(card["choices"]) if i not in used]
178
+ if not cands:
179
+ warnings.append(f"{card['id']}: λΆ„κΈ° '{hint}β†’{target}' λ°°μ •ν•  선택지 μ—†μŒ")
180
+ continue
181
+ best = max(cands, key=lambda c: _match_score(c["label"], hint, "")
182
+ + _match_score(c["label"], "", tgt_text))
183
+ score = (_match_score(best["label"], hint, "")
184
+ + _match_score(best["label"], "", tgt_text))
185
+ if score <= 0:
186
+ warnings.append(f"{card['id']}: λΆ„κΈ° '{hint}β†’{target}' λ§€μΉ­ μ‹€νŒ¨ β€” μˆ˜λ™ 확인 ν•„μš”")
187
+ continue
188
+ best["next"] = target
189
+ used.add(card["choices"].index(best))
190
+
191
+ # ── λ³Έλ¬Έ 블둝 νŒŒμ‹± ──────────────────────────────────────
192
+ def _parse_blocks(self, lines: list[str], tag_line: str, ws: list[str]):
193
+ narration, results = [], []
194
+ buf: list[str] = []
195
+ gate = "all"
196
+ in_results = False # β–· 헀더 μ΄ν›„λŠ” κ²°κ³Ό μ˜μ—­
197
+ cur_result = None
198
+
199
+ def flush_prose():
200
+ t = "\n".join(buf).strip()
201
+ buf.clear()
202
+ if t:
203
+ narration.append({"type": "prose", "text": t, "gate": gate})
204
+
205
+ def close_result():
206
+ nonlocal cur_result
207
+ if cur_result:
208
+ cur_result["text"] = cur_result["text"].strip()
209
+ results.append(cur_result)
210
+ cur_result = None
211
+
212
+ for raw in lines:
213
+ line = raw.rstrip()
214
+ s = line.strip()
215
+ if not s or s == tag_line.strip() or s.startswith("---"):
216
+ continue
217
+ if RE_SECTION_STOP.match(s):
218
+ break
219
+
220
+ if RE_CHOICE_HEADER.match(s):
221
+ flush_prose()
222
+ close_result()
223
+ in_results = True
224
+ continue
225
+
226
+ nr = RE_NEW_RESULT.match(s)
227
+ if nr:
228
+ mark, hint, rest = nr.group(1).strip(), nr.group(2).strip(), nr.group(3)
229
+ if mark in GATE_MARKS: # γ€ˆν‘œλ©΄γ€‰γ€ˆμ‹¬μΈ΅γ€‰ = μ§€λ¬Έ 게이트
230
+ flush_prose()
231
+ gate = GATE_MARKS[mark]
232
+ if rest.strip():
233
+ buf.append(rest)
234
+ continue
235
+ flush_prose()
236
+ close_result()
237
+ nm = RE_RESULT_NEXT.search(rest)
238
+ cur_result = {"axis": AXIS_MAP.get(mark), "hint": hint,
239
+ "text": RE_RESULT_NEXT.sub("", rest).strip(),
240
+ "next": nm.group(1) if nm else None}
241
+ continue
242
+
243
+ orr = RE_OLD_RESULT.match(s)
244
+ if orr and in_results:
245
+ flush_prose()
246
+ close_result()
247
+ cur_result = {"axis": None, "hint": orr.group(1).strip(),
248
+ "text": orr.group(2).strip(), "next": None}
249
+ continue
250
+
251
+ if cur_result is not None: # κ²°κ³Ό 블둝 연속 쀄
252
+ cur_result["text"] += "\n" + s
253
+ continue
254
+
255
+ cm = RE_CENTER.search(s)
256
+ if cm and s.startswith(">"):
257
+ flush_prose()
258
+ narration.append({"type": "dialogue_center", "text": cm.group(1).strip(), "gate": gate})
259
+ gate = "all" if gate == "trust_high" else gate # 심측 κ²Œμ΄νŠΈλŠ” λŒ€ν‘œλŒ€μ‚¬κΉŒμ§€
260
+ continue
261
+
262
+ dm = RE_CHAR_DIALOGUE.match(s)
263
+ if dm:
264
+ flush_prose()
265
+ narration.append({"type": "character_dialogue", "speaker": dm.group(1).strip(),
266
+ "text": dm.group(2).strip(), "anchor": True, "gate": gate})
267
+ continue
268
+
269
+ if re.match(r"^\*\(.+\)\*$", s):
270
+ flush_prose()
271
+ narration.append({"type": "stage_direction",
272
+ "text": s.strip("*()").strip(), "gate": gate})
273
+ continue
274
+
275
+ if s.startswith(">"):
276
+ s = s.lstrip("> ").strip()
277
+ buf.append(s)
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)
284
+ and not (k == "anchor" and not v) and not (k == "gate" and v == "all")}
285
+ for b in narration
286
+ ]
287
+ return narration, results
288
+
289
+ def _match_results(self, choices: list[dict], results: list[dict], ws: list[str]):
290
+ """κ²°κ³Ό 블둝을 선택지에 μœ μ‚¬λ„ 순으둜 λ°°μ • (νƒμš•, 1:1)."""
291
+ pairs = sorted(
292
+ ((r_i, c_i, _match_score(c["label"], r["hint"], r["text"]))
293
+ for r_i, r in enumerate(results) for c_i, c in enumerate(choices)),
294
+ key=lambda t: -t[2],
295
+ )
296
+ used_r, used_c = set(), set()
297
+ for r_i, c_i, score in pairs:
298
+ if r_i in used_r or c_i in used_c:
299
+ continue
300
+ r, c = results[r_i], choices[c_i]
301
+ if score <= 0:
302
+ ws.append(f"κ²°κ³Ό 'γ€ˆ{r['hint']}〉' ↔ 선택지 λ§€μΉ­ μ‹€νŒ¨ (score 0)")
303
+ used_r.add(r_i)
304
+ continue
305
+ c["axis"], c["result"] = r["axis"], r["text"]
306
+ if r["next"]:
307
+ c["next"] = r["next"]
308
+ if score < 3:
309
+ ws.append(f"λ§€μΉ­ 약함(score {score}): '{c['label']}' ← γ€ˆ{r['hint']}〉")
310
+ used_r.add(r_i)
311
+ used_c.add(c_i)
312
+
313
+ @staticmethod
314
+ def _grab(body: str, pattern: str) -> str:
315
+ m = re.search(pattern, body, re.S)
316
+ return re.sub(r"\s+", " ", m.group(1)).strip() if m else ""
317
+
318
+
319
+ class RCardParser:
320
+ def parse(self, text: str) -> dict:
321
+ m = re.search(r"^#\s+(R-[κ°€-힣\w]+)", text, re.M)
322
+ rid = m.group(1) if m else "R-UNKNOWN"
323
+ tm = re.search(r"^##\s+" + re.escape(rid) + r"\s*[Β·Β·]\s*(.+)$", text, re.M)
324
+ title = tm.group(1).strip() if tm else ""
325
+ tag_line = next((l for l in text.split("\n") if l.strip().startswith(">") and "`#" in l), "")
326
+ tags = RE_TAG.findall(tag_line)
327
+
328
+ rules = []
329
+ rm = re.search(r"###\s*πŸ”’[^\n]*\n(.*?)(?=\n###)", text, re.S)
330
+ if rm:
331
+ rules = [re.sub(r"\*\*", "", l.strip("- ").strip())
332
+ for l in rm.group(1).split("\n") if l.strip().startswith("-")]
333
+
334
+ stages = []
335
+ stage_iter = list(RE_STAGE.finditer(text))
336
+ for i, sm in enumerate(stage_iter):
337
+ seg = text[sm.end(): stage_iter[i + 1].start() if i + 1 < len(stage_iter) else len(text)]
338
+ anchor_m = RE_CENTER.search(seg)
339
+ variants = re.findall(r"-\s*(?:λ³€ν˜•|물리 증거[^:]*):\s*(.+)", seg)
340
+ prose = "\n".join(
341
+ l.strip() for l in seg.split("\n")
342
+ if l.strip() and not l.strip().startswith(("-", ">", "β–Έ", "#"))
343
+ ).strip()
344
+ stages.append({"stage": int(sm.group(1)), "title": sm.group(2).strip(),
345
+ "anchor": anchor_m.group(1).strip() if anchor_m else "",
346
+ "prose": prose, "variants": variants})
347
+
348
+ return {"id": rid, "title": title, "tags": tags, "rules": rules,
349
+ "stages": stages, "raw": "" if stages else text}
350
+
351
+
352
+ def save(data: dict, path: Path):
353
+ path.parent.mkdir(parents=True, exist_ok=True)
354
+ path.write_text(yaml.dump(data, allow_unicode=True, sort_keys=False, width=100),
355
+ encoding="utf-8")
356
+
357
+
358
+ def main():
359
+ ap = argparse.ArgumentParser()
360
+ ap.add_argument("input", help="λΉ„νŠΈμΉ΄λ“œ 폴더 (E01~ ν•˜μœ„ 폴더 + R-*.md)")
361
+ ap.add_argument("-o", "--output", default="content/beatcards")
362
+ ap.add_argument("--episodes", default="E01,E02,E03,E04,E05,E06")
363
+ args = ap.parse_args()
364
+
365
+ src, out = Path(args.input), Path(args.output)
366
+ episodes = args.episodes.split(",")
367
+ total_cards, all_warnings = 0, []
368
+
369
+ ep_parser = EpisodeParser()
370
+ for ep in episodes:
371
+ md = src / ep / f"{ep}_λΉ„νŠΈμΉ΄λ“œ.md"
372
+ if not md.exists():
373
+ print(f"⚠ 원본 μ—†μŒ: {md}")
374
+ continue
375
+ data, ws = ep_parser.parse(md.read_text(encoding="utf-8"), str(md))
376
+ EpisodeFile.model_validate(data) # μ €μž₯ μ „ μŠ€ν‚€λ§ˆ 검증
377
+ save(data, out / f"{ep}.yaml")
378
+ total_cards += len(data["cards"])
379
+ all_warnings += ws
380
+ print(f"βœ“ {ep}: μΉ΄λ“œ {len(data['cards'])}개 β†’ {out / (ep + '.yaml')}")
381
+
382
+ r_parser = RCardParser()
383
+ for rmd in sorted(src.glob("R-*.md")):
384
+ data = r_parser.parse(rmd.read_text(encoding="utf-8"))
385
+ RCard.model_validate(data)
386
+ save(data, out / f"{data['id']}.yaml")
387
+ print(f"βœ“ {data['id']}: 단계 {len(data['stages'])}개")
388
+
389
+ print(f"\n총 {total_cards}μΉ΄λ“œ λ³€ν™˜ μ™„λ£Œ")
390
+ if all_warnings:
391
+ print(f"\n점검 ν•­λͺ© {len(all_warnings)}건:")
392
+ for w in all_warnings:
393
+ print(f" ⚠ {w}")
394
+
395
+
396
+ if __name__ == "__main__":
397
+ main()
scripts/play.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """μΉ΄λ₯΄λ°€λΌ 챕터1 ν”Œλ ˆμ΄ CLI β€” 리딩 루프 + κ°œμž… 포켓 (M4 톡합).
2
+
3
+ μ‚¬μš©λ²•:
4
+ .venv/bin/python scripts/play.py # λŒ€ν™”ν˜• (포켓은 LLM ν‚€ 있으면 μ±„νŒ…)
5
+ .venv/bin/python scripts/play.py --policy trust --quiet # μžλ™ ν”Œλ ˆμ΄, μ—”λ”© 확인
6
+ .venv/bin/python scripts/play.py --chat # 포켓 μžμœ μ±„νŒ… κ°•μ œ (OPENAI_API_KEY ν•„μš”)
7
+ .venv/bin/python scripts/play.py --no-chat # 포켓 μ±„νŒ… 끄기 (μ„ νƒμ§€λ§Œ)
8
+
9
+ 포켓 μ±„νŒ…: 선택지 μΉ΄λ“œμ—μ„œ 번호 λŒ€μ‹  자유 ν…μŠ€νŠΈλ₯Ό μž…λ ₯ν•˜λ©΄ μΉ΄λ₯΄λ°€λΌμ™€ λŒ€ν™”κ°€ 열리고,
10
+ director 수렴(λ˜λŠ” ν„΄ μƒν•œ) ν›„ μ„ νƒμ§€λ‘œ λ³΅κ·€ν•œλ‹€.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import asyncio
17
+ import os
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
22
+
23
+ from engine.repositories.content import ContentRepository # noqa: E402
24
+ from engine.services.story import StepResult, StoryEngine # noqa: E402
25
+
26
+ DIM = "\033[2m"
27
+ BOLD = "\033[1m"
28
+ RESET = "\033[0m"
29
+
30
+
31
+ def render(step: StepResult, quiet: bool):
32
+ if quiet or step.card is None:
33
+ return
34
+ c = step.card
35
+ print(f"\n{BOLD}── {c.id} Β· {c.title} ──{RESET}")
36
+ for b in step.narration:
37
+ if b.type == "dialogue_center":
38
+ print(f"\n{BOLD} ❝ {b.text} ❞{RESET}\n")
39
+ elif b.type == "character_dialogue":
40
+ print(f' {b.speaker}: "{b.text}"')
41
+ elif b.type == "stage_direction":
42
+ print(f"{DIM} ({b.text}){RESET}")
43
+ else:
44
+ print("\n" + b.text)
45
+
46
+
47
+ async def pocket_chat(pocket_service, engine: StoryEngine, card_id: str, first_line: str):
48
+ """κ°œμž… 포켓 μžμœ μ±„νŒ… β€” 수렴/ν„΄ μƒν•œκΉŒμ§€ 돌고 μˆ¨μ€ μƒνƒœλ₯Ό μ»€λ°‹ν•œλ‹€."""
49
+ sid = await pocket_service.open(card_id, engine.state)
50
+ line = first_line
51
+ while True:
52
+ turn = await pocket_service.run_turn(sid, line)
53
+ print(f"\n {BOLD}μΉ΄λ₯΄λ°€λΌ{RESET}: {turn.reply}")
54
+ if turn.converged:
55
+ print(f"{DIM} (μž₯면이 쑰용히 λ‹€μŒμœΌλ‘œ κΈ°μš΄λ‹€ β€” {turn.reason}){RESET}")
56
+ break
57
+ line = input("λ‚˜(둜라) > ").strip()
58
+ if not line:
59
+ break
60
+ log = await pocket_service.close(sid, engine.state)
61
+ return log
62
+
63
+
64
+ def pick_or_chat(step: StepResult, policy: str, chat_enabled: bool):
65
+ """선택지 μž…λ ₯ λ˜λŠ” 자유 ν…μŠ€νŠΈ(포켓 μ±„νŒ… μ§„μž…). λ°˜ν™˜: ('choice', idx) | ('chat', text)."""
66
+ choices = step.choices
67
+ if policy != "ask":
68
+ for i, ch in enumerate(choices):
69
+ if ch.axis == policy:
70
+ return "choice", i
71
+ for i, ch in enumerate(choices):
72
+ if ch.axis in (None, "neutral"):
73
+ return "choice", i
74
+ return "choice", 0
75
+ for i, ch in enumerate(choices):
76
+ print(f" [{i + 1}] {ch.label}")
77
+ if chat_enabled:
78
+ print(f"{DIM} (번호 λŒ€μ‹  ν•˜κ³  싢은 말을 μ“°λ©΄ μΉ΄λ₯΄λ°€λΌμ™€ λŒ€ν™”ν•©λ‹ˆλ‹€){RESET}")
79
+ while True:
80
+ raw = input("선택 > ").strip()
81
+ if raw.isdigit() and 1 <= int(raw) <= len(choices):
82
+ return "choice", int(raw) - 1
83
+ if raw and chat_enabled:
84
+ return "chat", raw
85
+ if raw and not chat_enabled:
86
+ print(f"{DIM} (포켓 μ±„νŒ… 꺼짐 β€” 번호λ₯Ό μž…λ ₯ν•˜μ„Έμš”){RESET}")
87
+
88
+
89
+ async def main():
90
+ ap = argparse.ArgumentParser()
91
+ ap.add_argument("--policy", default="ask", choices=["ask", "trust", "doubt", "neutral"])
92
+ ap.add_argument("--quiet", action="store_true")
93
+ ap.add_argument("--content", default="content")
94
+ ap.add_argument("--chat", action="store_true", help="포켓 μžμœ μ±„νŒ… κ°•μ œ ν™œμ„±ν™”")
95
+ ap.add_argument("--no-chat", action="store_true", help="포켓 μžμœ μ±„νŒ… λΉ„ν™œμ„±ν™”")
96
+ args = ap.parse_args()
97
+
98
+ chat_enabled = not args.no_chat and (args.chat or bool(os.environ.get("OPENAI_API_KEY")))
99
+ repo = ContentRepository(args.content)
100
+ engine = StoryEngine(repo)
101
+
102
+ pocket_service = None
103
+ if chat_enabled:
104
+ from engine.plugins.logging import TurnLoggingPlugin
105
+ from engine.services.pocket import PocketService
106
+ pocket_service = PocketService(repo, plugins=[TurnLoggingPlugin()])
107
+
108
+ step = engine.start()
109
+ while step.ending is None:
110
+ render(step, args.quiet)
111
+ if step.choices:
112
+ while True:
113
+ kind, val = pick_or_chat(step, args.policy, chat_enabled)
114
+ if kind == "choice":
115
+ choice, step = engine.choose(val)
116
+ if not args.quiet:
117
+ print(f"\n{DIM}β–· {choice.label}{RESET}")
118
+ if choice.result:
119
+ print(choice.result)
120
+ break
121
+ card = repo.get_card(engine.current_id)
122
+ if not card.can_chat:
123
+ print(f"{DIM} (이 μž₯면에선 κ·Έλ…€κ°€ μžλ¦¬μ— μ—†λ‹€ β€” 번호λ₯Ό μž…λ ₯ν•˜μ„Έμš”){RESET}")
124
+ continue
125
+ await pocket_chat(pocket_service, engine, engine.current_id, val)
126
+ # μ±„νŒ… ν›„ 같은 μΉ΄λ“œμ˜ μ„ νƒμ§€λ‘œ 볡귀 (λ„λ‹¬μ Β·λΆ„κΈ°λŠ” μ½”λ“œκ°€ 보μž₯)
127
+ else:
128
+ if not args.quiet and args.policy == "ask":
129
+ input(f"{DIM}(λ‹€μŒ ⏎){RESET}")
130
+ step = engine.advance()
131
+
132
+ s = engine.state
133
+ print(f"\n{BOLD}═══ 챕터 μ’…λ£Œ: {step.ending.label} ═══{RESET}")
134
+ print(f"{DIM}[디버그 β€” μœ μ € λΉ„λ…ΈμΆœ] trust={s.trust_score} doubt={s.doubt_score} "
135
+ f"frailty={s.frailty} level={s.trust_level} μΉ΄λ“œ {len(engine.visited)}μž₯ λ°©λ¬Έ{RESET}")
136
+
137
+
138
+ if __name__ == "__main__":
139
+ asyncio.run(main())
scripts/sync_assets.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """μ—°μΆœ 이미지 슬림 동기화 β€” 원본 폴더(κΆŒμœ„)μ—μ„œ 데λͺ¨κ°€ μ“°λŠ” μ΄λ―Έμ§€λ§Œ web/assets/둜 볡사.
2
+
3
+ μ›μ²œ(κΆŒμœ„): μΉ΄λ₯΄λ°€λΌ/0701_μΉ΄λ₯΄λ°€λΌ/이미지/ (λŒ€ν‘œλ‹˜ μ‚°μΆœλ¬Ό). web/assets/λŠ” 배포용 νŒŒμƒλ¬Ό.
4
+ router의 COVER_IMAGEΒ·IMAGE_MAPΒ·BACKGROUND_MAPΒ·INTRO_IMAGESκ°€ μ°Έμ‘°ν•˜λŠ”
5
+ μƒλŒ€κ²½λ‘œλ₯Ό κ·ΈλŒ€λ‘œ μœ μ§€ν•œ 채 λ³΅μ‚¬ν•œλ‹€ (κΆŒμœ„λŠ” router.py β€” λ³€κ²½ μ‹œ ν•¨κ»˜ κ°±μ‹ ).
6
+
7
+ μ‹€ν–‰: python scripts/sync_assets.py
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import shutil
13
+ from pathlib import Path
14
+
15
+ ROOT = Path(__file__).resolve().parent.parent
16
+ SRC = ROOT / "μΉ΄λ₯΄λ°€λΌ" / "0701_μΉ΄λ₯΄λ°€λΌ" / "이미지"
17
+ DST = ROOT / "web" / "assets"
18
+
19
+ RELATIVE_PATHS = [
20
+ # ν‘œμ§€Β·μ—°μΆœ μ»· (IMAGE_MAP)
21
+ "ν”Œλž˜μ΄ 이미지/1.png", # ν‘œμ§€
22
+ "ν”Œλž˜μ΄ 이미지/4.png", # E01-07
23
+ "인물/μΉ΄λ₯΄λ°€λΌ.png", # E03-03 + ν™”μž μ΄ˆμƒ
24
+ "인물/μΉ΄λ₯΄λ°€λΌ κ°€λ©΄.png", # E06-09
25
+ # ν™”μž μ΄ˆμƒ (SPEAKER_PORTRAIT)
26
+ "인물/아버지.png",
27
+ "인물/가정ꡐ사.png", # λ§ˆλ“œλͺ¨μ•„μ €
28
+ "인물/보λͺ¨.png", # λ§ˆλ‹΄
29
+
30
+ # 인트둜 μ‹œν€€μŠ€ (INTRO_IMAGES)
31
+ "μ˜€λ…Έλ§ˆxμ†Œλ½_λ°©ν–₯/κ°œμš”2.png", # μž‘ν’ˆ μ†Œκ°œ
32
+ "μ˜€λ…Έλ§ˆxμ†Œλ½_λ°©ν–₯/κ°œμš”3.png", # λ“±μž₯인물 μ†Œκ°œ
33
+ "ν”Œλž˜μ΄ 이미지/2.png", # 1μž₯ μ‹œμž‘
34
+ # μž₯μ†Œλ³„ λ°°κ²½ (BACKGROUND_MAP)
35
+ "λ°°κ²½/μ„±.png",
36
+ "λ°°κ²½/μΉ¨μ‹€.png",
37
+ "λ°°κ²½/식당.png",
38
+ "λ°°κ²½/μ•ˆκ°œ 숲.png",
39
+ "λ°°κ²½/νν—ˆμ„±λ‹Ή.png",
40
+ ]
41
+
42
+
43
+ def main() -> None:
44
+ if not SRC.exists():
45
+ raise SystemExit(f"원본 이미지 폴더 μ—†μŒ: {SRC}")
46
+ for rel in RELATIVE_PATHS:
47
+ src, dst = SRC / rel, DST / rel
48
+ if not src.exists():
49
+ raise SystemExit(f"원본 이미지 λˆ„λ½: {src}")
50
+ dst.parent.mkdir(parents=True, exist_ok=True)
51
+ shutil.copy2(src, dst)
52
+ print(f"볡사: {rel}")
53
+ print(f"μ™„λ£Œ β€” {len(RELATIVE_PATHS)}μž₯ β†’ {DST}")
54
+
55
+
56
+ if __name__ == "__main__":
57
+ main()
web/assets/λ°°κ²½/식당.png ADDED

Git LFS Details

  • SHA256: ef9eba96b1ac8cc8dbaa844f57d178f51a76f5910604531d7e37a278ce8e162d
  • Pointer size: 132 Bytes
  • Size of remote file: 2.41 MB
web/assets/λ°°κ²½/μ•ˆκ°œ 숲.png ADDED

Git LFS Details

  • SHA256: 93d8e264bd34c7c96fb7d2c54fc7dd811a1ba69a751cb5d4bf4f97de561ebfee
  • Pointer size: 132 Bytes
  • Size of remote file: 2.5 MB
web/assets/λ°°κ²½/νν—ˆμ„±λ‹Ή.png ADDED

Git LFS Details

  • SHA256: 4c9053b0eed1cbdfa1e9a6c86d73260713329cb73119c3dc2c5a60d4051c0b61
  • Pointer size: 132 Bytes
  • Size of remote file: 2.67 MB
web/assets/μ˜€λ…Έλ§ˆxμ†Œλ½_λ°©ν–₯/κ°œμš”2.png ADDED

Git LFS Details

  • SHA256: ae23199ebdcd2df173b46a81268a3d64cd10b8b599ea1d4b80952831c7f27eee
  • Pointer size: 132 Bytes
  • Size of remote file: 2 MB
web/assets/μ˜€λ…Έλ§ˆxμ†Œλ½_λ°©ν–₯/κ°œμš”3.png ADDED

Git LFS Details

  • SHA256: e2961c91b9d88d4a668b9e04a868a9132f1267648e6ca64fae9b3563315aabc4
  • Pointer size: 132 Bytes
  • Size of remote file: 2.3 MB
web/assets/인물/가정ꡐ사.png ADDED

Git LFS Details

  • SHA256: 77d456e361de09cb38ca795f85c599ea696e98c90cf271823673c8060ee19a71
  • Pointer size: 132 Bytes
  • Size of remote file: 2.19 MB
web/assets/인물/보λͺ¨.png ADDED

Git LFS Details

  • SHA256: 24bd91f5552da191f1dc505933a8dd57cf13d5232c801f44e9f8f0645f824db1
  • Pointer size: 132 Bytes
  • Size of remote file: 2.21 MB
web/assets/인물/아버지.png ADDED

Git LFS Details

  • SHA256: ccd6c7210728f109fa0f95fc053adbb94519ab292cfdfc23502d5cdf1010189a
  • Pointer size: 132 Bytes
  • Size of remote file: 2.08 MB
web/assets/ν”Œλž˜μ΄ 이미지/2.png ADDED

Git LFS Details

  • SHA256: 837a4d6664bb2c7b753eef5aaca75f38436076d84abeaa3c1db7ed77e0cd2196
  • Pointer size: 132 Bytes
  • Size of remote file: 2.06 MB
web/index.html CHANGED
@@ -20,6 +20,16 @@
20
  overflow-x: hidden;
21
  }
22
 
 
 
 
 
 
 
 
 
 
 
23
  /* ── μ§„ν–‰ λ°” + μž₯ 라벨 ── */
24
  #progress-wrap { position: fixed; top: 0; left: 0; right: 0; z-index: 40; }
25
  #progress-bar { height: 2px; width: 0%; background: linear-gradient(90deg, var(--gold-dim), var(--gold));
@@ -41,9 +51,29 @@
41
  margin: 2.4em 0; line-height: 2.1; word-break: keep-all; }
42
  .center-line::before { content: "❝ "; color: var(--gold-dim); }
43
  .center-line::after { content: " ❞"; color: var(--gold-dim); }
44
- .char-line { margin: 1.2em 0; padding-left: 1em; border-left: 2px solid var(--gold-dim);
45
- color: #cfc4ad; word-break: keep-all; }
46
- .char-line .spk { color: var(--gold-dim); font-size: .85rem; margin-right: .6em; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  .stage-dir { color: var(--text-dim); font-style: italic; font-size: .92rem;
48
  text-align: center; margin: 1.4em 0; }
49
  .result-block { margin: 1.6em 0; padding: 1em 1.2em; background: rgba(201,168,106,.05);
@@ -52,9 +82,17 @@
52
  .fade { animation: fadein 1.1s ease both; }
53
  @keyframes fadein { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; } }
54
 
55
- /* ── 선택지: λ³Έλ¬Έ 흐름 μ•ˆ, 쀑앙 μ€‘ν•˜λΆ€ 1μ—΄ ── */
56
- #choice-area { margin: 9vh auto 0; max-width: 430px; display: flex;
57
- flex-direction: column; gap: 12px; }
 
 
 
 
 
 
 
 
58
  button {
59
  font-family: inherit; font-size: .98rem; cursor: pointer; word-break: keep-all;
60
  background: rgba(26,34,51,.85); color: var(--text);
@@ -115,15 +153,37 @@
115
  padding: 11px 14px; font-family: inherit; font-size: .96rem; }
116
  #chat-input:focus { outline: none; border-color: var(--gold); }
117
 
118
- /* ── ν‘œμ§€Β·μ—”λ”© ── */
 
 
119
  #cover { position: fixed; inset: 0; z-index: 70; display: flex; flex-direction: column;
120
  align-items: center; justify-content: flex-end; padding-bottom: 9vh;
121
- background-size: cover; background-position: center 20%; }
122
- #cover::after { content: ""; position: absolute; inset: 0;
123
- background: linear-gradient(transparent 40%, rgba(11,14,20,.92)); }
124
- #cover > * { position: relative; z-index: 1; }
125
- #cover button { padding: 14px 52px; background: rgba(11,14,20,.75); font-size: 1.02rem;
 
 
 
 
 
 
126
  letter-spacing: .3em; text-indent: .3em; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  .ending { text-align: center; }
128
  .ending h2 { color: var(--gold); letter-spacing: .25em; margin: 20vh 0 1.4em; font-weight: 500; }
129
  .ending p { color: var(--text-dim); }
@@ -131,14 +191,26 @@
131
  </style>
132
  </head>
133
  <body>
 
 
 
 
134
  <div id="progress-wrap"><div id="progress-bar"></div></div>
135
  <div id="progress-label"></div>
136
  <div id="browse-ribbon">μ§€λ‚˜μ˜¨ νŽ˜μ΄μ§€ β€” 였λ₯Έμͺ½μ„ 눌러 μ§€κΈˆμœΌλ‘œ</div>
137
 
138
  <div id="cover">
 
 
139
  <button onclick="start()" id="start-btn">밀을 μ—°λ‹€</button>
140
  </div>
141
 
 
 
 
 
 
 
142
  <div id="stage"><div id="page"></div></div>
143
 
144
  <div id="nav-left" class="nav-zone" title="이전"></div>
@@ -177,10 +249,13 @@ const api = async (path, body) => {
177
 
178
  /* ── μ‹œμž‘ ── */
179
  let RESUME = null; // {sid, step} β€” μ €μž₯된 μ„Έμ…˜ 볡원뢄
 
180
  (async () => {
181
  try {
182
  const m = await api('/meta');
183
- $('cover').style.backgroundImage = `url("${encodeURI(m.cover_image)}")`;
 
 
184
  } catch (e) {}
185
  const saved = localStorage.getItem('carmilla_sid');
186
  if (saved) {
@@ -197,6 +272,7 @@ let RESUME = null; // {sid, step} β€” μ €μž₯된 μ„Έμ…˜ 볡원
197
  async function start() {
198
  $('start-btn').disabled = true;
199
  let step;
 
200
  if (RESUME) {
201
  SID = RESUME.sid; step = RESUME.step;
202
  } else {
@@ -206,26 +282,86 @@ async function start() {
206
  step = data.step;
207
  }
208
  $('cover').remove();
 
 
 
 
 
209
  document.body.classList.add('show-hints');
210
  setTimeout(() => document.body.classList.remove('show-hints'), 5000);
211
  showStep(step, true);
212
  }
213
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  /* ── λ Œλ” ── */
 
 
215
  function block(b) {
216
  if (b.type === 'dialogue_center') return `<div class="center-line fade">${esc(b.text)}</div>`;
217
- if (b.type === 'character_dialogue')
218
- return `<div class="char-line fade"><span class="spk">${esc(b.speaker)}</span>β€œ${esc(b.text)}”</div>`;
219
  if (b.type === 'stage_direction') return `<div class="stage-dir fade">( ${esc(b.text)} )</div>`;
220
  return `<div class="prose fade">${esc(b.text)}</div>`;
221
  }
222
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  function pageHtml(step, fresh) {
224
  const c = step.card;
225
  let html = '';
226
  if (fresh || c.episode !== lastEpisode) html += `<div class="ep-header fade">${esc(c.episode_title || c.episode)}</div>`;
227
  html += `<div class="card-title fade">${esc(c.title)}</div>`;
228
- html += step.narration.map(block).join('');
229
  html += `<div id="choice-area">${choicesHtml(step)}</div>`;
230
  return html;
231
  }
@@ -259,20 +395,21 @@ function pushCutPage(step) { // 이미지 νŽ˜μ΄μ§€ 번호 = λ³Έλ¬Έ 번
259
  const html = `<div class="page-cut"><img src="${encodeURI(step.image)}" alt="">` +
260
  `<div class="cut-caption">${esc(step.card.title)}</div></div>`;
261
  pushEntry(html, {index: step.progress.index - 1, total: step.progress.total},
262
- step.card.episode_title);
263
  }
264
 
265
  function pushPage(step, fresh) {
266
  lastEpisode = step.card.episode;
267
- pushEntry(pageHtml(step, fresh), step.progress, step.card.episode_title);
268
  }
269
 
270
- function pushEntry(html, progress, ep) {
271
  $('page').innerHTML = html;
272
- history.push({html, progress, ep});
273
  cursor = history.length - 1;
274
  document.body.classList.remove('browsing');
275
  setProgress(progress, ep);
 
276
  window.scrollTo({top: 0});
277
  }
278
 
@@ -281,6 +418,7 @@ function renderEntry(i) {
281
  $('page').innerHTML = h.html;
282
  document.body.classList.toggle('browsing', i < history.length - 1);
283
  setProgress(h.progress, h.ep);
 
284
  window.scrollTo({top: 0});
285
  }
286
 
@@ -289,12 +427,19 @@ $('nav-right').onclick = () => go(+1);
289
  $('nav-left').onclick = () => go(-1);
290
  document.addEventListener('keydown', (e) => {
291
  if (chatOpen) return;
 
 
 
 
 
 
292
  if (e.key === 'ArrowRight' || e.key === ' ') go(+1);
293
  if (e.key === 'ArrowLeft') go(-1);
294
  });
295
 
296
  async function go(dir) {
297
- if (busy || chatOpen || !SID || STEP?.ending && cursor >= history.length - 1 && dir > 0) return;
 
298
  if (dir < 0) { // κ³Όκ±° νŽ˜μ΄μ§€ 훑어보기 (읽기 μ „μš©)
299
  if (cursor <= 0) return;
300
  cursor--;
@@ -396,10 +541,11 @@ $('chat-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') send
396
  function renderEnding(end) {
397
  setProgress(STEP.progress, '');
398
  $('progress-label').textContent = '';
 
399
  $('page').innerHTML = `<div class="ending fade">
400
  <h2>${esc(end.label)}</h2><p>${esc(end.description)}</p>
401
  <button onclick="restart()">λ‹€μ‹œ, 처음의 밀으둜</button></div>`;
402
- history.push({html: $('page').innerHTML, progress: STEP.progress, ep: ''});
403
  cursor = history.length - 1;
404
  }
405
 
 
20
  overflow-x: hidden;
21
  }
22
 
23
+ /* ── μž₯μ†Œ λ°°κ²½ (λΉ„μ£Όμ–Ό 노벨) β€” μ„œλ²„κ°€ νŽ˜μ΄μ§€λ§ˆλ‹€ μ§€μ •, ν¬λ‘œμŠ€νŽ˜μ΄λ“œ ── */
24
+ .bg-layer { position: fixed; inset: 0; z-index: -2; pointer-events: none;
25
+ background-size: cover; background-position: center;
26
+ opacity: 0; transition: opacity 1.6s ease;
27
+ filter: brightness(.5) saturate(.85); }
28
+ .bg-layer.show { opacity: .55; }
29
+ #bg-dim { position: fixed; inset: 0; z-index: -1; pointer-events: none;
30
+ background: linear-gradient(rgba(11,14,20,.3) 0%, rgba(11,14,20,.55) 45%,
31
+ rgba(11,14,20,.82) 100%); }
32
+
33
  /* ── μ§„ν–‰ λ°” + μž₯ 라벨 ── */
34
  #progress-wrap { position: fixed; top: 0; left: 0; right: 0; z-index: 40; }
35
  #progress-bar { height: 2px; width: 0%; background: linear-gradient(90deg, var(--gold-dim), var(--gold));
 
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;
64
+ border: 1px solid rgba(201,168,106,.5); background: rgba(11,14,20,.72); }
65
+ .vn-name::before, .vn-name::after {
66
+ content: ""; position: absolute; top: 50%; width: 46px; height: 1px;
67
+ background: linear-gradient(90deg, transparent, var(--gold-dim)); }
68
+ .vn-name::before { right: 100%; margin-right: 10px; }
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);
 
82
  .fade { animation: fadein 1.1s ease both; }
83
  @keyframes fadein { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; } }
84
 
85
+ /* ── 선택지: λŒ€ν™”ν•˜λ“― β€” β€Ί 마컀 + κ°€λŠ” ꡬ뢄선 리슀트 (μ°Έκ³ : ν”Œλž˜μ΄ 이미지/7.png) ── */
86
+ #choice-area { margin: 8vh auto 0; max-width: 520px; display: flex;
87
+ flex-direction: column; gap: 0; }
88
+ #choice-area button { background: transparent; border: none; border-radius: 0;
89
+ border-top: 1px solid rgba(201,168,106,.22);
90
+ text-align: left; padding: 17px 12px; font-size: 1rem; }
91
+ #choice-area button:last-child { border-bottom: 1px solid rgba(201,168,106,.22); }
92
+ #choice-area button::before { content: "β€Ί"; color: var(--gold-dim);
93
+ margin-right: .8em; font-size: 1.05rem; }
94
+ #choice-area button:hover { color: var(--gold); background: rgba(201,168,106,.06); }
95
+ #choice-area button.chat-open { border-style: solid; }
96
  button {
97
  font-family: inherit; font-size: .98rem; cursor: pointer; word-break: keep-all;
98
  background: rgba(26,34,51,.85); color: var(--text);
 
153
  padding: 11px 14px; font-family: inherit; font-size: .96rem; }
154
  #chat-input:focus { outline: none; border-color: var(--gold); }
155
 
156
+ /* ── ν‘œμ§€Β·μ—”λ”© ──
157
+ 원본(1086Γ—1448)을 화면에 cover둜 늘리면 해상도가 κΉ¨μ§„λ‹€ β€”
158
+ λΈ”λŸ¬ λ°±λ“œλ‘­ + 원본 크기 μ΄ν•˜λ‘œλ§Œ μ„ λͺ…ν•˜κ²Œ 쀑앙 배치 (μΈνŠΈλ‘œλ„ 동일 기법) */
159
  #cover { position: fixed; inset: 0; z-index: 70; display: flex; flex-direction: column;
160
  align-items: center; justify-content: flex-end; padding-bottom: 9vh;
161
+ background: var(--ink); overflow: hidden; }
162
+ #cover-blur { position: absolute; inset: -48px; background-size: cover;
163
+ background-position: center 20%; filter: blur(30px) brightness(.45); }
164
+ #cover-art { position: absolute; left: 50%; top: 46%; transform: translate(-50%, -50%);
165
+ max-width: min(92vw, 1086px); max-height: min(84vh, 1448px);
166
+ box-shadow: 0 0 90px rgba(0,0,0,.85);
167
+ border: 1px solid rgba(201,168,106,.2); }
168
+ #cover::after { content: ""; position: absolute; inset: 0; z-index: 1;
169
+ background: linear-gradient(transparent 55%, rgba(11,14,20,.9)); }
170
+ #cover button { position: relative; z-index: 2; padding: 14px 52px;
171
+ background: rgba(11,14,20,.75); font-size: 1.02rem;
172
  letter-spacing: .3em; text-indent: .3em; }
173
+
174
+ /* ── 인트둜 μ‹œν€€μŠ€ (μž‘ν’ˆ μ†Œκ°œ β†’ 인물 μ†Œκ°œ β†’ 1μž₯ μ‹œμž‘) ── */
175
+ #intro { position: fixed; inset: 0; z-index: 68; background: var(--ink);
176
+ display: none; align-items: center; justify-content: center;
177
+ cursor: pointer; overflow: hidden; }
178
+ #intro.open { display: flex; }
179
+ #intro-blur { position: absolute; inset: -48px; background-size: cover;
180
+ background-position: center; filter: blur(30px) brightness(.4); }
181
+ #intro-art { position: relative; max-width: min(94vw, 1086px); max-height: min(92vh, 1491px);
182
+ object-fit: contain; box-shadow: 0 0 90px rgba(0,0,0,.85);
183
+ border: 1px solid rgba(201,168,106,.2); animation: fadein 1s ease both; }
184
+ #intro-hint { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
185
+ color: var(--text-dim); font-size: .72rem; letter-spacing: .25em;
186
+ animation: fadein 1.6s ease both 1s; }
187
  .ending { text-align: center; }
188
  .ending h2 { color: var(--gold); letter-spacing: .25em; margin: 20vh 0 1.4em; font-weight: 500; }
189
  .ending p { color: var(--text-dim); }
 
191
  </style>
192
  </head>
193
  <body>
194
+ <div class="bg-layer" id="bgA"></div>
195
+ <div class="bg-layer" id="bgB"></div>
196
+ <div id="bg-dim"></div>
197
+
198
  <div id="progress-wrap"><div id="progress-bar"></div></div>
199
  <div id="progress-label"></div>
200
  <div id="browse-ribbon">μ§€λ‚˜μ˜¨ νŽ˜μ΄μ§€ β€” 였λ₯Έμͺ½μ„ 눌러 μ§€κΈˆμœΌλ‘œ</div>
201
 
202
  <div id="cover">
203
+ <div id="cover-blur"></div>
204
+ <img id="cover-art" alt="μΉ΄λ₯΄λ°€λΌ ν‘œμ§€">
205
  <button onclick="start()" id="start-btn">밀을 μ—°λ‹€</button>
206
  </div>
207
 
208
+ <div id="intro">
209
+ <div id="intro-blur"></div>
210
+ <img id="intro-art" alt="">
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>
 
249
 
250
  /* ── μ‹œμž‘ ── */
251
  let RESUME = null; // {sid, step} β€” μ €μž₯된 μ„Έμ…˜ 볡원뢄
252
+ let INTRO = []; // 인트둜 μ‹œν€€μŠ€ 이미지 (μƒˆ μ‹œμž‘μ—λ§Œ, μ„œλ²„ /meta)
253
  (async () => {
254
  try {
255
  const m = await api('/meta');
256
+ $('cover-blur').style.backgroundImage = `url("${encodeURI(m.cover_image)}")`;
257
+ $('cover-art').src = encodeURI(m.cover_image);
258
+ INTRO = m.intro_images || [];
259
  } catch (e) {}
260
  const saved = localStorage.getItem('carmilla_sid');
261
  if (saved) {
 
272
  async function start() {
273
  $('start-btn').disabled = true;
274
  let step;
275
+ const fresh = !RESUME;
276
  if (RESUME) {
277
  SID = RESUME.sid; step = RESUME.step;
278
  } else {
 
282
  step = data.step;
283
  }
284
  $('cover').remove();
285
+ if (fresh && INTRO.length) return showIntro(0, step); // μƒˆ μ‹œμž‘μ—λ§Œ 인트둜
286
+ beginReading(step);
287
+ }
288
+
289
+ function beginReading(step) {
290
  document.body.classList.add('show-hints');
291
  setTimeout(() => document.body.classList.remove('show-hints'), 5000);
292
  showStep(step, true);
293
  }
294
 
295
+ function showIntro(i, step) { // μž‘ν’ˆ μ†Œκ°œ β†’ 인물 μ†Œκ°œ β†’ 1μž₯ μ‹œμž‘ β†’ λ³Έλ¬Έ
296
+ const el = $('intro');
297
+ $('intro-blur').style.backgroundImage = `url("${encodeURI(INTRO[i])}")`;
298
+ $('intro-art').src = encodeURI(INTRO[i]);
299
+ el.classList.add('open');
300
+ el.onclick = () => {
301
+ if (i + 1 < INTRO.length) showIntro(i + 1, step);
302
+ else { el.classList.remove('open'); el.onclick = null; beginReading(step); }
303
+ };
304
+ }
305
+
306
+ /* ── μž₯μ†Œ λ°°κ²½ (ν¬λ‘œμŠ€νŽ˜μ΄λ“œ) ── */
307
+ let bgFlip = false, bgCurrent = null;
308
+ function setBackground(url) {
309
+ if ((url || null) === bgCurrent) return;
310
+ bgCurrent = url || null;
311
+ const nxt = $(bgFlip ? 'bgA' : 'bgB'), prv = $(bgFlip ? 'bgB' : 'bgA');
312
+ bgFlip = !bgFlip;
313
+ if (url) { nxt.style.backgroundImage = `url("${encodeURI(url)}")`; nxt.classList.add('show'); }
314
+ else nxt.classList.remove('show');
315
+ prv.classList.remove('show');
316
+ }
317
+
318
  /* ── λ Œλ” ── */
319
+ const PLAYER_SPEAKERS = new Set(['μœ μ €', '둜라', 'λ‚˜']); // 1인칭 β€” μ΄ˆμƒ 없이 였λ₯Έμͺ½ μ •λ ¬
320
+
321
  function block(b) {
322
  if (b.type === 'dialogue_center') return `<div class="center-line fade">${esc(b.text)}</div>`;
 
 
323
  if (b.type === 'stage_direction') return `<div class="stage-dir fade">( ${esc(b.text)} )</div>`;
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'
347
+ && blocks[i].speaker === spk) {
348
+ lines.push(blocks[i].text);
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
  }
 
395
  const html = `<div class="page-cut"><img src="${encodeURI(step.image)}" alt="">` +
396
  `<div class="cut-caption">${esc(step.card.title)}</div></div>`;
397
  pushEntry(html, {index: step.progress.index - 1, total: step.progress.total},
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});
409
  cursor = history.length - 1;
410
  document.body.classList.remove('browsing');
411
  setProgress(progress, ep);
412
+ setBackground(bg);
413
  window.scrollTo({top: 0});
414
  }
415
 
 
418
  $('page').innerHTML = h.html;
419
  document.body.classList.toggle('browsing', i < history.length - 1);
420
  setProgress(h.progress, h.ep);
421
+ setBackground(h.bg);
422
  window.scrollTo({top: 0});
423
  }
424
 
 
427
  $('nav-left').onclick = () => go(-1);
428
  document.addEventListener('keydown', (e) => {
429
  if (chatOpen) return;
430
+ const intro = $('intro');
431
+ if (intro && intro.classList.contains('open')) { // 인트둜 쀑 β€” β†’/슀페이슀둜 λ„˜κΉ€
432
+ if ((e.key === 'ArrowRight' || e.key === ' ' || e.key === 'Enter') && intro.onclick)
433
+ intro.onclick();
434
+ return;
435
+ }
436
  if (e.key === 'ArrowRight' || e.key === ' ') go(+1);
437
  if (e.key === 'ArrowLeft') go(-1);
438
  });
439
 
440
  async function go(dir) {
441
+ if (busy || chatOpen || !SID || !STEP
442
+ || STEP.ending && cursor >= history.length - 1 && dir > 0) return;
443
  if (dir < 0) { // κ³Όκ±° νŽ˜μ΄μ§€ 훑어보기 (읽기 μ „μš©)
444
  if (cursor <= 0) return;
445
  cursor--;
 
541
  function renderEnding(end) {
542
  setProgress(STEP.progress, '');
543
  $('progress-label').textContent = '';
544
+ setBackground(null); // μ—”λ”© β€” 배경을 κ±·κ³  μ •μ μœΌλ‘œ
545
  $('page').innerHTML = `<div class="ending fade">
546
  <h2>${esc(end.label)}</h2><p>${esc(end.description)}</p>
547
  <button onclick="restart()">λ‹€μ‹œ, 처음의 밀으둜</button></div>`;
548
+ history.push({html: $('page').innerHTML, progress: STEP.progress, ep: '', bg: null});
549
  cursor = history.length - 1;
550
  }
551