"""페이지 병합 — 여러 비트카드를 한 화면(페이지)으로 묶는 결정적 파티션. 원칙: - 서사 진행·숨은 상태의 권위는 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)