New shapes, all genuinely new features (checked full git history, none
of this ever existed before):
- '<ref>: <value>' clue grammar (Spotter#2: 4.04km / Spotter#3: 298deg
/ Spotter#1: West), no keyword, no 'from', reference comes first.
This collided hard with the existing 'Type#id:' header shape,
'Spotter#2: 4.04km' is structurally identical to a real header like
'AmmoCache#3:', so it was hijacking the block before any clues could
attach to the entity above it. Fixed by checking whether a would-be
header's trailing content is itself just a bare clue reading with
nothing else (_BARE_CLUE_VALUE_RE); a real header's never is.
- 16-point compass words ('North Northwest'), alongside the existing
8-point ones, longest-alternative-first in the regex so the compound
form doesn't get cut off at the bare first word.
- A compass word names a whole sector, not a single ray, so a clue
built from one now carries a bearing_tolerance_deg (11.25deg, half a
16-point sector) and solve_location() deliberately never tries to
triangulate it into an exact point, precise math on an imprecise
reading would misrepresent the confidence. The map draws it as a
wedge (two bounding rays + fill) instead of a single ray.
- 'Reported active in grid D10': large-grid-cell-only, no sub-grid x:y
at all, defaults to the cell's rough middle (5:5).
Verified against the exact reported example end to end (parse ->
solver correctly resolving what it can and leaving the rest
unresolved -> map draw with the wedge overlay) plus the full existing
regression sweep across every previously-added format.
367 lines
16 KiB
Python
367 lines
16 KiB
Python
"""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
|