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