File size: 15,014 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
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
"""Deterministic assembly: stage outputs + structural scaffold -> a solvable CaseFile.

Python owns only the structure (who is guilty, who was where, which clue breaks the
alibi); all names, prose, secrets, motives, and reveals come from the model's stage
outputs. Built this way, the result passes the solver by construction.
"""

from __future__ import annotations

import re

from ..constants import DAY_MINUTES
from ..schemas.case import (
    AlibiLie,
    CaseFile,
    Culprit,
    GenerationKnobs,
    Motive,
    Setting,
    Solution,
    Victim,
    Weapon,
)
from ..schemas.clue import Clue, Fact
from ..schemas.enums import DiscoveryMethod, SubjectType
from ..schemas.suspect import (
    AnchoredLie,
    PersonalityAxes,
    Suspect,
)
from ..schemas.timeline import (
    AlibiSegment,
    Location,
    StatedAlibi,
    TimeWindow,
    WhereaboutsSegment,
)
from ..schemas.visual import VisualDescriptor
from .stages import MysteryOut, WorldCastOut

_FALLBACK_ALIBI = "All right - I stepped out for a moment, but I had nothing to do with this."

# Evidence sanitiser. A small model often writes a confession ("I killed him") or names the
# culprit/victim in a clue, which spoils the mystery. Such clues are detected and replaced
# with a natural physical trace, so evidence always reads like a real case. Good model
# output passes through untouched.
_CONFESSION_RE = re.compile(
    r"\b(i|i'?m|i'?ve|i'?d|my|me|we|us|murder|murdered|killed|kill|killing|slain|stabbed|"
    r"planned|revenge|confess|culprit|victim|the weapon|the murder)\b",
    re.IGNORECASE,
)

# Scene traces that quietly place the culprit at the crime - never naming anyone.
_TRACE_NAMES = (
    "Partial fingerprint", "Smudged tumbler", "Snagged thread", "Damp footprints",
    "Dropped cufflink", "Scuffed floorboard", "Stubbed cigarette", "Stopped clock",
)
_TRACE_REVEALS = (
    "A fresh partial fingerprint on the {weapon}, unaccounted for among the guests.",
    "A tumbler left where the body fell, its rim marked with a recent lip-print.",
    "A torn thread of dark cloth snagged on the {room} doorframe.",
    "Half-dried footprints crossing the {room}, left around the time of death.",
    "A monogrammed cufflink wedged under the rug, dropped in haste.",
    "A freshly scuffed floorboard where someone braced against a struggle.",
    "A cigarette stubbed out mid-smoke - and not the victim's brand.",
    "A mantel clock knocked still at the very minute of death.",
)
# Personal items that hint at an innocent's hidden secret - never about the murder.
_HERRING_NAMES = (
    "Crumpled letter", "Hidden receipt", "Old photograph", "Pawn ticket", "Ticket stub",
)
_HERRING_REVEALS = (
    "A crumpled letter hinting at a debt they never mentioned.",
    "A receipt for something they swore they never bought.",
    "A worn photograph they would rather no one had seen.",
    "A pawn ticket for a family heirloom, recently surrendered.",
    "A ticket stub placing them somewhere they had denied being.",
)


def _unnatural(text: str, banned: tuple[str, ...]) -> bool:
    if not text or not text.strip() or _CONFESSION_RE.search(text):
        return True
    low = text.lower()
    return any(b and b.lower() in low for b in banned)


def _natural_clue(name: str, reveal: str, *, herring: bool, seed: int, weapon: str,
                  room: str, banned: tuple[str, ...]) -> tuple[str, str]:
    """Keep the model's (name, reveal) when it reads like real physical evidence; otherwise
    return a seeded natural fallback so no confession or name reaches the player."""
    if not _unnatural(reveal, banned) and not _unnatural(name, banned):
        return name.strip(), reveal.strip()
    names = _HERRING_NAMES if herring else _TRACE_NAMES
    reveals = _HERRING_REVEALS if herring else _TRACE_REVEALS
    idx = seed % len(reveals)
    return names[idx], reveals[idx].format(weapon=(weapon or "weapon").lower(),
                                           room=(room or "room").lower())


def _clamp_index(index: int, count: int) -> int:
    return max(0, min(index, count - 1))


def _locations(world: WorldCastOut) -> tuple[Location, ...]:
    locs = []
    for i, loc in enumerate(world.locations):
        loc_id = f"L{i + 1}"
        # Hub-and-spoke connectivity off the first room keeps the map connected.
        adjacent = ("L1",) if i != 0 else tuple(f"L{j + 1}" for j in range(1, len(world.locations)))
        locs.append(Location(loc_id=loc_id, name=loc, description="", adjacent_to=adjacent))
    return tuple(locs)


# Distinct coat accents so generated suspects look different from each other.
_ACCENTS: tuple[str, ...] = ("#b8860b", "#3a6ea5", "#9a9aa0", "#6b8f71", "#a4533a", "#7a6ca8")

# Distinct temperaments so each case has a confident one, a frightened one, a hostile
# one, etc. - guaranteed variety even when the small model rates everyone the same. Each
# is (composure, aggression, evasiveness, demeanour, nervous tell). Consecutive suspects
# get consecutive (distinct) entries; a per-case offset rotates them for variety.
_TEMPERAMENTS: tuple[tuple[float, float, float, str, str], ...] = (
    (0.88, 0.30, 0.35, "cool and self-assured, almost amused by the questioning", "a faint knowing smile"),
    (0.20, 0.30, 0.70, "visibly frightened and on edge, dreading every question", "trembling hands"),
    (0.55, 0.88, 0.40, "hostile and defensive, bristling at any hint of suspicion", "a clenched jaw"),
    (0.72, 0.40, 0.30, "composed and cooperative on the surface, carefully measured", "a too-steady voice"),
    (0.32, 0.55, 0.68, "rattled and evasive, voice tightening under pressure", "darting eyes"),
    (0.60, 0.25, 0.55, "guarded and weary, giving away as little as possible", "long, careful pauses"),
)


def _temperament(seed: int, index: int) -> tuple[float, float, float, str, str]:
    return _TEMPERAMENTS[(seed + index) % len(_TEMPERAMENTS)]


# Small models lean hard on "Whispers/Shadows/Midnight..." titles. When the model produces
# one (or an empty title), swap in a deterministic, case-specific title so no two cases
# share the same cliche.
_TITLE_BANNED = re.compile(r"whisper|shadow|midnight|\bdark|secret|silen|echo|veil|\bnight\b", re.IGNORECASE)


def _fresh_title(raw: str, seed: int, setting: str, victim: str, room: str) -> str:
    raw = (raw or "").strip()
    if raw and not _TITLE_BANNED.search(raw):
        return raw
    last = victim.split()[-1] if victim else "the Victim"
    templates = (
        f"A Death in the {room}",
        f"The {room} Affair",
        f"Murder at {setting}",
        f"The {last} File",
        f"Blood in the {room}",
        f"Last Call at {setting}",
        f"The {setting} Killing",
        f"The {room} Verdict",
    )
    return templates[seed % len(templates)]


def _visual(gen, index: int) -> VisualDescriptor:  # type: ignore[no-untyped-def]
    mood = "guarded" if gen.evasiveness >= 0.5 else "tense"
    look = ", ".join(p for p in (gen.appearance, gen.attire) if p)
    gender = "female" if (gen.gender or "").lower().startswith("f") else "male"
    return VisualDescriptor(
        subject_type=SubjectType.SUSPECT, palette="noir", mood=mood, gender=gender,
        age_band=gen.age_band or None, attire=gen.attire or None,
        accent_color=_ACCENTS[index % len(_ACCENTS)], prompt_hint=look,
    )


def assemble_case(
    *,
    case_id: str,
    seed: int,
    knobs: GenerationKnobs,
    world: WorldCastOut,
    mystery: MysteryOut,
    window: TimeWindow,
    tod: TimeWindow,
    culprit_idx: int,
    crime_idx: int,
    claimed_idx: int,
) -> CaseFile:
    n = len(world.suspects)
    n_loc = len(world.locations)
    culprit_idx = _clamp_index(culprit_idx, n)
    crime_idx = _clamp_index(crime_idx, n_loc)
    claimed_idx = _clamp_index(claimed_idx, n_loc)
    if claimed_idx == crime_idx:
        claimed_idx = (crime_idx + 1) % n_loc
    crime_loc = f"L{crime_idx + 1}"
    claimed_loc = f"L{claimed_idx + 1}"

    locations = _locations(world)
    culprit_name = world.suspects[culprit_idx].name

    # Spread evidence so EACH room yields its own clue(s): the key forensic breaker
    # stays at the scene, every other clue is round-robined to a distinct other room.
    non_crime_locs = [i for i in range(n_loc) if i != crime_idx] or [crime_idx]
    _spread = {"i": 0}

    def _next_room() -> str:
        idx = non_crime_locs[_spread["i"] % len(non_crime_locs)]
        _spread["i"] += 1
        return f"L{idx + 1}"

    facts: list[Fact] = [
        Fact(fact_id="F_scene", statement=f"{culprit_name} was in {locations[crime_idx].name} "
             f"during the murder.", true_value=True, loc_id=crime_loc, at_min=tod.start_min),
    ]
    banned = (culprit_name, world.victim_name)
    weapon_name, crime_room_name = world.weapon_name, locations[crime_idx].name
    b1_name, b1_reveal = _natural_clue(mystery.breaker_one_name, mystery.breaker_one_reveal,
                                       herring=False, seed=seed, weapon=weapon_name,
                                       room=crime_room_name, banned=banned)
    b2_name, b2_reveal = _natural_clue(mystery.breaker_two_name, mystery.breaker_two_reveal,
                                       herring=False, seed=(seed >> 3) + 1, weapon=weapon_name,
                                       room=crime_room_name, banned=banned)
    clues: list[Clue] = [
        Clue(clue_id="C_b1", name=b1_name, reveal_text=b1_reveal,
             discoverable_at_loc_id=crime_loc, discovery_method=DiscoveryMethod.FORENSIC,
             supports_fact_id="F_scene", points_to_sus_id=f"S{culprit_idx + 1}",
             contradicts_alibi_of=f"S{culprit_idx + 1}", weight=1.0),
        Clue(clue_id="C_b2", name=b2_name, reveal_text=b2_reveal,
             discoverable_at_loc_id=_next_room(), discovery_method=DiscoveryMethod.FORENSIC,
             supports_fact_id="F_scene", points_to_sus_id=f"S{culprit_idx + 1}",
             contradicts_alibi_of=f"S{culprit_idx + 1}", weight=0.7),
    ]

    suspects: list[Suspect] = []
    for i, gen in enumerate(world.suspects):
        sus_id = f"S{i + 1}"
        is_culprit = i == culprit_idx
        secret_fact = f"F_sec{i + 1}"
        facts.append(Fact(fact_id=secret_fact, statement=gen.secret, true_value=True))

        if is_culprit:
            whereabouts = (
                WhereaboutsSegment(window=TimeWindow(start_min=window.start_min, end_min=tod.start_min),
                                   loc_id=claimed_loc, activity="mingling in plain sight"),
                WhereaboutsSegment(window=tod, loc_id=crime_loc,
                                   activity="alone with the victim"),
            )
            alibi = StatedAlibi(claim_text=mystery.alibi_claim,
                                claimed_segments=(AlibiSegment(window=window, loc_id=claimed_loc),))
            lies = (AnchoredLie(lie_id="LIE_alibi", topic="where you were during the murder",
                               claimed=mystery.alibi_claim, truth_ref="F_scene",
                               breaks_on=("C_b1", "C_b2"), fallback=_FALLBACK_ALIBI),)
            must_lie = ("F_scene",)
        else:
            loc_idx = non_crime_locs[i % len(non_crime_locs)]
            loc_id = f"L{loc_idx + 1}"
            whereabouts = (WhereaboutsSegment(window=window, loc_id=loc_id,
                                              activity="going about the evening"),)
            alibi = StatedAlibi(claim_text=f"I was in {locations[loc_idx].name} the whole time.",
                                claimed_segments=(AlibiSegment(window=window, loc_id=loc_id),))
            # Each innocent's exposing clue lives in its own room (round-robin), so the
            # player gathers evidence room by room rather than all at once.
            h_name, h_reveal = _natural_clue(gen.evidence_name, gen.evidence_reveal, herring=True,
                                             seed=seed + i + 1, weapon=weapon_name,
                                             room=locations[loc_idx].name, banned=banned)
            clues.append(Clue(clue_id=f"C_h{i + 1}", name=h_name,
                              reveal_text=h_reveal, discoverable_at_loc_id=_next_room(),
                              discovery_method=DiscoveryMethod.SEARCH, supports_fact_id=secret_fact,
                              points_to_sus_id=sus_id, is_red_herring=True, weight=0.3))
            lies = (AnchoredLie(lie_id=f"LIE_sec{i + 1}", topic=gen.secret[:48], claimed=gen.cover_story,
                               truth_ref=secret_fact, breaks_on=(f"C_h{i + 1}",),
                               fallback="Fine, that part is true - but it has nothing to do with the murder."),)
            must_lie = (secret_fact,)

        comp, aggr, evas, demeanour, temp_tell = _temperament(seed, i)
        suspects.append(Suspect(
            sus_id=sus_id, name=gen.name, role=gen.role,
            persona_summary=gen.persona_summary,  # player-facing: stays clean (no temperament)
            demeanour=demeanour,  # prompt-only; never shown to the player
            is_culprit=is_culprit,
            personality=PersonalityAxes(composure=comp, aggression=aggr, evasiveness=evas),
            tells=(gen.tell or temp_tell,),
            knows_facts=("F_scene", secret_fact) if is_culprit else (secret_fact,),
            secrets=(gen.secret,),
            true_whereabouts=whereabouts, stated_alibi=alibi, must_lie_about=must_lie,
            anchored_lies=lies, visual=_visual(gen, i),
        ))

    motive = Motive(motive_id="M1", category=mystery.motive_category, summary=mystery.motive_summary)
    found_at_min = min(window.end_min + 5, DAY_MINUTES)

    title = _fresh_title(world.title, seed, world.setting_name, world.victim_name,
                         locations[crime_idx].name)
    return CaseFile(
        case_id=case_id, seed=seed, title=title, briefing=world.briefing, knobs=knobs,
        setting=Setting(name=world.setting_name, description=world.setting_description,
                        locations=locations, murder_window=window),
        victim=Victim(vic_id="V1", name=world.victim_name, role=world.victim_role,
                      found_at_loc_id=crime_loc, found_at_min=found_at_min,
                      cause_of_death=world.cause_of_death, time_of_death=tod),
        weapon=Weapon(weapon_id="W1", name=world.weapon_name, kind=world.weapon_kind,
                      origin_loc_id=crime_loc),
        suspects=tuple(suspects),
        culprit=Culprit(sus_id=f"S{culprit_idx + 1}", true_motive=motive,
                        method_narrative=mystery.method_narrative,
                        alibi_lie=AlibiLie(claimed_loc_id=claimed_loc, actual_loc_id=crime_loc,
                                           contradicted_by_clue_ids=("C_b1", "C_b2"))),
        facts=tuple(facts), clues=tuple(clues),
        solution=Solution(culprit_sus_id=f"S{culprit_idx + 1}", weapon_id="W1", motive_id="M1",
                          minimal_clue_set=("C_b1",), deduction_chain=tuple(mystery.deduction_chain)),
    )