Spaces:
Running
Running
| """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), | |
| ) | |