"""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 {}