case0 / src /case_zero /api /case_adapter.py
HusseinEid's picture
feat: multi-crime cases, scene+exhibit pixel art, background AI generation
80cd1f2 verified
"""Project a generated ``CaseFile`` into the PUBLIC wire view (the same contract the
golden case uses). Deterministic: it synthesizes the display surface from the case's
own fields. Nothing from the sealed solution (culprit, true motive id, breaker chain)
is exposed; the true motive's *text* appears only as one option among the decoys.
Generated evidence renders as paper exhibits (reveal_text); richer per-type payloads
(threads, keycard tables, voicemail) and authored tags/quotes are a later enhancement.
"""
from __future__ import annotations
import re
from ..generator.crime_profiles import CrimeProfile, profile_for
from ..schemas.case import CaseFile
from ..schemas.clue import Clue
from .public_view import (
FlashbackAccount,
PublicCase,
PublicEvidence,
PublicFlashback,
PublicMotive,
PublicSuspect,
PublicVictim,
StoryBeat,
SuggestedQuestion,
TimelineBeat,
)
from .questions import FIXED_QUESTIONS
_SCENES = ("skyline", "atrium", "interro", "seawall", "mezzanine", "desk")
_WEATHER = (
"Rain, and a wind off the water.", "Fog thick enough to lean on.",
"A dry cold that gets into the joints.", "Sleet against every window.",
)
_GREETS = (
"Detective. Let's get this over with.",
"I already told the others everything I know.",
"Ask what you came to ask.",
"I have nothing to hide, if that's what you think.",
)
_APPARENT = (
"Was close enough to the victim to have a reason - and a chance.",
"Had more to lose tonight than they're letting on.",
"Their story has a seam in it, if you pull the right thread.",
"Stood to gain from a death no one saw coming.",
)
_DECOY_MOTIVES = (
"To bury a debt that was about to surface.",
"Jealousy that had festered for years.",
"To keep a buried secret buried.",
"To settle an old score, finally.",
"Fear of being exposed and ruined.",
)
# Keyword-matched icons so a letter looks like paper and a keycard like a key -
# never a phone icon on a bloodied letter. First match wins; unmatched rotate.
_ICON_RULES: tuple[tuple[re.Pattern[str], str], ...] = (
(re.compile(r"photo|polaroid|snapshot|portrait|negative|film", re.I), "photoEv"),
(re.compile(r"cctv|camera|footage|surveillance|still\b", re.I), "cctv"),
(re.compile(r"voicemail|recording|tape|audio|cylinder|dictaphone", re.I), "voicemail"),
(re.compile(r"phone|telephone|telegram|message|wire\b", re.I), "phone"),
(re.compile(r"key\b|keys\b|keycard|access|badge|pass\b|lock\b", re.I), "keycard"),
(re.compile(r"receipt|ticket|ledger|letter|note\b|paper|document|contract|deed|cheque|"
r"check\b|bill\b|pawn|invoice|book\b|journal|diary|stub", re.I), "receipt"),
(re.compile(r"map\b|compass|route|itinerary|timetable|schedule", re.I), "compass"),
)
_ICONS = ("photoEv", "receipt", "compass")
def _icon_for(name: str, reveal: str, idx: int) -> str:
hay = f"{name} {reveal}"
for rule, icon in _ICON_RULES:
if rule.search(hay):
return icon
return _ICONS[idx % len(_ICONS)]
# Noir city names so CITY reads as an actual city, not the venue (the venue goes in SCENE).
_CITIES = (
"Graymoor", "Blackport", "Ashmoor", "Harrowgate", "Duskwater", "Coldhaven",
"Ravenreach", "Mortlake", "Greyharbor", "Thornehaven", "Mistport", "Hollowmere",
"Saltmarsh", "Ironhaven", "Blackwater", "Fellgate",
)
def _hash(s: str) -> int:
h = 0
for ch in s:
h = (h * 31 + ord(ch)) & 0x7FFFFFFF
return h
def _clock(minute: int) -> str:
h = (minute // 60) % 24
m = minute % 60
suffix = "AM" if h < 12 else "PM"
h12 = h % 12 or 12
return f"{h12}:{m:02d} {suffix}"
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 _suspect_public(case: CaseFile, idx: int) -> PublicSuspect:
s = case.suspects[idx]
seed = _hash(s.sus_id)
role_word = (s.role.split()[-1] if s.role else "").strip(".,").upper()
tag = f"THE {role_word}" if role_word else "PERSON OF INTEREST"
quote = (s.persona_summary.split(".")[0] + ".") if s.persona_summary else "I've nothing to hide."
gender = "female" if ((s.visual.gender if s.visual else "") or "").lower().startswith("f") else "male"
return PublicSuspect(
id=s.sus_id,
name=s.name,
role=s.role,
age=30 + (seed % 35),
sprite=s.sus_id,
gender=gender,
tag=tag[:22],
baseline_suspicion=25 + (seed % 16),
motive=_APPARENT[idx % len(_APPARENT)],
alibi=s.stated_alibi.claim_text,
quote=quote,
greet=_GREETS[idx % len(_GREETS)],
suggested_questions=tuple(SuggestedQuestion(id=q, q=text) for q, text in FIXED_QUESTIONS),
)
def _evidence_public(case: CaseFile, clue: Clue, idx: int) -> PublicEvidence:
at = next((f.at_min for f in case.facts if f.fact_id == clue.supports_fact_id and f.at_min is not None), None)
time = _clock(at) if at is not None else _clock(case.setting.murder_window.start_min + 7 * (idx + 1))
return PublicEvidence(
id=clue.clue_id,
name=clue.name.upper(),
type=clue.discovery_method.value.upper(),
icon=_icon_for(clue.name, clue.reveal_text, idx),
time=time,
found=f"Recovered from {_loc_name(case, clue.discoverable_at_loc_id)}.",
summary=clue.reveal_text,
detail=clue.reveal_text,
)
def _timeline(case: CaseFile, profile: CrimeProfile) -> tuple[TimelineBeat, ...]:
beats: list[TimelineBeat] = []
w = case.setting.murder_window
beats.append(TimelineBeat(time=_clock(w.start_min), label="The evening is under way; everyone is in the house.", locked=True))
culprit = case.culprit.sus_id
for i, clue in enumerate(case.clues):
at = next((f.at_min for f in case.facts if f.fact_id == clue.supports_fact_id and f.at_min is not None), None)
t = at if at is not None else w.start_min + 7 * (i + 1)
conflict = clue.contradicts_alibi_of == culprit
beats.append(TimelineBeat(time=_clock(t), label=clue.reveal_text[:80], ev=clue.clue_id, conflict=conflict))
incident = profile.timeline_line.format(
name=case.victim.name, instrument=case.weapon.name,
room=_loc_name(case, case.victim.found_at_loc_id),
)
beats.append(TimelineBeat(time=_clock(case.victim.time_of_death.start_min), label=incident, locked=True))
beats.sort(key=lambda b: b.time)
return tuple(beats)
def _flashback(case: CaseFile) -> PublicFlashback:
culprit = next((s for s in case.suspects if s.sus_id == case.culprit.sus_id), case.suspects[0])
claimed = _loc_name(case, case.culprit.alibi_lie.claimed_loc_id)
crime = _loc_name(case, case.victim.found_at_loc_id)
breakers = [c for c in case.clues if c.contradicts_alibi_of == culprit.sus_id][:3]
return PublicFlashback(
title="TWO ACCOUNTS",
a=FlashbackAccount(
who=f"{culprit.name} SAYS",
scene=claimed,
lines=(culprit.stated_alibi.claim_text, f"I was in {claimed} the whole time.", "I never went near it."),
flags=(),
),
b=FlashbackAccount(
who="THE EVIDENCE SAYS",
scene=crime,
lines=tuple(c.reveal_text[:90] for c in breakers) or ("The evidence places someone at the scene.",),
flags=tuple(range(len(breakers))),
),
)
def _motives(case: CaseFile, seed: int) -> tuple[PublicMotive, ...]:
true_text = case.culprit.true_motive.summary
decoys = [d for d in _DECOY_MOTIVES if d.lower() != true_text.lower()]
chosen = [PublicMotive(id="M1", text=true_text)]
for i in range(3):
chosen.append(PublicMotive(id=f"MD{i + 1}", text=decoys[(seed + i) % len(decoys)]))
# stable shuffle by seed so the true option isn't always first
rot = seed % len(chosen)
return tuple(chosen[rot:] + chosen[:rot])
def _story_beats(case: CaseFile, profile: CrimeProfile, scene: str) -> tuple[StoryBeat, ...]:
"""Each beat's backdrop is the place its text describes: the city for the call, the
REAL crime scene for the incident, another room of the same building for the cast,
and the detective's desk for the hand-off."""
v = case.victim
crime_loc = case.victim.found_at_loc_id
building = case.setting.name
other_room = next(
(loc.name for loc in case.setting.locations if loc.loc_id != crime_loc),
_loc_name(case, crime_loc),
)
return (
StoryBeat(scene="skyline", kicker=case.setting.name.upper(), title="The call", text=case.briefing),
StoryBeat(scene=scene, kicker="THE VICTIM", title=v.name, text=f"{v.name}, {v.role}. {v.cause_of_death}"),
StoryBeat(scene=f"{building}{other_room}", kicker="THOSE WHO STAYED", title="Persons of interest",
text="Each of them had a reason to be here tonight, and a story you'll need to take apart."),
StoryBeat(scene="desk", kicker="YOUR CASE NOW", title="Detective",
text="One of them is lying to your face. Find the crack in the account and follow it down."),
)
def casefile_to_public(case: CaseFile) -> PublicCase:
seed = case.seed
profile = profile_for(case.crime_kind)
tod = case.victim.time_of_death.start_min
building = case.setting.name
room = _loc_name(case, case.victim.found_at_loc_id)
scene = f"{building}{room}" # building + room; the painter keys off the room
city = _CITIES[seed % len(_CITIES)]
return PublicCase(
id=case.case_id,
city=city,
district=building,
title=case.title,
tagline="Detective, your presence is required.",
weather=_WEATHER[seed % len(_WEATHER)],
victim=PublicVictim(name=case.victim.name, role=case.victim.role, age=40 + (seed % 30), sprite="victim", bio=case.briefing),
scene=scene,
tod=_clock(tod),
found=f"{profile.found_verb} {scene} at {_clock(case.victim.found_at_min)}.",
cause=case.victim.cause_of_death,
kind=profile.kind.value,
kind_label=profile.kind_label,
division=profile.division,
victim_status=profile.victim_status,
tod_label=profile.tod_label,
verdict=profile.verdict,
facts=(
("CITY", city),
("VICTIM", f"{case.victim.name}"),
("SCENE", scene),
(profile.tod_label, _clock(tod)),
("WHAT HAPPENED", case.victim.cause_of_death),
("VERDICT", profile.verdict),
),
boot_lines=(
"The phone drags you up out of half a sleep.",
f"{case.setting.name}. {profile.boot_line}",
f"{case.victim.name} - {case.victim.role}.",
"They're holding the scene. It's yours now, detective.",
),
story_beats=_story_beats(case, profile, scene),
suspects=tuple(_suspect_public(case, i) for i in range(len(case.suspects))),
evidence=tuple(_evidence_public(case, c, i) for i, c in enumerate(case.clues)),
timeline=_timeline(case, profile),
flashback=_flashback(case),
motives=_motives(case, seed),
)