case0 / src /case_zero /api /exhibits.py
HusseinEid's picture
Per-case visuals, incident vignettes, guided play, no-repeat dealing, four new crime kinds (#4)
16ff49b
Raw
History Blame
6.59 kB
"""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 {}