File size: 5,226 Bytes
414dc55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80cd1f2
 
 
 
 
 
 
 
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
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
"""The PUBLIC wire contract: the exact case shape the Preact client receives.

This mirrors the prototype's ``prototype/js/case.jsx`` (the data-model-by-example) in
camelCase. It is the ONLY case representation that ever reaches the browser before the
verdict. The sealed solution - killer, true motive, key-evidence chain - and every
per-question/per-evidence suspicion *delta* and scripted *answer* are deliberately absent:
those are produced live, server-side (the deltas alone would reveal the killer).
"""

from __future__ import annotations

from pydantic import BaseModel, ConfigDict, Field
from pydantic.alias_generators import to_camel


class _Wire(BaseModel):
    """camelCase on the wire; snake_case in Python. Frozen - a view is never mutated."""

    model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, frozen=True)


class PublicVictim(_Wire):
    name: str
    role: str
    age: int
    sprite: str
    bio: str


class SuggestedQuestion(_Wire):
    id: str
    q: str


class PublicSuspect(_Wire):
    id: str
    name: str
    role: str
    age: int
    sprite: str
    gender: str = "male"
    tag: str
    baseline_suspicion: int
    motive: str  # apparent motive shown in the dossier - NOT proof
    alibi: str
    quote: str
    greet: str
    suggested_questions: tuple[SuggestedQuestion, ...]


class ThreadMessage(_Wire):
    from_: str = Field(alias="from")
    who: str
    t: str
    m: str


class PublicEvidence(_Wire):
    id: str
    name: str
    type: str  # PHONE | PAPER | IMAGE | AUDIO | DATA
    icon: str
    time: str
    found: str
    summary: str
    # Exactly one of these display payloads is populated, per type.
    thread: tuple[ThreadMessage, ...] | None = None
    detail: str | None = None
    transcript: str | None = None
    dur: str | None = None
    rows: tuple[tuple[str, ...], ...] | None = None


class TimelineBeat(_Wire):
    time: str
    label: str
    locked: bool = False
    ev: str | None = None
    conflict: bool = False


class FlashbackAccount(_Wire):
    who: str
    scene: str
    lines: tuple[str, ...]
    flags: tuple[int, ...] = ()


class PublicFlashback(_Wire):
    title: str
    a: FlashbackAccount
    b: FlashbackAccount


class PublicMotive(_Wire):
    id: str
    text: str


class StoryBeat(_Wire):
    scene: str
    kicker: str
    title: str
    text: str


class PublicCase(_Wire):
    id: str
    city: str
    district: str
    title: str
    tagline: str
    weather: str
    victim: PublicVictim
    scene: str
    tod: str
    found: str
    cause: str
    # Case-kind display labels. Defaulted to homicide so the golden case and every
    # pre-kind stored case keep their exact current wording.
    kind: str = "homicide"
    kind_label: str = "HOMICIDE"  # title screen: "A PROCEDURAL {kindLabel}"
    division: str = "HOMICIDE DIVISION"
    victim_status: str = "DECEASED"  # dossier stamp next to the victim
    tod_label: str = "T.O.D."
    verdict: str = "Homicide"  # KEY FACTS verdict line
    facts: tuple[tuple[str, str], ...]
    boot_lines: tuple[str, ...]
    story_beats: tuple[StoryBeat, ...]
    suspects: tuple[PublicSuspect, ...]
    evidence: tuple[PublicEvidence, ...]
    timeline: tuple[TimelineBeat, ...]
    flashback: PublicFlashback
    motives: tuple[PublicMotive, ...]


def golden_to_public(data: dict) -> PublicCase:
    """Project a sealed golden/stored case dict into the PUBLIC view.

    Strips the ``sealed`` block and, per suspect, the scripted ``answer``/``delta`` and
    the ``present``/``default`` reply tables - leaving only question text the player sees.
    """
    suspects = tuple(
        PublicSuspect(
            id=s["id"],
            name=s["name"],
            role=s["role"],
            age=s["age"],
            sprite=s["sprite"],
            gender=s.get("gender", "male"),
            tag=s["tag"],
            baseline_suspicion=s["suspicion"],
            motive=s["motive"],
            alibi=s["alibi"],
            quote=s["quote"],
            greet=s["greet"],
            suggested_questions=tuple(
                SuggestedQuestion(id=q["id"], q=q["q"]) for q in s["questions"]
            ),
        )
        for s in data["suspects"]
    )
    return PublicCase(
        id=data["id"],
        city=data["city"],
        district=data["district"],
        title=data["title"],
        tagline=data["tagline"],
        weather=data["weather"],
        victim=PublicVictim(**data["victim"]),
        scene=data["scene"],
        tod=data["tod"],
        found=data["found"],
        cause=data["cause"],
        facts=tuple(tuple(pair) for pair in data["facts"]),
        boot_lines=tuple(data["bootLines"]),
        story_beats=tuple(StoryBeat(**b) for b in data.get("storyBeats", [])),
        suspects=suspects,
        evidence=tuple(PublicEvidence(**e) for e in data["evidence"]),
        timeline=tuple(TimelineBeat(**b) for b in data["timeline"]),
        flashback=PublicFlashback(
            title=data["flashback"]["title"],
            a=FlashbackAccount(**data["flashback"]["a"]),
            b=FlashbackAccount(**data["flashback"]["b"]),
        ),
        motives=tuple(PublicMotive(**m) for m in data["motives"]),
    )