"""Author the hand-crafted tutorial case and write cases/seeds/tutorial.json. The tutorial doubles as: the onboarding case that teaches the loop, a deterministic demo case, and a golden test fixture. It is fully solvable by construction; the solvability checker is run before it is written. """ from __future__ import annotations import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT / "src")) from case_zero.constants import SEED_CASES_DIR # noqa: E402 from case_zero.persistence.case_store import save_case # noqa: E402 from case_zero.schemas import ( # noqa: E402 AlibiLie, AlibiSegment, AnchoredLie, CaseFile, Clue, Culprit, Difficulty, DiscoveryMethod, Fact, GenerationKnobs, Location, Motive, MotiveCategory, PersonalityAxes, PhysicalCapability, Relationship, Setting, Solution, StatedAlibi, Suspect, TimeWindow, Victim, VisualDescriptor, Weapon, WhereaboutsSegment, ) from case_zero.schemas.enums import SubjectType # noqa: E402 def m(hour: int, minute: int) -> int: return hour * 60 + minute WINDOW = TimeWindow(start_min=m(21, 0), end_min=m(22, 0)) def _locations() -> tuple[Location, ...]: return ( Location(loc_id="L1", name="The Lounge", description="The main club floor.", adjacent_to=("L2", "L3", "L5")), Location(loc_id="L2", name="The Kitchen", adjacent_to=("L1",)), Location(loc_id="L3", name="The Terrace", description="The open rooftop stage.", adjacent_to=("L1",)), Location(loc_id="L4", name="The Office", description="Vane's private office.", adjacent_to=("L5",)), Location(loc_id="L5", name="The Cloakroom", adjacent_to=("L1", "L4")), ) def _facts() -> tuple[Fact, ...]: return ( Fact(fact_id="F1", statement="Margot was in the office during the murder window.", true_value=True, loc_id="L4", at_min=m(21, 30)), Fact(fact_id="F2", statement="Margot smokes Gauloises cigarettes.", true_value=True), Fact(fact_id="F3", statement="Eddie has been skimming from the till.", true_value=True), Fact(fact_id="F4", statement="Lillian was secretly in love with Cornelius.", true_value=True), Fact(fact_id="F5", statement="Boone falsified the club's ledgers.", true_value=True), ) def _clues() -> tuple[Clue, ...]: return ( Clue(clue_id="C1", name="Cloakroom ticket (9:48 PM)", reveal_text="A cloakroom claim ticket stamped 9:48 PM lies near the body - someone " "fetched a wrap mid-evening and ended up here.", discoverable_at_loc_id="L4", discovery_method=DiscoveryMethod.FORENSIC, supports_fact_id="F1", points_to_sus_id="S1", contradicts_alibi_of="S1", weight=1.0), Clue(clue_id="C2", name="Gauloises ash", reveal_text="Grey ash from a Gauloises cigarette on the office rug - an unusual brand.", discoverable_at_loc_id="L4", discovery_method=DiscoveryMethod.FORENSIC, supports_fact_id="F2", points_to_sus_id="S1", contradicts_alibi_of="S1", weight=0.7), Clue(clue_id="C3", name="Doctored bar tab", reveal_text="A bar ledger showing Eddie quietly forgiving his own tabs.", discoverable_at_loc_id="L1", discovery_method=DiscoveryMethod.DOCUMENT, supports_fact_id="F3", points_to_sus_id="S2", is_red_herring=True, weight=0.3), Clue(clue_id="C4", name="Torn love letter", reveal_text="A torn letter in Lillian's hand to Cornelius - equal parts love and fury.", discoverable_at_loc_id="L5", discovery_method=DiscoveryMethod.SEARCH, supports_fact_id="F4", points_to_sus_id="S3", is_red_herring=True, weight=0.3), Clue(clue_id="C5", name="Altered ledger page", reveal_text="A ledger page with figures scratched out and rewritten in Boone's hand.", discoverable_at_loc_id="L4", discovery_method=DiscoveryMethod.DOCUMENT, supports_fact_id="F5", points_to_sus_id="S4", is_red_herring=True, weight=0.3), ) def _visual(age: str, attire: str, mood: str, accent: str, gender: str) -> VisualDescriptor: return VisualDescriptor(subject_type=SubjectType.SUSPECT, palette="noir", age_band=age, attire=attire, mood=mood, accent_color=accent, gender=gender) def _suspects() -> tuple[Suspect, ...]: margot = Suspect( sus_id="S1", name="Margot Vane", role="the widow and co-owner", persona_summary="Poised and imperious in mourning black; clipped, controlled speech, and " "she resents every question.", is_culprit=True, physical_capability=PhysicalCapability(strength=True, mobility=True), personality=PersonalityAxes(composure=0.8, aggression=0.5, evasiveness=0.7), tells=("a too-steady voice", "smoothing her gloves"), knows_facts=("F1", "F2"), secrets=("Cornelius was about to cut you out of the club; his death secures everything.",), true_whereabouts=( WhereaboutsSegment(window=TimeWindow(start_min=m(21, 0), end_min=m(21, 20)), loc_id="L1", activity="greeting patrons in the lounge", co_present_sus_ids=("S2",)), WhereaboutsSegment(window=TimeWindow(start_min=m(21, 20), end_min=m(21, 50)), loc_id="L4", activity="confronting Cornelius in the office"), WhereaboutsSegment(window=TimeWindow(start_min=m(21, 50), end_min=m(22, 0)), loc_id="L5", activity="composing herself in the cloakroom"), ), stated_alibi=StatedAlibi( claim_text="I was in the lounge the entire evening, in plain sight of a dozen guests.", claimed_segments=(AlibiSegment(window=WINDOW, loc_id="L1", witness_sus_ids=("S2",)),), ), must_lie_about=("F1",), anchored_lies=( AnchoredLie( lie_id="LIE_alibi", topic="where you were during the murder", claimed="I never once left the lounge; ask any of the patrons.", truth_ref="F1", breaks_on=("C1", "C2"), fallback="Fine - I stepped out to the cloakroom for my wrap, that is all. I never " "went near the office.", ), ), visual=_visual("50s", "a black beaded evening gown", "imperious", "#b8860b", "female"), ) eddie = Suspect( sus_id="S2", name="Eddie Marsh", role="the bartender", persona_summary="Quick and affable, sweats easily, and keeps glancing at the till.", personality=PersonalityAxes(composure=0.3, aggression=0.2, evasiveness=0.6), tells=("wiping a bar that is already clean",), knows_facts=("F3",), secrets=("You have been skimming from the till for months.",), true_whereabouts=( WhereaboutsSegment(window=WINDOW, loc_id="L1", activity="tending the bar", co_present_sus_ids=("S1", "S4")), ), stated_alibi=StatedAlibi( claim_text="I never left the bar; somebody always wanted a drink.", claimed_segments=(AlibiSegment(window=WINDOW, loc_id="L1", witness_sus_ids=("S1", "S4")),), ), must_lie_about=("F3",), anchored_lies=( AnchoredLie(lie_id="LIE_till", topic="the till and the money", claimed="The till always comes up square; I would never touch it.", truth_ref="F3", breaks_on=("C3",), fallback="All right, I have borrowed against tips. It has nothing to do " "with Cornelius."), ), visual=_visual("30s", "shirtsleeves and a bar apron", "nervous", "#3a6ea5", "male"), ) lillian = Suspect( sus_id="S3", name="Lillian Frost", role="the headline singer", persona_summary="Theatrical and guarded about her private life, mourning beneath the glamour.", personality=PersonalityAxes(composure=0.6, aggression=0.4, evasiveness=0.5), tells=("a brittle laugh",), knows_facts=("F4",), secrets=("You were secretly in love with Cornelius and quarreled with him.",), true_whereabouts=( WhereaboutsSegment(window=WINDOW, loc_id="L3", activity="performing two sets on the terrace"), ), stated_alibi=StatedAlibi( claim_text="I was on the terrace, singing, the whole hour. Two hundred people saw me.", claimed_segments=(AlibiSegment(window=WINDOW, loc_id="L3"),), ), must_lie_about=("F4",), anchored_lies=( AnchoredLie(lie_id="LIE_affair", topic="your relationship with Cornelius", claimed="Cornelius and I were strictly professional.", truth_ref="F4", breaks_on=("C4",), fallback="We were close. I loved him. But I would never hurt him."), ), visual=_visual("20s", "a silver sequined stage dress", "guarded", "#9a9aa0", "female"), ) boone = Suspect( sus_id="S4", name="Septimus Boone", role="the club's accountant", persona_summary="Meticulous and nervous, speaks in numbers, and is terrified of an audit.", personality=PersonalityAxes(composure=0.4, aggression=0.2, evasiveness=0.7), tells=("polishing his spectacles",), knows_facts=("F5",), secrets=("You falsified the ledgers to hide your own embezzlement.",), true_whereabouts=( WhereaboutsSegment(window=WINDOW, loc_id="L1", activity="going over receipts at a corner table", co_present_sus_ids=("S2",)), ), stated_alibi=StatedAlibi( claim_text="I sat at my usual corner table in the lounge with my books all night.", claimed_segments=(AlibiSegment(window=WINDOW, loc_id="L1", witness_sus_ids=("S2",)),), ), must_lie_about=("F5",), anchored_lies=( AnchoredLie(lie_id="LIE_ledger", topic="the ledgers and the books", claimed="The books are in perfect order, I assure you.", truth_ref="F5", breaks_on=("C5",), fallback="There may be irregularities - mine, not murder. I was nowhere " "near him."), ), visual=_visual("40s", "a rumpled three-piece suit", "anxious", "#6b8f71", "male"), ) return (margot, eddie, lillian, boone) def build_tutorial_case() -> CaseFile: return CaseFile( case_id="tutorial-gilded-aerie", seed=1989, title="The Gilded Aerie", briefing=( "1924. On the rooftop of the Gilded Aerie supper club, owner Cornelius Vane is found " "dead in his office, struck down with his own brass award. The club was full; everyone " "had a reason to resent him. Four people could have slipped away during the murder hour. " "Question them, search the rooms, and present what you find. One of them is lying about " "where they were - catch that lie, and you have your killer." ), knobs=GenerationKnobs(setting_hint="rooftop supper club", era_hint="1924", tone_hint="jazz-age noir", n_suspects=4, n_red_herrings=3, difficulty=Difficulty.GENTLE), setting=Setting(name="The Gilded Aerie", description="A rooftop supper club above the city.", locations=_locations(), murder_window=WINDOW), victim=Victim(vic_id="V1", name="Cornelius Vane", role="club owner", found_at_loc_id="L4", found_at_min=m(22, 5), cause_of_death="blunt force from a brass statuette", time_of_death=TimeWindow(start_min=m(21, 20), end_min=m(21, 50))), weapon=Weapon(weapon_id="W1", name="brass award statuette", kind="blunt object", origin_loc_id="L4", requires_strength=False, leaves_trace="blood and a dented base"), suspects=_suspects(), culprit=Culprit( sus_id="S1", true_motive=Motive(motive_id="M1", category=MotiveCategory.GREED, summary="Cornelius was about to cut Margot out of the club; his death " "secures her inheritance."), method_narrative="You followed Cornelius to the office, argued about the will, and " "struck him with the brass statuette.", alibi_lie=AlibiLie(claimed_loc_id="L1", actual_loc_id="L4", contradicted_by_clue_ids=("C1", "C2")), ), relationships=( Relationship(from_sus_id="S1", to_sus_id="S2", kind="employer of", sentiment=0.1), Relationship(from_sus_id="S4", to_sus_id="S2", kind="friendly with", sentiment=0.3), ), facts=_facts(), clues=_clues(), solution=Solution( culprit_sus_id="S1", weapon_id="W1", motive_id="M1", minimal_clue_set=("C1",), deduction_chain=( "Cornelius was killed in the office between 9:20 and 9:50.", "Every suspect but Margot has a witness for that window.", "Margot swears she never left the lounge - yet her cloakroom ticket, stamped 9:48, " "was found beside the body.", "Her alibi is a lie; she was in the office. Margot Vane is the killer.", ), ), ) def main() -> None: case = build_tutorial_case() out = SEED_CASES_DIR / "tutorial.json" save_case(case, out) print(f"wrote {out} ({len(case.suspects)} suspects, {len(case.clues)} clues)") if __name__ == "__main__": main()