File size: 4,954 Bytes
80cd1f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Multi-crime-type cases: every kind assembles, solves, and projects with the right
display labels - and homicide stays the unchanged default for old case files.
"""

from __future__ import annotations

import pytest

from case_zero.api.case_adapter import casefile_to_public
from case_zero.generator.assemble import assemble_case
from case_zero.generator.crime_profiles import PROFILES, kind_for_seed, profile_for
from case_zero.generator.stages import GenSuspect, MysteryOut, WorldCastOut, mystery_prompt, world_cast_prompt
from case_zero.projections.suspect_brief import build_suspect_brief
from case_zero.schemas.case import GenerationKnobs
from case_zero.schemas.enums import CrimeKind, MotiveCategory
from case_zero.schemas.timeline import TimeWindow
from case_zero.solver.checker import check


def _world() -> WorldCastOut:
    return WorldCastOut(
        title="THE LANTERN ROOM",
        briefing="Trouble at a winter salon.",
        setting_name="Ashgrove House",
        locations=["The Study", "The Parlor", "The Conservatory", "The Cellar"],
        victim_name="Edmund Crane",
        victim_role="Master Bookbinder",
        cause_of_death="The loss came to light just before midnight.",
        found_at_index=0,
        weapon_name="bookbinding press",
        weapon_kind="blunt",
        suspects=[
            GenSuspect(name="Cora Vale", role="Apprentice", persona_summary="Quiet, ink-stained.", secret="She forged a signature once.", cover_story="I was cataloguing.", evidence_name="ink bottle", evidence_reveal="A spilled bottle of rare ink.", gender="female"),
            GenSuspect(name="Miles Ardent", role="Patron", persona_summary="Wealthy, indebted.", secret="He owes the victim money.", cover_story="I never left the salon.", evidence_name="promissory note", evidence_reveal="A note for a large sum, unpaid.", gender="male"),
            GenSuspect(name="Greta Holt", role="Housekeeper", persona_summary="Long-serving.", secret="She skims the accounts.", cover_story="I was fetching wine.", evidence_name="ledger", evidence_reveal="A ledger with altered figures.", gender="female"),
        ],
    )


def _mystery() -> MysteryOut:
    return MysteryOut(
        motive_category=MotiveCategory.GREED,
        motive_summary="To erase a debt that would have ruined him.",
        method_narrative="He waited until the study emptied.",
        alibi_claim="I was in the parlor with the others all evening.",
        alibi_claimed_loc_index=1,
        breaker_one_name="thread",
        breaker_one_reveal="A torn thread of dark cloth on the study latch.",
        breaker_two_name="footprint",
        breaker_two_reveal="A damp footprint by the press, fresh at the hour.",
        deduction_chain=["The study was locked.", "One route stayed open.", "The thread matches."],
    )


def _case(kind: CrimeKind):
    return assemble_case(
        case_id="gen-000099", seed=99, knobs=GenerationKnobs(crime_kind=kind),
        world=_world(), mystery=_mystery(), profile=profile_for(kind),
        window=TimeWindow(start_min=21 * 60, end_min=22 * 60),
        tod=TimeWindow(start_min=21 * 60 + 20, end_min=21 * 60 + 50),
        culprit_idx=1, crime_idx=0, claimed_idx=1,
    )


@pytest.mark.parametrize("kind", list(CrimeKind))
def test_every_kind_assembles_solvable_and_labelled(kind: CrimeKind):
    case = _case(kind)
    assert case.crime_kind == kind
    report = check(case)
    assert report.ok, f"{kind}: {report.issues}"
    pub = casefile_to_public(case)
    profile = PROFILES[kind]
    assert pub.kind == kind.value
    assert pub.division == profile.division
    assert pub.victim_status == profile.victim_status
    assert ("VERDICT", profile.verdict) in pub.facts
    # the incident story beat sits on the REAL crime scene, not a generic painter name
    assert pub.story_beats[1].scene == pub.scene


@pytest.mark.parametrize("kind", list(CrimeKind))
def test_culprit_brief_states_the_right_deed(kind: CrimeKind):
    case = _case(kind)
    culprit = case.suspect(case.culprit.sus_id)
    brief = build_suspect_brief(case, culprit)
    deed = PROFILES[kind].brief_deed.format(
        victim=case.victim.name, instrument=case.weapon.name, room="The Study",
    )
    assert any(deed in line for line in brief.i_must_conceal)


def test_prompts_carry_the_kind():
    p = profile_for(CrimeKind.THEFT)
    w = world_cast_prompt(p, "", "", "", 4, 21 * 60, 22 * 60)
    assert "heist" in w and "STOLEN OBJECT" in w
    m = mystery_prompt(p, "Miles Ardent", "Patron", "Edmund Crane", "the Falcon Diamond",
                       "The Study", "The Parlor", 21 * 60, 22 * 60)
    assert "thief" in m and "stole" in m


def test_kind_for_seed_covers_all_kinds():
    kinds = {kind_for_seed(s) for s in range(64)}
    assert kinds == set(CrimeKind)
    # homicide stays the most common draw
    draws = [kind_for_seed(s) for s in range(90)]
    assert draws.count(CrimeKind.HOMICIDE) > draws.count(CrimeKind.THEFT)