File size: 8,142 Bytes
7d46a91
 
 
 
 
 
 
 
1c9c13f
7d46a91
 
 
1c9c13f
 
7d46a91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6e20b9b
7d46a91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1c9c13f
 
 
7d46a91
1c9c13f
 
 
 
 
7d46a91
 
1c9c13f
 
 
 
 
 
7d46a91
 
 
 
 
 
 
 
 
 
 
 
1c9c13f
7d46a91
 
 
 
 
 
1c9c13f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d46a91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6e20b9b
7d46a91
 
 
 
 
6e20b9b
7d46a91
 
6e20b9b
7d46a91
 
 
 
 
 
 
 
 
6e20b9b
 
 
 
 
7d46a91
6e20b9b
7d46a91
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
"""νŽ˜μ΄μ§€ 병합 β€” μ—¬λŸ¬ λΉ„νŠΈμΉ΄λ“œλ₯Ό ν•œ ν™”λ©΄(νŽ˜μ΄μ§€)으둜 λ¬ΆλŠ” 결정적 νŒŒν‹°μ…˜.

원칙:
- μ„œμ‚¬ μ§„ν–‰Β·μˆ¨μ€ μƒνƒœμ˜ κΆŒμœ„λŠ” StoryEngine이 μœ μ§€ν•œλ‹€. PagerλŠ” engine.advance()λ₯Ό
  νŽ˜μ΄μ§€ κ²½κ³„κΉŒμ§€ 반볡 ν˜ΈμΆœν•΄ 지문을 병합할 뿐, λΆ€μˆ˜νš¨κ³Όλ₯Ό 직접 λ§Œλ“€μ§€ μ•ŠλŠ”λ‹€
  (μΉ΄λ“œλ‹Ή μ •ν™•νžˆ 1회 β€” 이쀑/λˆ„λ½ 적용 κΈˆμ§€)
- νŒŒν‹°μ…˜μ€ μ½˜ν…μΈ (μ „μ—­ μΉ΄λ“œ μˆœμ„œ)의 순수 ν•¨μˆ˜ β€” μœ μ € κ²½λ‘œΒ·μƒνƒœμ™€ 무관해
  νŽ˜μ΄μ§€ 총수(진행도 λΆ„λͺ¨)κ°€ μ•ˆμ •μ μ΄κ³ , 볡원 μ‹œμ—λ„ 같은 νŽ˜μ΄μ§€κ°€ μž¬κ΅¬μ„±λœλ‹€
- 병합은 μ„ ν˜• 링크(card.next == μ „μ—­ λ‹€μŒ μΉ΄λ“œ)둜 이어진 ꡬ간(run)μ—μ„œλ§Œ. κ°•μ œ 뢄리:
  Β· 선택지/λΆ„κΈ°(next_options) μΉ΄λ“œ λ’€ (κ²°μ • 지점)
  Β· solo μΉ΄λ“œ(μ—°μΆœ μ»·Β·μ±„νŒ… 액컀— ν˜ΈμΆœμžκ°€ μ§€μ •) μ•žλ’€
  Β· μ—ν”Όμ†Œλ“œ 경계
- 밀도 κ· ν˜•: run별 총 κΈ€μžμˆ˜λ‘œ νŽ˜μ΄μ§€ 수 k = round(총/target)λ₯Ό λ¨Όμ € μ •ν•˜κ³ ,
  λˆ„μ  κΈ€μžμˆ˜κ°€ κ· λ“± 경계(총·i/k)에 κ°€μž₯ κ°€κΉŒμš΄ μΉ΄λ“œ λ’€μ—μ„œ μ ˆλ‹¨ β€” 그리디 과적 λ°©μ§€
"""

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)
    narration_cards: list[str] = 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:
        # 1) ꡬ쑰적 브레이크둜 run(병합 κ°€λŠ₯ν•œ μ΅œλŒ€ ꡬ간)을 λ§Œλ“€κ³ 
        runs: list[list[BeatCard]] = []
        cur: list[BeatCard] = []
        prev: Optional[BeatCard] = None
        for card in self.repo.all_cards():
            if cur and self._structural_break(prev, card):
                runs.append(cur)
                cur = []
            cur.append(card)
            prev = card
        if cur:
            runs.append(cur)

        # 2) runλ³„λ‘œ κ· ν˜• λΆ„ν• 
        parts: list[list[str]] = []
        for run in runs:
            parts += [[c.id for c in part] for part in self._split_run(run)]

        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 _structural_break(self, prev: BeatCard, card: BeatCard) -> 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
        return prev.episode_id != card.episode_id

    @staticmethod
    def _chars(card: BeatCard) -> int:
        return sum(len(b.text) for b in card.narration)

    def _split_run(self, run: list[BeatCard]) -> list[list[BeatCard]]:
        """run을 k = round(총 κΈ€μžμˆ˜/target)개의 연속 κ΅¬κ°„μœΌλ‘œ κ· λ“± λΆ„ν•  (결정적)."""
        total = sum(self._chars(c) for c in run)
        k = max(1, min(len(run), round(total / self.target)))
        if k == 1:
            return [run]
        bounds = [total * i / k for i in range(1, k)]  # κ· λ“± μ ˆλ‹¨ λͺ©ν‘œμ 
        parts: list[list[BeatCard]] = []
        cur: list[BeatCard] = []
        acc = 0
        bi = 0
        for idx, card in enumerate(run):
            cur.append(card)
            acc += self._chars(card)
            remaining_cards = len(run) - idx - 1
            remaining_cuts = (k - 1) - bi
            if bi >= len(bounds) or remaining_cards == 0:
                continue
            next_acc = acc + self._chars(run[idx + 1])
            # μ§€κΈˆ 자λ₯΄λŠ” 게 λ‹€μŒ μΉ΄λ“œκΉŒμ§€ 끌고 κ°€λŠ” 것보닀 경계에 κ°€κΉκ±°λ‚˜,
            # 남은 μ ˆλ‹¨ 수만큼의 μΉ΄λ“œλ°–μ— μ•ˆ λ‚¨μ•˜μœΌλ©΄ μ—¬κΈ°μ„œ μ ˆλ‹¨
            if (abs(acc - bounds[bi]) <= abs(next_acc - bounds[bi])
                    or remaining_cards == remaining_cuts):
                parts.append(cur)
                cur = []
                bi += 1
        if cur:
            parts.append(cur)
        return parts

    # ── ν‘œμ‹œ 번호 (진행도) ──────────────────────────────────
    @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)
        cards = [first.id] * len(step.narration)
        while engine.current_id != part[-1]:
            step = engine.advance()
            if step.ending:  # νŒŒν‹°μ…˜ λ‚΄ λ§ν¬λŠ” 챕터 μ•ˆ β€” 방어적 처리
                return PageView(ending=step.ending)
            narration += step.narration
            cards += [step.card.id] * len(step.narration)
        last = step.card
        return PageView(first=first, last=last, narration=narration,
                        narration_cards=cards,
                        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, ncards = [], []
        for c in cards:
            vis = engine.visible_narration(c)
            narration += vis
            ncards += [c.id] * len(vis)
        return PageView(first=cards[0], last=cards[-1], narration=narration,
                        narration_cards=ncards,
                        choices=list(cards[-1].choices), can_chat=cards[-1].can_chat)