"""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 .crime_profiles import CrimeProfile, profile_for 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"stole|stolen|robbed|forged|defrauded|swindled|blackmail|blackmailed|extorted|torched|" r"abducted|kidnapped|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 abandoned at the scene, 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 within the hour.", "A monogrammed cufflink wedged under the rug, dropped in haste.", "A freshly scuffed floorboard where someone braced in a hurry.", "A cigarette stubbed out mid-smoke - a brand nobody in the house admits to.", "A mantel clock knocked still at the very minute it happened.", ) # 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, profile: CrimeProfile) -> 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 = profile.fallback_titles title = templates[seed % len(templates)].format(room=room, setting=setting, last=last) # Setting/room names often carry their own article ("the Old Clock Tower") - collapse # the doubled article a template can produce ("The the Old Clock Tower Demand"). return re.sub(r"\b(the)\s+the\b", r"\1", title, flags=re.IGNORECASE) 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, profile: CrimeProfile | None = None, ) -> CaseFile: if profile is None: profile = profile_for(knobs.crime_kind or "homicide") 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 {profile.incident_noun}.", 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) # Two breakers with the same fallback trace read as a copy-paste bug to the player - # bump the seed until the second one draws a different object. bump = 2 while b2_name == b1_name and bump < 12: b2_name, b2_reveal = _natural_clue("", "", herring=False, seed=(seed >> 3) + bump, weapon=weapon_name, room=crime_room_name, banned=banned) bump += 1 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=f"at the scene of the {profile.incident_noun}"), ) alibi = StatedAlibi(claim_text=mystery.alibi_claim, claimed_segments=(AlibiSegment(window=window, loc_id=claimed_loc),)) lies = (AnchoredLie(lie_id="LIE_alibi", topic=f"where you were during the {profile.incident_noun}", 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=f"Fine, that part is true - but it has nothing to do " f"with the {profile.incident_noun}."),) 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, profile) return CaseFile( case_id=case_id, seed=seed, title=title, briefing=world.briefing, knobs=knobs, crime_kind=profile.kind, 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)), )