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