"""Orchestrator: the FSM that binds quests + state + validation. The Orchestrator owns one (state, quests) pair and the *decisions* on it -- using quest boundaries it chooses when to advance / complete a stage / win / lose, and it assembles the player-facing hint (judge hint, else the static concept_brief, or the reveal on loss). GameState owns its own mutations. """ from __future__ import annotations from config import MAX_ATTEMPT from core.game_state import GameState from core.quest_bank import QuestBank from core.validation import Result, Validator class Orchestrator: def __init__(self, bank: QuestBank | None = None, state: GameState | None = None, validator: Validator | None = None): self.bank = bank if bank is not None else QuestBank() self.state = state if state is not None else GameState() self.validator = validator if validator is not None else Validator() @property def current_question(self) -> dict: """The full current question (shown + check + reveal).""" return self.bank.stage(self.state.stage_idx)["questions"][self.state.q_idx] def quest_view(self) -> dict: """Model-safe READ of the current quest: stage flavor + the SHOWN part only. Drops `check` and `reveal` here, at the core, so the answer never travels toward the model. Injected into the prompt instead of being a tool. """ stage = self.bank.stage(self.state.stage_idx) return {"concept": stage["concept"], "fiction": stage["fiction"], **self.current_question["shown"]} def submit(self, answer: str) -> Result: """Validate an answer, drive the state, return (verdict, hint).""" q = self.current_question result = self.validator.validate(answer, q["check"]) if result.verdict == "correct": n_questions = len(self.bank.stage(self.state.stage_idx)["questions"]) if self.state.q_idx + 1 < n_questions: self.state.advance() elif self.state.stage_idx + 1 < len(self.bank): self.state.complete_stage() else: self.state.win() return result # no hint needed on correct # "wrong" or "partial" -> both count as an attempt toward losing self.state.strike() if self.state.attempt >= MAX_ATTEMPT: self.state.lose() return Result("lost", q["reveal"]) return Result(result.verdict, result.hint or q["shown"]["concept_brief"])