"""Resolve relative Clues (bearing/distance from another entity) into absolute Coords. Geometry lives in "board units" = km: one large grid cell is 1km x 1km, and Coord.as_fraction() already returns (col, row) in exactly those units, so no extra scale factor is needed. Bearing is compass-style: 0 = north (+row, since row increases upward on the map same as in-game), 90 = east (+col), clockwise. Solvable shapes, in priority order (matches everything seen in real typewriter data so far): 1. One clue with both bearing and distance from a resolved reference -> direct polar projection. Always unique. 2. Two bearing-only clues from different resolved references -> ray/ray intersection. Always unique (unless parallel). 3. A bearing-only clue + a distance-only clue (different references) -> ray/circle intersection. A ray can cross a circle at 0, 1, or 2 points. 4. Two distance-only clues from different references -> circle/circle intersection. Two circles can cross at 0, 1, or 2 points. Cases 3 and 4 surface a 2-point result as `potential` rather than a resolved `coord`, nothing here picks a "more likely" one of the two, so nothing downstream is allowed to depend on it either. """ from __future__ import annotations import math from dataclasses import dataclass, field from .models import LARGE_X, Board, Coord, Location, TargetType Point = tuple[float, float] # (col, row) in km @dataclass class SolveResult: coord: Coord | None = None potential: list[Coord] = field(default_factory=list) # Set when `coord` came from closest_compromise_point() rather than a # real intersection, i.e. the underlying readings don't quite agree # with each other. Explains itself, meant to be shown to the user # (see resolve_board()/Location.note), never checked by code. note: str | None = None def bearing_distance_to_delta(bearing_deg: float, distance_km: float) -> Point: rad = math.radians(bearing_deg) return distance_km * math.sin(rad), distance_km * math.cos(rad) def point_from_bearing_distance(origin: Point, bearing_deg: float, distance_km: float) -> Point: dcol, drow = bearing_distance_to_delta(bearing_deg, distance_km) return origin[0] + dcol, origin[1] + drow # A scout flight's plotted path: a rectangle anchored on a chosen large # grid square's center, oriented along a chosen bearing. SCOUT_FLIGHT_BACK_KM = 0.92 SCOUT_FLIGHT_SIDE_KM = 1.21 SCOUT_FLIGHT_FORWARD_KM = 13.04 def scout_flight_corners(center: Point, bearing_deg: float) -> list[Point]: """The 4 corners of a scout flight's rectangle: SCOUT_FLIGHT_BACK_KM behind `center` along `bearing_deg` to SCOUT_FLIGHT_FORWARD_KM ahead of it, SCOUT_FLIGHT_SIDE_KM to either side. Order: back-left, back-right, forward-right, forward-left, a closed loop when drawn in that order.""" fwd = bearing_distance_to_delta(bearing_deg, 1.0) side = bearing_distance_to_delta((bearing_deg + 90) % 360, 1.0) back = (center[0] - fwd[0] * SCOUT_FLIGHT_BACK_KM, center[1] - fwd[1] * SCOUT_FLIGHT_BACK_KM) front = (center[0] + fwd[0] * SCOUT_FLIGHT_FORWARD_KM, center[1] + fwd[1] * SCOUT_FLIGHT_FORWARD_KM) def offset(p: Point, sign: float) -> Point: return (p[0] + side[0] * SCOUT_FLIGHT_SIDE_KM * sign, p[1] + side[1] * SCOUT_FLIGHT_SIDE_KM * sign) return [offset(back, -1), offset(back, 1), offset(front, 1), offset(front, -1)] def ray_circle_intersections( origin: Point, bearing_deg: float, center: Point, radius_km: float ) -> list[Point]: """All points at distance >= 0 along the bearing ray from `origin` that lie on the circle of `radius_km` around `center`, nearest first. 0, 1, or 2 points.""" rad = math.radians(bearing_deg) dx, dy = math.sin(rad), math.cos(rad) ox, oy = origin[0] - center[0], origin[1] - center[1] b = 2 * (dx * ox + dy * oy) c = ox * ox + oy * oy - radius_km * radius_km disc = b * b - 4 * c if disc < 0: return [] sqrt_disc = math.sqrt(disc) ts = sorted(t for t in ((-b - sqrt_disc) / 2, (-b + sqrt_disc) / 2) if t >= 1e-9) if len(ts) == 2 and abs(ts[0] - ts[1]) < 1e-6: ts = ts[:1] # tangent: two roots collapse to one point return [(origin[0] + dx * t, origin[1] + dy * t) for t in ts] def circle_circle_intersections( center_a: Point, radius_a: float, center_b: Point, radius_b: float ) -> list[Point]: """Points where two circles cross, arbitrary order. 0, 1, or 2 points; also 0 for coincident circles (infinitely many "intersections", nothing useful to return).""" ax, ay = center_a bx, by = center_b dx, dy = bx - ax, by - ay d = math.hypot(dx, dy) if d < 1e-9: return [] # same center, either no solution (r differs) or infinite (r same); neither is useful if d > radius_a + radius_b + 1e-9 or d < abs(radius_a - radius_b) - 1e-9: return [] # too far apart, or one circle nested inside the other with no crossing a = (radius_a**2 - radius_b**2 + d**2) / (2 * d) h = math.sqrt(max(radius_a**2 - a**2, 0.0)) px, py = ax + a * dx / d, ay + a * dy / d if h < 1e-9: return [(px, py)] # tangent circles: one touching point perp_x, perp_y = -dy / d, dx / d return [(px + h * perp_x, py + h * perp_y), (px - h * perp_x, py - h * perp_y)] def closest_compromise_point( center_a: Point, radius_a: float, center_b: Point, radius_b: float ) -> Point | None: """When two distance-only clues' circles don't actually cross (real game data isn't perfectly consistent, a spotter's position or a reported distance can be off by enough that the two circles end up nested or just short of touching), the point that best reconciles both readings anyway: the midpoint between circle A's point facing circle B and circle B's point facing circle A, the standard notion of "closest points between two circles" when they're genuinely apart, and it degrades gracefully rather than blowing up when they're nested or nearly concentric too (unlike projecting along the center line the way a real intersection's `a` term does, which diverges as the centers get close together while the radii stay far apart, exactly the case this function exists for). None only for coincident centers, where "the line through them" isn't defined.""" ax, ay = center_a bx, by = center_b dx, dy = bx - ax, by - ay d = math.hypot(dx, dy) if d < 1e-9: return None ux, uy = dx / d, dy / d edge_a = (ax + ux * radius_a, ay + uy * radius_a) # on circle A, facing B edge_b = (bx - ux * radius_b, by - uy * radius_b) # on circle B, facing A return (edge_a[0] + edge_b[0]) / 2, (edge_a[1] + edge_b[1]) / 2 def ray_ray_intersection( origin_a: Point, bearing_a: float, origin_b: Point, bearing_b: float ) -> Point | None: rad_a, rad_b = math.radians(bearing_a), math.radians(bearing_b) dax, day = math.sin(rad_a), math.cos(rad_a) dbx, dby = math.sin(rad_b), math.cos(rad_b) denom = dax * dby - day * dbx if abs(denom) < 1e-9: return None # parallel bearings, no unique intersection ex, ey = origin_b[0] - origin_a[0], origin_b[1] - origin_a[1] t = (ex * dby - ey * dbx) / denom return origin_a[0] + dax * t, origin_a[1] + day * t def point_to_coord(point: Point) -> Coord | None: """(col, row) km -> Coord, or None if it's meaningfully off the 20x10 map (rather than just a hair over from rounding).""" col, row = point if not (-0.5 <= col <= 20.5 and -0.5 <= row <= 10.5): return None col = min(max(col, 0.0), 19.999) row = min(max(row, 0.0), 9.999) x_idx = int(col) x = round((col - x_idx) * 10) if x > 9: x, x_idx = 0, min(x_idx + 1, 19) Y = int(row) + 1 y = round((row - (Y - 1)) * 10) if y > 9: y, Y = 0, min(Y + 1, 10) return Coord(X=LARGE_X[x_idx], Y=Y, x=x, y=y) def _entity_point(board: Board, name: str) -> Point | None: obj = board.find_by_name(name) if obj is None or obj.coord is None: return None return obj.coord.as_fraction() def solve_location(location: Location, board: Board) -> SolveResult: """Try to resolve `location` from its clues, given everything currently resolved on `board`. Returns a definitive `coord`, or `potential` candidates when the geometry is genuinely ambiguous, or neither if there's not enough resolved info yet.""" if location.coord is not None: return SolveResult(coord=location.coord) resolved = [(clue, pt) for clue in location.clues if (pt := _entity_point(board, clue.reference)) is not None] if not resolved: return SolveResult() for clue, pt in resolved: # A toleranced bearing (from a compass word, 'West', not a precise # degree reading) names a whole sector, not a ray, exact # intersection math on it would just be lying about how precise # the reading actually is. Left to draw as a wedge on the map # instead (grid_widget.py), never used to solve a position. if clue.bearing_tolerance_deg is not None: continue if clue.bearing_deg is not None and clue.distance_km is not None: coord = point_to_coord(point_from_bearing_distance(pt, clue.bearing_deg, clue.distance_km)) if coord is not None: return SolveResult(coord=coord) bearings = [ (c, p) for c, p in resolved if c.bearing_deg is not None and c.distance_km is None and c.bearing_tolerance_deg is None ] distances = [(c, p) for c, p in resolved if c.distance_km is not None and c.bearing_deg is None] if len(bearings) >= 2: (c1, p1), (c2, p2) = bearings[0], bearings[1] point = ray_ray_intersection(p1, c1.bearing_deg, p2, c2.bearing_deg) if point is not None: coord = point_to_coord(point) if coord is not None: return SolveResult(coord=coord) if bearings and distances: (cb, pb), (cd, pd) = bearings[0], distances[0] points = ray_circle_intersections(pb, cb.bearing_deg, pd, cd.distance_km) coords = [c for p in points if (c := point_to_coord(p)) is not None] if len(coords) == 1: return SolveResult(coord=coords[0]) if len(coords) >= 2: return SolveResult(potential=coords) # genuinely ambiguous if len(distances) >= 2: (c1, p1), (c2, p2) = distances[0], distances[1] points = circle_circle_intersections(p1, c1.distance_km, p2, c2.distance_km) coords = [c for p in points if (c := point_to_coord(p)) is not None] if len(coords) == 1: return SolveResult(coord=coords[0]) if len(coords) >= 2: return SolveResult(potential=coords) # genuinely ambiguous # No real intersection, the circles are nested or just short of # touching. Rather than give up, use the point that best splits # the difference, flagged as approximate rather than treated as # a clean fix. point = closest_compromise_point(p1, c1.distance_km, p2, c2.distance_km) if point is not None: coord = point_to_coord(point) if coord is not None: return SolveResult(coord=coord, note=( f"approximate: {c1.reference}'s {c1.distance_km}km and {c2.reference}'s " f"{c2.distance_km}km circles don't actually cross, used the closest point " "between them instead" )) 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: # circle_circle_intersections() not crossing isn't fatal by itself # any more, solve_location() falls back to closest_compromise_point() # for that, only reaching here if even that gave up. (c1, p1), (c2, p2) = distances[0], distances[1] point = closest_compromise_point(p1, c1.distance_km, p2, c2.distance_km) if point is None: return f"{c1.reference} and {c2.reference} are reported at the exact same position, can't triangulate from two coincident circles" if point_to_coord(point) is None: return f"the best-fit point for {c1.reference}'s and {c2.reference}'s distances falls off the map" 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 chains like AmmoCache#3 -> AmmoCache#2 -> Alpha/Spotters). Ambiguous results are recorded as `potential_coords` and never feed further resolution. Returns the names of everything newly *resolved* (not counting ones that only became ambiguous) this call.""" newly_resolved: list[str] = [] changed = True while changed: changed = False for entities in (board.reference_points, board.targets): for obj in entities: if obj.coord is not None or obj.location.potential_coords: continue # already resolved, or stuck ambiguous, don't reprocess result = solve_location(obj.location, board) if result.coord is not None: obj.coord = result.coord # clears any stale note (see the coord setters) obj.location.note = result.note newly_resolved.append(obj.name) changed = True elif result.potential: obj.location.potential_coords = result.potential return newly_resolved def dedupe_generic_targets(board: Board) -> list[str]: """A target is often first spotted before it's identified, coming in as the generic TargetType.UNKNOWN ("Target#N"). If a later report identifies it with a specific type and it resolves to the *exact same* position as an already-known specific target, it's not a new contact, it's the same one being spotted, just described more precisely. Drop the redundant generic entry, keep the specific one. Strikes are our own planned impacts, not enemy contacts, and never participate. Run this after resolve_board(), since positions may only become comparable once resolved. Returns the names removed.""" removed: list[str] = [] unknowns = [t for t in board.targets if t.type is TargetType.UNKNOWN and t.coord is not None] specifics = [ t for t in board.targets if t.type not in (TargetType.UNKNOWN, TargetType.STRIKE) and t.coord is not None ] for generic in unknowns: if any(generic.coord == specific.coord for specific in specifics): board.remove_target(generic) removed.append(generic.name) return removed