"""Pure reducers that fold a turn + adjudication into a new GameState.""" from __future__ import annotations from ..schemas.case import CaseFile from ..schemas.interrogation import InterrogationTurn from .director import Adjudication from .game_state import Exchange, GameState, NotebookEntry, NotebookKind, SuspectState def _fact_statement(case: CaseFile, fact_id: str) -> str: for fact in case.facts: if fact.fact_id == fact_id: return fact.statement return fact_id def _suspect_name(case: CaseFile, sus_id: str) -> str: try: return case.suspect(sus_id).name except KeyError: return sus_id def _next_suspect_state( prev: SuspectState, *, turn_index: int, question: str, turn: InterrogationTurn, adj: Adjudication, presented_clue_id: str | None, ) -> SuspectState: evidence_shown = prev.evidence_shown if presented_clue_id is not None: evidence_shown = evidence_shown | {presented_clue_id} broken = prev.broken_lie_ids contradictions = prev.contradictions if adj.is_contradiction: if adj.broken_lie_id is not None: broken = broken | {adj.broken_lie_id} contradictions = (*contradictions, f"Alibi contradicted on turn {turn_index}.") return prev.model_copy( update={ "turns": prev.turns + 1, "stress": adj.stress_after, "rapport": adj.rapport_after, "transcript": (*prev.transcript, Exchange(turn_index=turn_index, question=question, answer=turn.spoken)), "revealed_fact_ids": prev.revealed_fact_ids | set(adj.revealed_fact_ids), "evidence_shown": evidence_shown, "broken_lie_ids": broken, "contradictions": contradictions, } ) def _notebook_entries( case: CaseFile, sus_id: str, turn_index: int, adj: Adjudication ) -> tuple[NotebookEntry, ...]: entries: list[NotebookEntry] = [] if adj.is_contradiction: entries.append( NotebookEntry( kind=NotebookKind.CONTRADICTION, text=f"{_suspect_name(case, sus_id)}'s alibi cracked under evidence.", turn_index=turn_index, suspect_id=sus_id, ) ) for fid in adj.revealed_fact_ids: entries.append( NotebookEntry( kind=NotebookKind.LEAD, text=_fact_statement(case, fid), turn_index=turn_index, suspect_id=sus_id, ) ) return tuple(entries) def apply_turn( state: GameState, case: CaseFile, sus_id: str, question: str, turn: InterrogationTurn, adj: Adjudication, presented_clue_id: str | None = None, ) -> GameState: """Return a new GameState with this turn folded in. Never mutates ``state``.""" turn_index = state.turn_count prev = state.state_for(sus_id) next_state = _next_suspect_state( prev, turn_index=turn_index, question=question, turn=turn, adj=adj, presented_clue_id=presented_clue_id, ) new_states = {**state.suspect_states, sus_id: next_state} notebook = state.notebook + _notebook_entries(case, sus_id, turn_index, adj) return state.model_copy( update={ "suspect_states": new_states, "current_suspect_id": sus_id, "turn_count": turn_index + 1, "notebook": notebook, } ) def add_player_note(state: GameState, text: str) -> GameState: """Append a free-text note the player typed into the notebook (never mutates state).""" text = text.strip() if not text: return state note = NotebookEntry(kind=NotebookKind.NOTE, text=text[:240], turn_index=state.turn_count) return state.model_copy(update={"notebook": (*state.notebook, note)}) def discover_clues(state: GameState, clue_ids: tuple[str, ...], case: CaseFile) -> GameState: """Add searched/forensic/document clues to the discovered set with notebook notes. Unknown clue ids are ignored so a bad id can never enter the discovered set.""" seen = set(state.discovered_clue_ids) collected: list[str] = [] for cid in clue_ids: if cid in seen or not _clue_exists(case, cid): continue seen.add(cid) collected.append(cid) newly = tuple(collected) if not newly: return state notes = tuple( NotebookEntry( kind=NotebookKind.CLUE, text=case.clue(cid).name, turn_index=state.turn_count, clue_id=cid, ) for cid in newly ) return state.model_copy( update={ "discovered_clue_ids": state.discovered_clue_ids | set(newly), "notebook": state.notebook + notes, } ) def _clue_exists(case: CaseFile, clue_id: str) -> bool: try: case.clue(clue_id) return True except KeyError: return False