Spaces:
Running
Running
File size: 11,232 Bytes
414dc55 80cd1f2 414dc55 80cd1f2 414dc55 80cd1f2 414dc55 80cd1f2 414dc55 80cd1f2 414dc55 80cd1f2 414dc55 80cd1f2 414dc55 80cd1f2 414dc55 80cd1f2 414dc55 80cd1f2 414dc55 80cd1f2 414dc55 80cd1f2 414dc55 80cd1f2 414dc55 80cd1f2 414dc55 80cd1f2 414dc55 80cd1f2 414dc55 | 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 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 | """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),
)
|