"""The PUBLIC wire contract: the exact case shape the Preact client receives. This mirrors the prototype's ``prototype/js/case.jsx`` (the data-model-by-example) in camelCase. It is the ONLY case representation that ever reaches the browser before the verdict. The sealed solution - killer, true motive, key-evidence chain - and every per-question/per-evidence suspicion *delta* and scripted *answer* are deliberately absent: those are produced live, server-side (the deltas alone would reveal the killer). """ from __future__ import annotations from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel class _Wire(BaseModel): """camelCase on the wire; snake_case in Python. Frozen - a view is never mutated.""" model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, frozen=True) class PublicVictim(_Wire): name: str role: str age: int sprite: str bio: str class SuggestedQuestion(_Wire): id: str q: str class PublicSuspect(_Wire): id: str name: str role: str age: int sprite: str gender: str = "male" tag: str baseline_suspicion: int motive: str # apparent motive shown in the dossier - NOT proof alibi: str quote: str greet: str suggested_questions: tuple[SuggestedQuestion, ...] class ThreadMessage(_Wire): from_: str = Field(alias="from") who: str t: str m: str class PublicEvidence(_Wire): id: str name: str type: str # PHONE | PAPER | IMAGE | AUDIO | DATA icon: str time: str found: str summary: str # Exactly one of these display payloads is populated, per type. thread: tuple[ThreadMessage, ...] | None = None detail: str | None = None transcript: str | None = None dur: str | None = None rows: tuple[tuple[str, ...], ...] | None = None class TimelineBeat(_Wire): time: str label: str locked: bool = False ev: str | None = None conflict: bool = False class FlashbackAccount(_Wire): who: str scene: str lines: tuple[str, ...] flags: tuple[int, ...] = () class PublicFlashback(_Wire): title: str a: FlashbackAccount b: FlashbackAccount class PublicMotive(_Wire): id: str text: str class StoryBeat(_Wire): scene: str kicker: str title: str text: str class PublicCase(_Wire): id: str city: str district: str title: str tagline: str weather: str victim: PublicVictim scene: str tod: str found: str cause: str facts: tuple[tuple[str, str], ...] boot_lines: tuple[str, ...] story_beats: tuple[StoryBeat, ...] suspects: tuple[PublicSuspect, ...] evidence: tuple[PublicEvidence, ...] timeline: tuple[TimelineBeat, ...] flashback: PublicFlashback motives: tuple[PublicMotive, ...] def golden_to_public(data: dict) -> PublicCase: """Project a sealed golden/stored case dict into the PUBLIC view. Strips the ``sealed`` block and, per suspect, the scripted ``answer``/``delta`` and the ``present``/``default`` reply tables - leaving only question text the player sees. """ suspects = tuple( PublicSuspect( id=s["id"], name=s["name"], role=s["role"], age=s["age"], sprite=s["sprite"], gender=s.get("gender", "male"), tag=s["tag"], baseline_suspicion=s["suspicion"], motive=s["motive"], alibi=s["alibi"], quote=s["quote"], greet=s["greet"], suggested_questions=tuple( SuggestedQuestion(id=q["id"], q=q["q"]) for q in s["questions"] ), ) for s in data["suspects"] ) return PublicCase( id=data["id"], city=data["city"], district=data["district"], title=data["title"], tagline=data["tagline"], weather=data["weather"], victim=PublicVictim(**data["victim"]), scene=data["scene"], tod=data["tod"], found=data["found"], cause=data["cause"], facts=tuple(tuple(pair) for pair in data["facts"]), boot_lines=tuple(data["bootLines"]), story_beats=tuple(StoryBeat(**b) for b in data.get("storyBeats", [])), suspects=suspects, evidence=tuple(PublicEvidence(**e) for e in data["evidence"]), timeline=tuple(TimelineBeat(**b) for b in data["timeline"]), flashback=PublicFlashback( title=data["flashback"]["title"], a=FlashbackAccount(**data["flashback"]["a"]), b=FlashbackAccount(**data["flashback"]["b"]), ), motives=tuple(PublicMotive(**m) for m in data["motives"]), )