File size: 11,232 Bytes
414dc55
 
 
 
 
 
 
 
 
 
 
80cd1f2
 
 
414dc55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80cd1f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
414dc55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80cd1f2
414dc55
 
 
 
 
 
 
80cd1f2
414dc55
 
 
 
 
 
 
 
 
80cd1f2
 
 
 
 
414dc55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80cd1f2
 
 
 
414dc55
80cd1f2
 
 
 
 
 
414dc55
 
80cd1f2
 
414dc55
80cd1f2
414dc55
 
 
 
 
 
80cd1f2
414dc55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80cd1f2
414dc55
80cd1f2
 
 
 
 
 
414dc55
 
 
 
80cd1f2
 
 
414dc55
 
 
80cd1f2
414dc55
 
 
80cd1f2
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
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
"""Project a generated ``CaseFile`` into the PUBLIC wire view (the same contract the
golden case uses). Deterministic: it synthesizes the display surface from the case's
own fields. Nothing from the sealed solution (culprit, true motive id, breaker chain)
is exposed; the true motive's *text* appears only as one option among the decoys.

Generated evidence renders as paper exhibits (reveal_text); richer per-type payloads
(threads, keycard tables, voicemail) and authored tags/quotes are a later enhancement.
"""

from __future__ import annotations

import re

from ..generator.crime_profiles import CrimeProfile, profile_for
from ..schemas.case import CaseFile
from ..schemas.clue import Clue
from .public_view import (
    FlashbackAccount,
    PublicCase,
    PublicEvidence,
    PublicFlashback,
    PublicMotive,
    PublicSuspect,
    PublicVictim,
    StoryBeat,
    SuggestedQuestion,
    TimelineBeat,
)
from .questions import FIXED_QUESTIONS

_SCENES = ("skyline", "atrium", "interro", "seawall", "mezzanine", "desk")
_WEATHER = (
    "Rain, and a wind off the water.", "Fog thick enough to lean on.",
    "A dry cold that gets into the joints.", "Sleet against every window.",
)
_GREETS = (
    "Detective. Let's get this over with.",
    "I already told the others everything I know.",
    "Ask what you came to ask.",
    "I have nothing to hide, if that's what you think.",
)
_APPARENT = (
    "Was close enough to the victim to have a reason - and a chance.",
    "Had more to lose tonight than they're letting on.",
    "Their story has a seam in it, if you pull the right thread.",
    "Stood to gain from a death no one saw coming.",
)
_DECOY_MOTIVES = (
    "To bury a debt that was about to surface.",
    "Jealousy that had festered for years.",
    "To keep a buried secret buried.",
    "To settle an old score, finally.",
    "Fear of being exposed and ruined.",
)
# Keyword-matched icons so a letter looks like paper and a keycard like a key -
# never a phone icon on a bloodied letter. First match wins; unmatched rotate.
_ICON_RULES: tuple[tuple[re.Pattern[str], str], ...] = (
    (re.compile(r"photo|polaroid|snapshot|portrait|negative|film", re.I), "photoEv"),
    (re.compile(r"cctv|camera|footage|surveillance|still\b", re.I), "cctv"),
    (re.compile(r"voicemail|recording|tape|audio|cylinder|dictaphone", re.I), "voicemail"),
    (re.compile(r"phone|telephone|telegram|message|wire\b", re.I), "phone"),
    (re.compile(r"key\b|keys\b|keycard|access|badge|pass\b|lock\b", re.I), "keycard"),
    (re.compile(r"receipt|ticket|ledger|letter|note\b|paper|document|contract|deed|cheque|"
                r"check\b|bill\b|pawn|invoice|book\b|journal|diary|stub", re.I), "receipt"),
    (re.compile(r"map\b|compass|route|itinerary|timetable|schedule", re.I), "compass"),
)
_ICONS = ("photoEv", "receipt", "compass")


def _icon_for(name: str, reveal: str, idx: int) -> str:
    hay = f"{name} {reveal}"
    for rule, icon in _ICON_RULES:
        if rule.search(hay):
            return icon
    return _ICONS[idx % len(_ICONS)]
# Noir city names so CITY reads as an actual city, not the venue (the venue goes in SCENE).
_CITIES = (
    "Graymoor", "Blackport", "Ashmoor", "Harrowgate", "Duskwater", "Coldhaven",
    "Ravenreach", "Mortlake", "Greyharbor", "Thornehaven", "Mistport", "Hollowmere",
    "Saltmarsh", "Ironhaven", "Blackwater", "Fellgate",
)


def _hash(s: str) -> int:
    h = 0
    for ch in s:
        h = (h * 31 + ord(ch)) & 0x7FFFFFFF
    return h


def _clock(minute: int) -> str:
    h = (minute // 60) % 24
    m = minute % 60
    suffix = "AM" if h < 12 else "PM"
    h12 = h % 12 or 12
    return f"{h12}:{m:02d} {suffix}"


def _loc_name(case: CaseFile, loc_id: str) -> str:
    for loc in case.setting.locations:
        if loc.loc_id == loc_id:
            return loc.name
    return loc_id


def _suspect_public(case: CaseFile, idx: int) -> PublicSuspect:
    s = case.suspects[idx]
    seed = _hash(s.sus_id)
    role_word = (s.role.split()[-1] if s.role else "").strip(".,").upper()
    tag = f"THE {role_word}" if role_word else "PERSON OF INTEREST"
    quote = (s.persona_summary.split(".")[0] + ".") if s.persona_summary else "I've nothing to hide."
    gender = "female" if ((s.visual.gender if s.visual else "") or "").lower().startswith("f") else "male"
    return PublicSuspect(
        id=s.sus_id,
        name=s.name,
        role=s.role,
        age=30 + (seed % 35),
        sprite=s.sus_id,
        gender=gender,
        tag=tag[:22],
        baseline_suspicion=25 + (seed % 16),
        motive=_APPARENT[idx % len(_APPARENT)],
        alibi=s.stated_alibi.claim_text,
        quote=quote,
        greet=_GREETS[idx % len(_GREETS)],
        suggested_questions=tuple(SuggestedQuestion(id=q, q=text) for q, text in FIXED_QUESTIONS),
    )


def _evidence_public(case: CaseFile, clue: Clue, idx: int) -> PublicEvidence:
    at = next((f.at_min for f in case.facts if f.fact_id == clue.supports_fact_id and f.at_min is not None), None)
    time = _clock(at) if at is not None else _clock(case.setting.murder_window.start_min + 7 * (idx + 1))
    return PublicEvidence(
        id=clue.clue_id,
        name=clue.name.upper(),
        type=clue.discovery_method.value.upper(),
        icon=_icon_for(clue.name, clue.reveal_text, idx),
        time=time,
        found=f"Recovered from {_loc_name(case, clue.discoverable_at_loc_id)}.",
        summary=clue.reveal_text,
        detail=clue.reveal_text,
    )


def _timeline(case: CaseFile, profile: CrimeProfile) -> tuple[TimelineBeat, ...]:
    beats: list[TimelineBeat] = []
    w = case.setting.murder_window
    beats.append(TimelineBeat(time=_clock(w.start_min), label="The evening is under way; everyone is in the house.", locked=True))
    culprit = case.culprit.sus_id
    for i, clue in enumerate(case.clues):
        at = next((f.at_min for f in case.facts if f.fact_id == clue.supports_fact_id and f.at_min is not None), None)
        t = at if at is not None else w.start_min + 7 * (i + 1)
        conflict = clue.contradicts_alibi_of == culprit
        beats.append(TimelineBeat(time=_clock(t), label=clue.reveal_text[:80], ev=clue.clue_id, conflict=conflict))
    incident = profile.timeline_line.format(
        name=case.victim.name, instrument=case.weapon.name,
        room=_loc_name(case, case.victim.found_at_loc_id),
    )
    beats.append(TimelineBeat(time=_clock(case.victim.time_of_death.start_min), label=incident, locked=True))
    beats.sort(key=lambda b: b.time)
    return tuple(beats)


def _flashback(case: CaseFile) -> PublicFlashback:
    culprit = next((s for s in case.suspects if s.sus_id == case.culprit.sus_id), case.suspects[0])
    claimed = _loc_name(case, case.culprit.alibi_lie.claimed_loc_id)
    crime = _loc_name(case, case.victim.found_at_loc_id)
    breakers = [c for c in case.clues if c.contradicts_alibi_of == culprit.sus_id][:3]
    return PublicFlashback(
        title="TWO ACCOUNTS",
        a=FlashbackAccount(
            who=f"{culprit.name} SAYS",
            scene=claimed,
            lines=(culprit.stated_alibi.claim_text, f"I was in {claimed} the whole time.", "I never went near it."),
            flags=(),
        ),
        b=FlashbackAccount(
            who="THE EVIDENCE SAYS",
            scene=crime,
            lines=tuple(c.reveal_text[:90] for c in breakers) or ("The evidence places someone at the scene.",),
            flags=tuple(range(len(breakers))),
        ),
    )


def _motives(case: CaseFile, seed: int) -> tuple[PublicMotive, ...]:
    true_text = case.culprit.true_motive.summary
    decoys = [d for d in _DECOY_MOTIVES if d.lower() != true_text.lower()]
    chosen = [PublicMotive(id="M1", text=true_text)]
    for i in range(3):
        chosen.append(PublicMotive(id=f"MD{i + 1}", text=decoys[(seed + i) % len(decoys)]))
    # stable shuffle by seed so the true option isn't always first
    rot = seed % len(chosen)
    return tuple(chosen[rot:] + chosen[:rot])


def _story_beats(case: CaseFile, profile: CrimeProfile, scene: str) -> tuple[StoryBeat, ...]:
    """Each beat's backdrop is the place its text describes: the city for the call, the
    REAL crime scene for the incident, another room of the same building for the cast,
    and the detective's desk for the hand-off."""
    v = case.victim
    crime_loc = case.victim.found_at_loc_id
    building = case.setting.name
    other_room = next(
        (loc.name for loc in case.setting.locations if loc.loc_id != crime_loc),
        _loc_name(case, crime_loc),
    )
    return (
        StoryBeat(scene="skyline", kicker=case.setting.name.upper(), title="The call", text=case.briefing),
        StoryBeat(scene=scene, kicker="THE VICTIM", title=v.name, text=f"{v.name}, {v.role}. {v.cause_of_death}"),
        StoryBeat(scene=f"{building}{other_room}", kicker="THOSE WHO STAYED", title="Persons of interest",
                  text="Each of them had a reason to be here tonight, and a story you'll need to take apart."),
        StoryBeat(scene="desk", kicker="YOUR CASE NOW", title="Detective",
                  text="One of them is lying to your face. Find the crack in the account and follow it down."),
    )


def casefile_to_public(case: CaseFile) -> PublicCase:
    seed = case.seed
    profile = profile_for(case.crime_kind)
    tod = case.victim.time_of_death.start_min
    building = case.setting.name
    room = _loc_name(case, case.victim.found_at_loc_id)
    scene = f"{building}{room}"  # building + room; the painter keys off the room
    city = _CITIES[seed % len(_CITIES)]
    return PublicCase(
        id=case.case_id,
        city=city,
        district=building,
        title=case.title,
        tagline="Detective, your presence is required.",
        weather=_WEATHER[seed % len(_WEATHER)],
        victim=PublicVictim(name=case.victim.name, role=case.victim.role, age=40 + (seed % 30), sprite="victim", bio=case.briefing),
        scene=scene,
        tod=_clock(tod),
        found=f"{profile.found_verb} {scene} at {_clock(case.victim.found_at_min)}.",
        cause=case.victim.cause_of_death,
        kind=profile.kind.value,
        kind_label=profile.kind_label,
        division=profile.division,
        victim_status=profile.victim_status,
        tod_label=profile.tod_label,
        verdict=profile.verdict,
        facts=(
            ("CITY", city),
            ("VICTIM", f"{case.victim.name}"),
            ("SCENE", scene),
            (profile.tod_label, _clock(tod)),
            ("WHAT HAPPENED", case.victim.cause_of_death),
            ("VERDICT", profile.verdict),
        ),
        boot_lines=(
            "The phone drags you up out of half a sleep.",
            f"{case.setting.name}. {profile.boot_line}",
            f"{case.victim.name} - {case.victim.role}.",
            "They're holding the scene. It's yours now, detective.",
        ),
        story_beats=_story_beats(case, profile, scene),
        suspects=tuple(_suspect_public(case, i) for i in range(len(case.suspects))),
        evidence=tuple(_evidence_public(case, c, i) for i, c in enumerate(case.clues)),
        timeline=_timeline(case, profile),
        flashback=_flashback(case),
        motives=_motives(case, seed),
    )