File size: 6,586 Bytes
16ff49b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Deterministic rich-exhibit payloads for generated and prebaked cases.

The golden case hand-authors device mockups (phone threads, voicemail players, keycard
tables, CCTV stills). Generated cases get the same treatment here, synthesized at serve
time from each clue's OWN public material - no model call, instant, reproducible.

NO-LEAK RULE (enforced by tests): synthesizers may read ONLY
  - the clue's name / reveal_text / location / supporting fact's ``at_min``,
  - suspects' names, roles, and STATED alibis (their public dossier claims),
  - the victim's name and the murder window.
Never the culprit, solution, secrets, true whereabouts, anchored lies, or any sealed
``Fact.statement``. A suspect's name may appear in synthesized content only if it is
verbatim in the reveal_text (already shown to players) or restating that suspect's own
stated alibi. Filler lines come from fixed banks that assert no plot facts.
"""

from __future__ import annotations

from ..schemas.case import CaseFile
from ..schemas.clue import Clue
from ..schemas.enums import DiscoveryMethod
from .public_view import ThreadMessage


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


def _clock12(minute: int) -> str:
    h = (minute // 60) % 24
    m = minute % 60
    return f"{h % 12 or 12}:{m:02d}"


def _clock24(minute: int) -> str:
    return f"{(minute // 60) % 24:02d}:{minute % 60:02d}"


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 _surname(full: str) -> str:
    parts = (full or "").split()
    return parts[-1] if parts else full


def _named_in(text: str, case: CaseFile) -> str | None:
    """A suspect's name, only if the clue's own text already names them."""
    hay = text or ""
    for s in case.suspects:
        if s.name and s.name in hay:
            return s.name
        sur = _surname(s.name)
        if len(sur) > 2 and sur in hay:
            return s.name
    return None


def _split_sentences(text: str, limit: int = 2) -> list[str]:
    parts = [p.strip() for p in (text or "").replace("? ", "?|").replace("! ", "!|").replace(". ", ".|").split("|") if p.strip()]
    if not parts:
        return [text or ""]
    if len(parts) <= limit:
        return parts
    head = " ".join(parts[: len(parts) - limit + 1])
    return [head, *parts[len(parts) - limit + 1:]]


# Plot-neutral openers: timestamps and pleasantries only, never case facts.
_OPENERS = (
    "We need to talk.",
    "Not over the phone.",
    "Are you there?",
    "Don't make me ask twice.",
    "You know why I'm writing.",
)


def _thread(case: CaseFile, clue: Clue, base_min: int) -> dict:
    seed = _hash(case.case_id + clue.clue_id)
    other = _named_in(f"{clue.name} {clue.reveal_text}", case)
    who_other = _surname(other).upper() if other else "UNKNOWN"
    who_me = _surname(case.victim.name).upper()
    climax = _split_sentences(clue.reveal_text, 2)
    n_open = 2 + seed % 2
    msgs: list[ThreadMessage] = []
    minute = base_min - 3 * (n_open + len(climax))
    for i in range(n_open):
        frm = "them" if i % 2 == 0 else "me"
        msgs.append(ThreadMessage(**{
            "from": frm,
            "who": who_other if frm == "them" else who_me,
            "t": _clock12(minute),
            "m": _OPENERS[(seed + i) % len(_OPENERS)],
        }))
        minute += 2 + (seed >> i) % 3
    for i, line in enumerate(climax):
        frm = "them" if (n_open + i) % 2 == 0 else "me"
        msgs.append(ThreadMessage(**{
            "from": frm,
            "who": who_other if frm == "them" else who_me,
            "t": _clock12(min(minute, base_min)),
            "m": line,
        }))
        minute += 2
    return {"type": "PHONE", "thread": tuple(msgs)}


def _voicemail(clue: Clue) -> dict:
    text = (clue.reveal_text or "").strip()
    quoted = text if text.startswith(("“", '"')) else f"“{text}”"
    secs = max(8, min(55, round(len(text.split()) * 0.4)))
    return {"type": "AUDIO", "transcript": quoted, "dur": f"0:{secs:02d}"}


def _keycard(case: CaseFile, clue: Clue, base_min: int) -> dict:
    rows: list[tuple[str, str, str, str]] = []
    for s in case.suspects[:4]:
        segs = s.stated_alibi.claimed_segments
        if not segs:
            continue
        seg = segs[0]
        rows.append((
            _clock24(seg.window.start_min),
            _loc_name(case, seg.loc_id),
            _surname(s.name).upper(),
            "ok",
        ))
    holder = _named_in(f"{clue.name} {clue.reveal_text}", case)
    rows.append((
        _clock24(base_min),
        _loc_name(case, clue.discoverable_at_loc_id),
        _surname(holder).upper() if holder else "UNREGISTERED",
        "flag",
    ))
    rows.sort(key=lambda r: r[0])
    return {"type": "DATA", "rows": tuple(rows[:6])}


def _cctv(case: CaseFile, clue: Clue, time_str: str) -> dict:
    loc = _loc_name(case, clue.discoverable_at_loc_id)
    return {
        "type": "IMAGE",
        "detail": f"CAM — {loc.upper()}\n{time_str}\n{clue.reveal_text}",
    }


def _photo(case: CaseFile, clue: Clue, idx: int, keep_icon: bool = False) -> dict:
    loc = _loc_name(case, clue.discoverable_at_loc_id)
    out: dict = {
        "type": "IMAGE",
        "detail": f"{loc.upper()} — EVIDENCE MARKER {idx + 1}\n{clue.reveal_text}",
    }
    if not keep_icon:
        out["icon"] = "photoEv"
    return out


# Forensic finds that read best as an in-situ scene photograph (evidence marker shot):
# the locker shows the photo frame, while the board/dossier keep the object art.
_IN_SITU_ICONS = frozenset({"fingerprint", "bootprint", "tumbler", "jewel", "clock"})


def synthesize_payload(case: CaseFile, clue: Clue, icon: str, idx: int, base_min: int, time_str: str) -> dict:
    """Extra ``PublicEvidence`` kwargs for one clue: a display type plus at most one
    rich payload. Anything unmatched stays a paper exhibit (the existing look)."""
    if icon == "phone":
        return _thread(case, clue, base_min)
    if icon == "voicemail" or (clue.discovery_method is DiscoveryMethod.INTERROGATION):
        return _voicemail(clue)
    if icon == "keycard":
        return _keycard(case, clue, base_min)
    if icon == "cctv":
        return _cctv(case, clue, time_str)
    if icon == "photoEv":
        return _photo(case, clue, idx)
    if clue.discovery_method is DiscoveryMethod.FORENSIC and icon in _IN_SITU_ICONS:
        return _photo(case, clue, idx, keep_icon=True)
    return {}