FEnigma/src/fenigma/solver.py
Dominik Roth 9c7588eb12 OCR: 'ref: value' clue grammar, 16-point compass, grid-only coords, wedge overlay
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.
2026-08-09 18:15:27 +02:00

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