File size: 3,707 Bytes
414dc55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
"""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}")