"""Procedurally generate Cold Cases the player has not seen before. Strategy: pick a small Dock, a culprit, a pathology, then generate witnesses whose target_masses naturally produce that pathology. """ from __future__ import annotations import random from functools import reduce from belief_engine import dempster, yager, pcr5, cautious, belief DOCK_POOL = ["The Sailor", "The Innkeeper", "The Stranger", "The Maid", "The Vicar", "The Heir", "The Sculptor", "The Doctor", "The Captain", "The Smith"] SETTINGS = ["a tavern brawl", "a cathedral vespers", "a poisoned banquet", "a cliff-top duel", "a ship-yard accident", "a vicarage burglary"] def _norm(m: dict) -> dict: s = sum(m.values()) or 1.0 return {k: v / s for k, v in m.items()} def _gen_witness(idx: int, dock: list[str], culprit: str, rng: random.Random, pathology: str): """Return (target_mass dict, reliability, independence_group).""" frame_key = ",".join(dock) others = [s for s in dock if s != culprit] ignorance = round(rng.uniform(0.1, 0.4), 2) if pathology == "contradiction" and idx < 2: # Two strong, contradicting accusations of two non-culprits accuse = others[idx % len(others)] m = {accuse: round(0.95, 2), frame_key: 0.05} elif pathology == "echo" and idx < 2: accuse = rng.choice(others) m = {accuse: 0.8, frame_key: 0.2} elif pathology == "drunk" and idx == 0: accuse = rng.choice(others) m = {accuse: 0.9, frame_key: 0.1} elif pathology == "sliver" and idx < 3: sliver = others[0] primary = rng.choice([culprit] + others[1:]) m = {primary: 0.9, sliver: 0.05, frame_key: 0.05} else: # weak truth-leaning witness m = {culprit: round(rng.uniform(0.3, 0.7), 2), frame_key: ignorance} m = _norm(m) rel = 0.2 if (pathology == "drunk" and idx == 0) else round(rng.uniform(0.6, 0.95), 2) ig = "echo_group" if pathology == "echo" and idx < 2 else None return m, rel, ig def generate_case(seed: int | None = None) -> dict: rng = random.Random(seed) dock = rng.sample(DOCK_POOL, k=rng.choice([3, 4])) culprit = rng.choice(dock) n_witnesses = rng.randint(3, 5) pathology = rng.choice(["contradiction", "echo", "drunk", "sliver"]) witnesses = [] for i in range(n_witnesses): mass, rel, ig = _gen_witness(i, dock, culprit, rng, pathology) witnesses.append({ "id": f"witness_{i+1}", "name": f"Witness {i+1}", "portrait_ref": "_base.svg", "persona": f"A {rng.choice(['stoic','nervous','garrulous','laconic'])} bystander to {rng.choice(SETTINGS)}.", "secret_knowledge": f"You believe {max(mass, key=mass.get).replace(',', ' or ')} is responsible.", "reliability": rel, "independence_group": ig, "cost_days": 1, "is_physical_evidence": False, "target_mass": mass, }) return { "case_id": f"cold_{seed if seed is not None else rng.randint(1, 1_000_000)}", "title": "A Cold Case", "setting_blurb": f"You preside over a death amidst {rng.choice(SETTINGS)}. Witness accounts diverge.", "victim": "The Deceased", "dock": dock, "court_days": 4, "threshold": 0.65, "methods_available": ["dempster", "yager", "pcr5", "cautious"], "witnesses": witnesses, "ground_truth": {"culprit": culprit, "accomplice": None}, "intended_path": f"Pathology: {pathology}.", "reveal_expectations": {}, } def _yaml_mass_to_python(raw: dict, dock: list[str]) -> dict: out = {} for k, v in raw.items(): out[frozenset(s.strip() for s in str(k).split(","))] = float(v) return out def _fuse(masses, method, frame): if method == "dempster": return reduce(dempster, masses) if method == "yager": return reduce(lambda a,b: yager(a,b,frame), masses) if method == "pcr5": return reduce(pcr5, masses) if method == "cautious": return reduce(lambda a,b: cautious(a,b,frame), masses) def validate_case(c: dict) -> tuple[bool, str]: """A case is interesting iff: at least one Method -> Just Conviction (convicts the true culprit at threshold) AND at least one Method -> Wrongful Conviction OR Cold Case. """ dock = c["dock"] frame = set(dock) culprit = c["ground_truth"]["culprit"] thr = c["threshold"] masses = [_yaml_mass_to_python(w["target_mass"], dock) for w in c["witnesses"]] outcomes = {} for m in ("dempster", "yager", "pcr5", "cautious"): try: fused = _fuse(masses, m, frame) except Exception: outcomes[m] = "error" continue bels = {s: belief(fused, frozenset({s})) for s in dock} top = max(bels, key=bels.get) if bels[top] >= thr: outcomes[m] = "just" if top == culprit else "wrongful" else: outcomes[m] = "cold" has_just = "just" in outcomes.values() has_other = any(v in ("wrongful", "cold") for v in outcomes.values()) if has_just and has_other: return True, "ok" return False, f"uninteresting: {outcomes}" def generate_interesting(seed_start: int = 0, max_tries: int = 50) -> dict: seed = seed_start for _ in range(max_tries): c = generate_case(seed=seed) ok, _ = validate_case(c) if ok: return c seed += 1 raise RuntimeError("could not generate an interesting case in time")