"""Accusation scoring & debrief (Section 8.7). The player names culprit + means + motive + opportunity (optionally citing clues). The engine scores against solution.md: culprit correctness, and whether each of means/motive/opportunity is supported by a *discovered* clue. Produces a graded result + a debrief that reveals the solution and missed clues. """ from __future__ import annotations from ..models import AccusationResult, Solution from ..worldio import World from .clues import ClueGraph def _supported(claim_text: str, solution_field: str, discovered_reveals: str) -> bool: """Heuristic: the player's stated reason overlaps the truth AND is backed by something they actually discovered. Token-overlap based (deterministic).""" claim_tokens = {t for t in claim_text.lower().split() if len(t) > 3} truth_tokens = {t for t in solution_field.lower().split() if len(t) > 3} if not claim_tokens or not truth_tokens: return False overlaps_truth = len(claim_tokens & truth_tokens) >= 1 backed = bool(claim_tokens & {t for t in discovered_reveals.lower().split() if len(t) > 3}) return overlaps_truth and backed def score_accusation( *, world: World, clue_graph: ClueGraph, discovered: set[str], culprit: str, means: str, motive: str, opportunity: str, ) -> AccusationResult: sol: Solution = world.solution culprit_correct = culprit.strip().lower() == sol.culprit.strip().lower() discovered_reveals = " ".join( node.reveals for cid in discovered if (node := clue_graph.nodes.get(cid)) ) means_ok = culprit_correct and _supported(means, sol.means, discovered_reveals) motive_ok = culprit_correct and _supported(motive, sol.motive, discovered_reveals) opp_ok = culprit_correct and _supported(opportunity, sol.opportunity, discovered_reveals) if culprit_correct and means_ok and motive_ok and opp_ok: grade: str = "solved" elif culprit_correct: grade = "right_culprit_unproven" else: grade = "wrong" missed = [ cid for cid, node in clue_graph.nodes.items() if node.required_for_solution and cid not in discovered ] debrief = _build_debrief(sol, grade, missed, clue_graph) return AccusationResult( culprit_named=culprit, culprit_correct=culprit_correct, means_supported=means_ok, motive_supported=motive_ok, opportunity_supported=opp_ok, grade=grade, debrief=debrief, missed_clues=missed, ) def _build_debrief( sol: Solution, grade: str, missed: list[str], clue_graph: ClueGraph ) -> str: lines = [] headline = { "solved": "CASE SOLVED. You named the culprit and proved every element.", "right_culprit_unproven": ( "RIGHT CULPRIT — but your case wasn't fully proven by discovered evidence." ), "wrong": "WRONG. The case remains open.", }[grade] lines.append(headline) lines.append("") lines.append("--- The truth ---") lines.append(f"Culprit: {sol.culprit}") lines.append(f"Means: {sol.means}") lines.append(f"Motive: {sol.motive}") lines.append(f"Opportunity: {sol.opportunity}") if sol.true_sequence: lines.append("") lines.append(sol.true_sequence.strip()) if missed: lines.append("") lines.append("Clues you never surfaced:") for cid in missed: node = clue_graph.nodes.get(cid) if node: lines.append(f" - {cid}: {node.reveals}") return "\n".join(lines)