Junhoee's picture
Upload 140 files
6e20b9b verified
Raw
History Blame Contribute Delete
8.14 kB
"""νŽ˜μ΄μ§€ 병합 β€” μ—¬λŸ¬ λΉ„νŠΈμΉ΄λ“œλ₯Ό ν•œ ν™”λ©΄(νŽ˜μ΄μ§€)으둜 λ¬ΆλŠ” 결정적 νŒŒν‹°μ…˜.
원칙:
- μ„œμ‚¬ μ§„ν–‰Β·μˆ¨μ€ μƒνƒœμ˜ κΆŒμœ„λŠ” 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)