case0 / src /case_zero /schemas /case.py
HusseinEid's picture
Case Zero - initial public release (fully local: Qwen2.5-1.5B via llama.cpp + Supertonic, custom pixel-noir SPA via gradio.Server)
414dc55
"""The CaseFile: the complete hidden ground truth for one mystery.
This is server-side only. The player never receives it directly - they get a
``PlayerCaseView`` projection (see projections.player_view) with the solution and
all ``is_culprit`` flags stripped. Deep referential and solvability invariants are
enforced by ``solver.checker`` so failures can target a single regenerated slice.
"""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
from ..constants import CASE_SCHEMA_VERSION, MAX_SUSPECTS, MIN_SUSPECTS
from .clue import Clue, Fact
from .enums import Difficulty, MotiveCategory
from .suspect import Suspect
from .timeline import Location, TimeWindow
class GenerationKnobs(BaseModel):
"""Light seeds for variety. Creative hints may be empty - then the model is
free to invent setting, era, and tone from scratch."""
model_config = ConfigDict(frozen=True)
setting_hint: str = ""
era_hint: str = ""
tone_hint: str = ""
n_suspects: int = Field(default=4, ge=MIN_SUSPECTS, le=MAX_SUSPECTS)
n_red_herrings: int = Field(default=2, ge=0, le=6)
alibi_tightness: float = Field(default=0.6, ge=0.0, le=1.0)
difficulty: Difficulty = Difficulty.STANDARD
class Setting(BaseModel):
model_config = ConfigDict(frozen=True)
name: str
description: str
locations: tuple[Location, ...]
murder_window: TimeWindow
class Victim(BaseModel):
model_config = ConfigDict(frozen=True)
vic_id: str
name: str
role: str
found_at_loc_id: str
found_at_min: int
cause_of_death: str
time_of_death: TimeWindow
class Weapon(BaseModel):
model_config = ConfigDict(frozen=True)
weapon_id: str
name: str
kind: str
origin_loc_id: str
requires_strength: bool = False
leaves_trace: str = ""
class Relationship(BaseModel):
model_config = ConfigDict(frozen=True)
from_sus_id: str
to_sus_id: str
kind: str
sentiment: float = Field(default=0.0, ge=-1.0, le=1.0)
known_publicly: bool = True
class Motive(BaseModel):
model_config = ConfigDict(frozen=True)
motive_id: str
category: MotiveCategory
summary: str
class AlibiLie(BaseModel):
model_config = ConfigDict(frozen=True)
claimed_loc_id: str
actual_loc_id: str
contradicted_by_clue_ids: tuple[str, ...]
class Culprit(BaseModel):
model_config = ConfigDict(frozen=True)
sus_id: str
true_motive: Motive
method_narrative: str
alibi_lie: AlibiLie
class Solution(BaseModel):
model_config = ConfigDict(frozen=True)
culprit_sus_id: str
weapon_id: str
motive_id: str
minimal_clue_set: tuple[str, ...]
deduction_chain: tuple[str, ...]
class CaseFile(BaseModel):
"""The full, hidden mystery. Frozen: a generated case is never mutated in place."""
model_config = ConfigDict(frozen=True)
case_id: str
seed: int
schema_version: str = CASE_SCHEMA_VERSION
title: str
briefing: str
knobs: GenerationKnobs
setting: Setting
victim: Victim
weapon: Weapon
suspects: tuple[Suspect, ...] = Field(min_length=MIN_SUSPECTS, max_length=MAX_SUSPECTS)
culprit: Culprit
relationships: tuple[Relationship, ...] = ()
facts: tuple[Fact, ...] = ()
clues: tuple[Clue, ...] = ()
solution: Solution
def suspect(self, sus_id: str) -> Suspect:
for s in self.suspects:
if s.sus_id == sus_id:
return s
raise KeyError(f"no suspect {sus_id!r}")
def clue(self, clue_id: str) -> Clue:
for c in self.clues:
if c.clue_id == clue_id:
return c
raise KeyError(f"no clue {clue_id!r}")