"""The CaseFile: the complete hidden ground truth for one mystery. This is server-side only. The player never receives it directly - they get a ``PlayerCaseView`` projection (see projections.player_view) with the solution and all ``is_culprit`` flags stripped. Deep referential and solvability invariants are enforced by ``solver.checker`` so failures can target a single regenerated slice. """ from __future__ import annotations from pydantic import BaseModel, ConfigDict, Field from ..constants import CASE_SCHEMA_VERSION, MAX_SUSPECTS, MIN_SUSPECTS from .clue import Clue, Fact from .enums import Difficulty, MotiveCategory from .suspect import Suspect from .timeline import Location, TimeWindow class GenerationKnobs(BaseModel): """Light seeds for variety. Creative hints may be empty - then the model is free to invent setting, era, and tone from scratch.""" model_config = ConfigDict(frozen=True) setting_hint: str = "" era_hint: str = "" tone_hint: str = "" n_suspects: int = Field(default=4, ge=MIN_SUSPECTS, le=MAX_SUSPECTS) n_red_herrings: int = Field(default=2, ge=0, le=6) alibi_tightness: float = Field(default=0.6, ge=0.0, le=1.0) difficulty: Difficulty = Difficulty.STANDARD class Setting(BaseModel): model_config = ConfigDict(frozen=True) name: str description: str locations: tuple[Location, ...] murder_window: TimeWindow class Victim(BaseModel): model_config = ConfigDict(frozen=True) vic_id: str name: str role: str found_at_loc_id: str found_at_min: int cause_of_death: str time_of_death: TimeWindow class Weapon(BaseModel): model_config = ConfigDict(frozen=True) weapon_id: str name: str kind: str origin_loc_id: str requires_strength: bool = False leaves_trace: str = "" class Relationship(BaseModel): model_config = ConfigDict(frozen=True) from_sus_id: str to_sus_id: str kind: str sentiment: float = Field(default=0.0, ge=-1.0, le=1.0) known_publicly: bool = True class Motive(BaseModel): model_config = ConfigDict(frozen=True) motive_id: str category: MotiveCategory summary: str class AlibiLie(BaseModel): model_config = ConfigDict(frozen=True) claimed_loc_id: str actual_loc_id: str contradicted_by_clue_ids: tuple[str, ...] class Culprit(BaseModel): model_config = ConfigDict(frozen=True) sus_id: str true_motive: Motive method_narrative: str alibi_lie: AlibiLie class Solution(BaseModel): model_config = ConfigDict(frozen=True) culprit_sus_id: str weapon_id: str motive_id: str minimal_clue_set: tuple[str, ...] deduction_chain: tuple[str, ...] class CaseFile(BaseModel): """The full, hidden mystery. Frozen: a generated case is never mutated in place.""" model_config = ConfigDict(frozen=True) case_id: str seed: int schema_version: str = CASE_SCHEMA_VERSION title: str briefing: str knobs: GenerationKnobs setting: Setting victim: Victim weapon: Weapon suspects: tuple[Suspect, ...] = Field(min_length=MIN_SUSPECTS, max_length=MAX_SUSPECTS) culprit: Culprit relationships: tuple[Relationship, ...] = () facts: tuple[Fact, ...] = () clues: tuple[Clue, ...] = () solution: Solution def suspect(self, sus_id: str) -> Suspect: for s in self.suspects: if s.sus_id == sus_id: return s raise KeyError(f"no suspect {sus_id!r}") def clue(self, clue_id: str) -> Clue: for c in self.clues: if c.clue_id == clue_id: return c raise KeyError(f"no clue {clue_id!r}")