Junhoee's picture
Upload 113 files
7d46a91 verified
Raw
History Blame Contribute Delete
7.97 kB
"""μΉ΄λ“œ μ§„ν–‰ μƒνƒœλ¨Έμ‹  β€” μ„œμ‚¬ μ§„ν–‰μ˜ μ΅œμ’… κ²°μ •κΆŒ (결정적 μ½”λ“œ, LLM μ—†μŒ).
- entry_beatλΆ€ν„° canon μΉ΄λ“œ νŽ˜μ΄μ§•, next 링크 전이
- 선택지: axis 반영 + λΆ„κΈ°(next/preset_branch) κ²°μ •
- 쇠약도: #λ©”μ»€λ‹ˆμ¦˜/쇠약도 μΉ΄λ“œ μ§„μž… μ‹œ 단쑰 증가
- 챕터 좜ꡬ(E07 λ“±) 도달 μ‹œ ending_rules 쑰건식을 μ½”λ“œλ‘œ 평가 (LLM νŒμ • κΈˆμ§€)
- 심측 게이트(trust_high) 지문은 μ‹ λ’° λ ˆλ²¨μ— 따라 λ…ΈμΆœ κ²°μ •
"""
from __future__ import annotations
import re
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.state import HiddenState
# ending_rules condition에 ν—ˆμš©λ˜λŠ” 문법: λ³€μˆ˜/μ •μˆ˜/비ꡐ/and (κ·Έ μ™ΈλŠ” 검증 μ‹€νŒ¨)
RE_CONDITION_TOKEN = re.compile(
r"^\s*(trust_score|doubt_score)\s*(>=|<=|>|<|==)\s*(\d+)\s*$")
def eval_condition(condition: str, state: HiddenState) -> bool:
"""쑰건식 평가 β€” eval() κΈˆμ§€, ν—ˆμš© λ¬Έλ²•λ§Œ νŒŒμ‹±."""
if condition.strip() == "default":
return True
values = {"trust_score": state.trust_score, "doubt_score": state.doubt_score}
for clause in condition.split(" and "):
m = RE_CONDITION_TOKEN.match(clause)
if not m:
raise ValueError(f"ν—ˆμš©λ˜μ§€ μ•ŠλŠ” 쑰건식: {condition!r}")
left, op, right = values[m.group(1)], m.group(2), int(m.group(3))
ok = {"<": left < right, "<=": left <= right, ">": left > right,
">=": left >= right, "==": left == right}[op]
if not ok:
return False
return True
@dataclass
class StepResult:
"""ν•œ 걸음의 좜λ ₯ β€” ν”„λ‘ νŠΈ/CLIκ°€ κ·ΈλŒ€λ‘œ λ Œλ”λ§ν•œλ‹€."""
card: Optional[BeatCard] = None
narration: list[NarrationBlock] = field(default_factory=list) # 게이트 ν•„ν„° μ μš©λΆ„
choices: list[Choice] = field(default_factory=list)
is_pocket: bool = False
ending: Optional[EndingRule] = None # 챕터 μ’…λ£Œ μ‹œμ—λ§Œ
class StoryEngine:
def __init__(self, repo: ContentRepository, state: HiddenState | None = None):
self.repo = repo
self.state = state or HiddenState()
self.current_id: Optional[str] = None
self.preset_branch: Optional[str] = None # 상λ₯˜ 선택이 μ˜ˆμ•½ν•œ ν•˜λ₯˜ λΆ„κΈ°
self.visited: list[str] = []
self.ending: Optional[EndingRule] = None
# ── μ˜μ†ν™” (μŠ€λƒ…μƒ·/볡원) ────────────────────────────────
def snapshot(self) -> dict:
"""JSON 직렬화 κ°€λŠ₯ν•œ 전체 μŠ€λƒ…μƒ· (story + hidden)."""
return {
"story": {
"current_id": self.current_id,
"visited": list(self.visited),
"preset_branch": self.preset_branch,
"ending_id": self.ending.id if self.ending else None,
},
"hidden": self.state.snapshot(),
}
@classmethod
def restore(cls, repo: ContentRepository, payload: dict) -> "StoryEngine":
"""μŠ€λƒ…μƒ· 볡원 β€” _enter()λ₯Ό κ±°μΉ˜μ§€ μ•ŠλŠ”λ‹€ (쇠약도/μΆ• λΆ€μˆ˜νš¨κ³Ό 재적용 κΈˆμ§€)."""
story = payload["story"]
engine = cls(repo, state=HiddenState.from_snapshot(payload["hidden"]))
engine.current_id = story["current_id"]
engine.visited = list(story["visited"])
engine.preset_branch = story.get("preset_branch")
ending_id = story.get("ending_id")
if ending_id:
engine.ending = next(
r for r in repo.chapter.ending_rules if r.id == ending_id)
return engine
def current_step(self) -> StepResult:
"""ν˜„μž¬ μœ„μΉ˜μ˜ StepResult μž¬κ΅¬μ„± (λ Œλ” μ „μš© β€” μƒνƒœ 무변경)."""
if self.ending:
return StepResult(ending=self.ending)
card = self.repo.get_card(self.current_id)
return StepResult(card=card, narration=self.visible_narration(card),
choices=list(card.choices), is_pocket=card.is_pocket)
# ── μ§„ν–‰ ────────────────────────────────────────────────
def start(self) -> StepResult:
return self._enter(self.repo.chapter.entry_beat)
def _enter(self, card_id: str) -> StepResult:
card = self.repo.get_card(card_id)
self.current_id = card_id
self.visited.append(card_id)
# μ•Ž 확립 (μ—”μ§„ A): 챕터 μ§„μž… μΉ΄λ“œμ—μ„œ 1회
if card_id == self.repo.chapter.entry_beat:
self.state.establish_awareness()
# 쇠약도 (신뒰와 독립 β€” 핡심 μ•„μ΄λŸ¬λ‹ˆ)
if card.frailty:
self.state.advance_frailty()
# μΉ΄λ“œ 자체 μΆ• β€” 캐논 이벀트 (예: E06-10 '첫 μ§„μ§œ μ˜μ‹¬'은 λͺ¨λ“  κ²½λ‘œμ— 적용).
# 단, 선택지듀이 좕을 κ°–κ³  있으면 선택이 κ²°μ •ν•˜λ―€λ‘œ 쀑볡 μ μš©ν•˜μ§€ μ•ŠλŠ”λ‹€
if card.axis and not any(ch.axis for ch in card.choices):
self.state.apply_axis(card.axis, source=card_id)
return StepResult(card=card, narration=self.visible_narration(card),
choices=list(card.choices), is_pocket=card.is_pocket)
def advance(self) -> StepResult:
"""선택지 μ—†λŠ” μΉ΄λ“œμ—μ„œ λ‹€μŒμœΌλ‘œ (μ±…μž₯ λ„˜κΈ°κΈ°)."""
card = self.repo.get_card(self.current_id)
if card.choices:
raise RuntimeError(f"{card.id}: 선택지가 μžˆλŠ” μΉ΄λ“œ β€” choose()λ₯Ό μ“Έ 것")
return self._goto(card.next, card)
def choose(self, index: int) -> tuple[Choice, StepResult]:
"""선택지 반영 β€” axis 적용 + λΆ„κΈ° κ²°μ •."""
card = self.repo.get_card(self.current_id)
if not card.choices:
raise RuntimeError(f"{card.id}: 선택지 μ—†λŠ” μΉ΄λ“œ")
choice = card.choices[index]
self.state.apply_axis(choice.axis, source=f"{card.id}[{index}]")
if choice.preset_branch:
self.preset_branch = choice.preset_branch
# λΆ„κΈ° μΉ΄λ“œ: preset이 μ˜ˆμ•½λΌ 있으면 그것이 μš°μ„  (상λ₯˜ 컀밋 쑴쀑)
target = choice.next
if card.next_options:
if self.preset_branch in card.next_options:
target = self.preset_branch
self.preset_branch = None
elif target is None:
raise RuntimeError(f"{card.id}: λΆ„κΈ° μΉ΄λ“œμΈλ° 선택지에 next μ—†μŒ")
return choice, self._goto(target or card.next, card)
def _goto(self, target: Optional[str], from_card: BeatCard) -> StepResult:
if target is None:
# λ‚˜κ°€λŠ” 링크 μ—†μŒ β€” μ½˜ν…μΈ  κ²€μ¦μ—μ„œ 경고된 μƒνƒœ. 챕터 μ’…λ£Œλ‘œ 처리
return self._finish()
resolved = self.repo.resolve_ref(target)
if resolved is None: # E07 λ“± 챕터 λ²”μœ„ λ°– β†’ 챕터 μ’…λ£Œ
return self._finish()
return self._enter(resolved)
# ── μ—”λ”© (결정적 μ½”λ“œ) ──────────────────────────────────
def _finish(self) -> StepResult:
rules = sorted(self.repo.chapter.ending_rules, key=lambda r: r.priority)
for rule in rules:
if eval_condition(rule.condition, self.state):
self.ending = rule
return StepResult(ending=rule)
raise RuntimeError("default μ—”λ”© κ·œμΉ™ μ—†μŒ β€” μ½˜ν…μΈ  결함")
# ── μ§€λ¬Έ 게이트 (μ—”μ§„ D: 심측 ν•΄κΈˆ) β€” Pager λ“± λ Œλ” 계측도 μ‚¬μš© ──
def visible_narration(self, card: BeatCard) -> list[NarrationBlock]:
return [b for b in card.narration
if b.gate == "all" or (b.gate == "trust_high" and self.state.deep_unlock)]