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 <ref> for a
known position' from 'these two readings are geometrically
inconsistent', wired into _apply_and_refresh's toast after a manual
Description-tab edit.
This commit is contained in:
Dominik Moritz Roth 2026-08-08 23:50:14 +02:00
parent b206b382fc
commit 8c73442c03
2 changed files with 44 additions and 0 deletions

View File

@ -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

View File

@ -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