"""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 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.", ) _ICONS = ("photoEv", "receipt", "keycard", "compass", "phone", "cctv") # 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=_ICONS[idx % len(_ICONS)], 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) -> 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)) beats.append(TimelineBeat(time=_clock(case.victim.time_of_death.start_min), label=f"{case.victim.name} is killed. Time of death.", 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) -> tuple[StoryBeat, ...]: v = case.victim return ( StoryBeat(scene="skyline", kicker=case.setting.name.upper(), title="The call", text=case.briefing), StoryBeat(scene="atrium", kicker="THE VICTIM", title=v.name, text=f"{v.name}, {v.role}. {v.cause_of_death}"), StoryBeat(scene="interro", 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="seawall", 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 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"Found in {scene} at {_clock(case.victim.found_at_min)}.", cause=case.victim.cause_of_death, facts=( ("CITY", city), ("VICTIM", f"{case.victim.name}"), ("SCENE", scene), ("TIME OF DEATH", _clock(tod)), ("CAUSE", case.victim.cause_of_death), ("VERDICT", "Homicide"), ), boot_lines=( "The phone drags you up out of half a sleep.", f"{case.setting.name}. A death no one saw coming.", f"{case.victim.name} - {case.victim.role}.", "They're holding the scene. It's yours now, detective.", ), story_beats=_story_beats(case), 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), flashback=_flashback(case), motives=_motives(case, seed), )