From 8c73442c037f71b85a4cd392dde24db905b4fae9 Mon Sep 17 00:00:00 2001 From: Dominik Roth Date: Sat, 8 Aug 2026 23:50:14 +0200 Subject: [PATCH] Surface why a manual clue edit didn't resolve, instead of silence Circle-circle intersection (and the other solvable shapes) already worked correctly, verified with a direct test: two distance-only clues resolve to potential_coords whenever the two circles actually cross. What was missing was feedback when they don't, e.g. two distances that put one circle entirely inside the other given the references' real positions looks identical, from the dialog's perspective, to a reference that's just not placed yet. solver.explain_unresolved() distinguishes 'waiting on for a known position' from 'these two readings are geometrically inconsistent', wired into _apply_and_refresh's toast after a manual Description-tab edit. --- src/fenigma/app.py | 6 ++++++ src/fenigma/solver.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/fenigma/app.py b/src/fenigma/app.py index 9dcc659..44dced7 100644 --- a/src/fenigma/app.py +++ b/src/fenigma/app.py @@ -990,6 +990,12 @@ class MainWindow(Adw.ApplicationWindow): def _apply_and_refresh(self, obj, location: Location) -> None: _apply_location(obj, location) self._refresh() + # A manual clue edit (typically the Description tab) can go in and + # come out unresolved with no other feedback, tell the user why + # instead of leaving it looking like nothing happened. + reason = solver.explain_unresolved(obj.location, self.board) + if reason is not None: + self.toast(f"{obj.name} not resolved: {reason}.") def _edit_target_position(self, target) -> None: """Firing card's "Edit pos" button: manual coord overrides whatever diff --git a/src/fenigma/solver.py b/src/fenigma/solver.py index b48428d..6421c0a 100644 --- a/src/fenigma/solver.py +++ b/src/fenigma/solver.py @@ -219,6 +219,44 @@ def solve_location(location: Location, board: Board) -> SolveResult: return SolveResult() +def explain_unresolved(location: Location, board: Board) -> str | None: + """Best-effort human explanation for why `location` hasn't resolved, + surfaced right after a manual edit so a bad/impossible entry doesn't + just silently do nothing. Either some clue's reference isn't itself + known yet, or the ones that are known are geometrically inconsistent, + e.g. two distance circles that don't actually cross given how far + apart their centers really are (the game's typewriter can print + distances that don't agree with reality if a spotter's position is + off, or a digit got misread). Returns None if there's nothing to + explain: already resolved, ambiguous-but-resolved-enough, or no + clues at all.""" + if location.coord is not None or location.potential_coords or not location.clues: + return None + + unresolved_refs = sorted({c.reference for c in location.clues if _entity_point(board, c.reference) is None}) + if unresolved_refs: + return "waiting on " + ", ".join(unresolved_refs) + " to have a known position first" + + resolved = [(clue, pt) for clue in location.clues if (pt := _entity_point(board, clue.reference)) is not None] + bearings = [(c, p) for c, p in resolved if c.bearing_deg is not None and c.distance_km is None] + distances = [(c, p) for c, p in resolved if c.distance_km is not None and c.bearing_deg is None] + + if bearings and distances: + (cb, pb), (cd, pd) = bearings[0], distances[0] + if not ray_circle_intersections(pb, cb.bearing_deg, pd, cd.distance_km): + return (f"the bearing from {cb.reference} never crosses the {cd.distance_km}km " + f"circle around {cd.reference}, check those two readings against each other") + + if len(distances) >= 2: + (c1, p1), (c2, p2) = distances[0], distances[1] + if not circle_circle_intersections(p1, c1.distance_km, p2, c2.distance_km): + return (f"the {c1.distance_km}km circle around {c1.reference} and the {c2.distance_km}km " + f"circle around {c2.reference} don't cross, too far apart or one nested inside " + "the other given those references' actual positions, check the readings/positions") + + return None # e.g. two bearings that are (near-)parallel, or genuinely just needs more info + + def resolve_board(board: Board) -> list[str]: """Resolve every not-yet-resolved RP/Target whose clues can currently be satisfied, repeating until a fixed point (handles dependency