Spaces:
Running
Running
| """μΉ΄λ μ§ν μνλ¨Έμ β μμ¬ μ§νμ μ΅μ’ κ²°μ κΆ (κ²°μ μ μ½λ, 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 | |
| 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(), | |
| } | |
| 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)] | |