"""The discoverability gate. A case proven solvable on paper is worthless if a key clue can only ever come from a suspect who chooses to stay silent. This gate guarantees every clue in the solution's minimal set is reachable by a non-interrogation channel (search / forensic / document), so the player can always obtain it. """ from __future__ import annotations from ..schemas.case import CaseFile from ..schemas.enums import DiscoveryMethod _NON_INTERROGATION = frozenset( {DiscoveryMethod.SEARCH, DiscoveryMethod.FORENSIC, DiscoveryMethod.DOCUMENT} ) def undiscoverable_minimal_clues(case: CaseFile) -> tuple[str, ...]: """Return minimal-set clue ids with no non-interrogation discovery path.""" by_id = {c.clue_id: c for c in case.clues} loc_ids = {loc.loc_id for loc in case.setting.locations} bad: list[str] = [] for cid in case.solution.minimal_clue_set: clue = by_id.get(cid) if clue is None: bad.append(cid) continue reachable = ( clue.discovery_method in _NON_INTERROGATION and clue.discoverable_at_loc_id in loc_ids ) if not reachable: bad.append(cid) return tuple(bad) def is_discoverable(case: CaseFile) -> bool: return not case.solution.minimal_clue_set or not undiscoverable_minimal_clues(case)