Spaces:
Running
Running
| """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 {} | |