Spaces:
Running
Running
File size: 9,082 Bytes
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 | """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),
)
|