diff --git a/src/fenigma/grid_widget.py b/src/fenigma/grid_widget.py index 6db098f..e6493c0 100644 --- a/src/fenigma/grid_widget.py +++ b/src/fenigma/grid_widget.py @@ -566,6 +566,30 @@ class GridCanvas(Gtk.DrawingArea): cr.set_source_rgb(*YELLOW) cr.set_line_width(2) self._draw_arrow(cr, rx, ry, tx, ty) + elif clue.bearing_deg is not None and clue.bearing_tolerance_deg is not None: + # A compass word ('West') names a whole sector, not a + # single ray, solve_location() never tries to + # triangulate this into an exact point (see its own + # docstring), draw the actual sector instead of + # pretending it's more precise than it is. + lo_km = solver.point_from_bearing_distance( + ref_km, clue.bearing_deg - clue.bearing_tolerance_deg, OVERLAY_RAY_LENGTH_KM) + hi_km = solver.point_from_bearing_distance( + ref_km, clue.bearing_deg + clue.bearing_tolerance_deg, OVERLAY_RAY_LENGTH_KM) + lx, ly = self._km_to_px(lo_km, cell_w, cell_h, grid_h) + hx, hy = self._km_to_px(hi_km, cell_w, cell_h, grid_h) + cr.new_path() + cr.move_to(rx, ry) + cr.line_to(lx, ly) + cr.line_to(hx, hy) + cr.close_path() + cr.set_source_rgba(*YELLOW, 0.15) + cr.fill_preserve() + cr.set_source_rgba(*YELLOW, 0.85) + cr.set_line_width(1.5) + cr.set_dash([3, 2]) + cr.stroke() + cr.set_dash([]) elif clue.bearing_deg is not None: far_km = solver.point_from_bearing_distance(ref_km, clue.bearing_deg, OVERLAY_RAY_LENGTH_KM) fx, fy = self._km_to_px(far_km, cell_w, cell_h, grid_h) diff --git a/src/fenigma/models.py b/src/fenigma/models.py index 649be95..ce20d77 100644 --- a/src/fenigma/models.py +++ b/src/fenigma/models.py @@ -116,17 +116,29 @@ class Clue: named entity (e.g. 'Spotter#1', 'Alpha', 'AmmoCache#2'). At least one of bearing/distance is set; a single clue with both fully determines a position given the reference, two clues (from different references) - need triangulating.""" + need triangulating. + + bearing_tolerance_deg is set instead of None when the bearing came + from a compass word ('West', 'North Northwest') rather than a precise + degree reading, a word names a whole sector, not a single ray (16- + point compass, so a sector spans 22.5 deg, tolerance is the half- + width, 11.25 deg). solve_location() deliberately never tries to + triangulate a toleranced bearing into an exact point, precise-looking + math on an imprecise reading would just be lying about the + confidence, the map draws it as a wedge instead (see + grid_widget.py's geo overlay).""" reference: str bearing_deg: float | None = None distance_km: float | None = None + bearing_tolerance_deg: float | None = None def to_dict(self) -> dict: return { "reference": self.reference, "bearing_deg": self.bearing_deg, "distance_km": self.distance_km, + "bearing_tolerance_deg": self.bearing_tolerance_deg, } @classmethod @@ -135,6 +147,7 @@ class Clue: reference=d["reference"], bearing_deg=d.get("bearing_deg"), distance_km=d.get("distance_km"), + bearing_tolerance_deg=d.get("bearing_tolerance_deg"), ) diff --git a/src/fenigma/ocr.py b/src/fenigma/ocr.py index 4a0012f..f3456fe 100644 --- a/src/fenigma/ocr.py +++ b/src/fenigma/ocr.py @@ -195,6 +195,26 @@ def _extract_requested_time(text: str) -> str | None: return m.group(1) if m else None +# "Reported active in grid D10": only the large-grid cell, no sub-grid +# x:y at all, unlike every other coord shape in this file. Tried last +# (after _extract_grid_coord, which requires the full x:y and so is +# strictly more precise when both would otherwise match). No sub- +# position is given, so it defaults to the cell's rough middle (5:5, +# there's no exact center on a 0-9 grid) rather than leaving it unset. +_LARGE_GRID_ONLY_RE = re.compile(rf"grid\s+([A-T])\s*({_DIGIT_CLASS}{{1,2}})\b", re.IGNORECASE) + + +def _extract_large_grid_only_coord(text: str) -> Coord | None: + m = _LARGE_GRID_ONLY_RE.search(text) + if not m: + return None + letter, y = m.groups() + try: + return Coord(X=letter.upper(), Y=int(_fix_digits(y)), x=5, y=5) + except ValueError: + return None + + def _extract_coord(line: str) -> Coord | None: m = _COORD_RE.search(line) if not m: @@ -449,13 +469,58 @@ _CLUE_BEARING_RE = re.compile(rf"Bearing\s*(\d{{1,3}})\s*°?{_GAP}from\s+(\S+)", _COMPASS_BEARINGS = { "NORTH": 0.0, "NORTHEAST": 45.0, "EAST": 90.0, "SOUTHEAST": 135.0, "SOUTH": 180.0, "SOUTHWEST": 225.0, "WEST": 270.0, "NORTHWEST": 315.0, + # 16-point compass, only ever seen written with a space rather than a + # hyphen ('North Northwest', not 'North-Northwest'), unlike the + # 8-point diagonals above. + "NORTHNORTHEAST": 22.5, "EASTNORTHEAST": 67.5, "EASTSOUTHEAST": 112.5, "SOUTHSOUTHEAST": 157.5, + "SOUTHSOUTHWEST": 202.5, "WESTSOUTHWEST": 247.5, "WESTNORTHWEST": 292.5, "NORTHNORTHWEST": 337.5, } -_COMPASS_WORD_RE = r"(North(?:[-\s]?East|[-\s]?West)?|South(?:[-\s]?East|[-\s]?West)?|East|West)" +# Longest/most specific alternatives first: alternation tries each in +# order and stops at the first that matches, so 'North Northwest' must +# reach the 16-point alternative before the bare 'North' one, or the +# latter would win and strand ' Northwest' unmatched. +_COMPASS_WORD_RE = ( + r"(North[-\s]?North[-\s]?East|North[-\s]?East|East[-\s]?North[-\s]?East|" + r"South[-\s]?South[-\s]?East|South[-\s]?East|East[-\s]?South[-\s]?East|" + r"South[-\s]?South[-\s]?West|South[-\s]?West|West[-\s]?South[-\s]?West|" + r"North[-\s]?North[-\s]?West|North[-\s]?West|West[-\s]?North[-\s]?West|" + r"North|South|East|West)" +) +# Half the width of one 16-point compass sector (360/16 = 22.5 deg each), +# a compass WORD names a whole sector, not a single ray. +_COMPASS_TOLERANCE_DEG = 11.25 + + +def _compass_to_bearing(word: str) -> float: + return _COMPASS_BEARINGS[re.sub(r"[-\s]", "", word).upper()] + + _CLUE_DISTANCE_COMPASS_RE = re.compile( rf"Distance\s*([\d.]+)\s*k?m?\s*{_COMPASS_WORD_RE}{_GAP}from\s+(\S+)", re.IGNORECASE ) _CLUE_DISTANCE_RE = re.compile(rf"Distance\s*([\d.]+)\s*k?m?{_GAP}from\s+(\S+)", re.IGNORECASE) +# A different clue grammar entirely, reference FIRST then a colon then +# the bare reading, no 'Bearing'/'Distance' keyword and no 'from' at all: +# Spotter#2: 4.04km +# Spotter#3: 298° +# Spotter#1: West +# Distance tried before bearing/compass, same reasoning as _CLUE_PATTERNS +# generally: an ambiguous run of digits could otherwise let the bearing +# pattern's optional '°' swallow part of a distance reading. +_CLUE_REF_DISTANCE_RE = re.compile(rf"(\S+)\s*:\s*([\d.]+)\s*k?m", re.IGNORECASE) +_CLUE_REF_BEARING_RE = re.compile(rf"(\S+)\s*:\s*({_DIGIT_CLASS}{{1,3}})\s*°", re.IGNORECASE) +_CLUE_REF_COMPASS_RE = re.compile(rf"(\S+)\s*:\s*{_COMPASS_WORD_RE}\b", re.IGNORECASE) +# 'Spotter#2: 4.04km' has the exact same 'Word#digits:' shape as a real +# block header ('AmmoCache#3:'), genuinely indistinguishable from one by +# shape alone. The deciding signal is what follows the colon: a real +# header's trailing content is never JUST a bare clue reading with +# nothing else, so _NAMED_HEADER_RE's own inline-content group gets +# checked against this before deciding it's actually a new header. +_BARE_CLUE_VALUE_RE = re.compile( + rf"^(?:[\d.]+\s*k?m|{_DIGIT_CLASS}{{1,3}}\s*°|{_COMPASS_WORD_RE})\s*$", re.IGNORECASE +) + _TYPE_BY_SHORT = {t.short: t for t in TargetType} # The game's typewriter has used "AmmoCache" for what's now modeled as # SupplyCache, treat it as the same type rather than dropping the target. @@ -497,15 +562,23 @@ def _clean_reference(raw: str) -> str: # becomes one clue, not a spurious extra bearing-only one from the same # text. `\s` already matches a literal newline, so this bridges an OCR # line-wrap ("...Bearing 125°" / "from Spotter#1" split across two lines) -# without needing the caller to rejoin anything. +# without needing the caller to rejoin anything. Extract functions return +# (bearing_deg, distance_km, reference, bearing_tolerance_deg), the last +# one None except for the compass-word shapes. _CLUE_PATTERNS = ( - (_CLUE_COMBINED_RE, lambda m: (float(m.group(1)), float(m.group(2)), m.group(3))), - (_CLUE_INLINE_RE, lambda m: (float(m.group(1)), float(m.group(2)), m.group(3))), - (_CLUE_BEARING_RE, lambda m: (float(m.group(1)), None, m.group(2))), + (_CLUE_COMBINED_RE, lambda m: (float(m.group(1)), float(m.group(2)), m.group(3), None)), + (_CLUE_INLINE_RE, lambda m: (float(m.group(1)), float(m.group(2)), m.group(3), None)), + (_CLUE_BEARING_RE, lambda m: (float(m.group(1)), None, m.group(2), None)), (_CLUE_DISTANCE_COMPASS_RE, lambda m: ( - _COMPASS_BEARINGS[re.sub(r"[-\s]", "", m.group(2)).upper()], float(m.group(1)), m.group(3) + _compass_to_bearing(m.group(2)), float(m.group(1)), m.group(3), None + )), + (_CLUE_DISTANCE_RE, lambda m: (None, float(m.group(1)), m.group(2), None)), + # reference-first shape: ': ', no keyword, no 'from' + (_CLUE_REF_DISTANCE_RE, lambda m: (None, float(m.group(2)), m.group(1), None)), + (_CLUE_REF_BEARING_RE, lambda m: (float(_fix_digits(m.group(2))), None, m.group(1), None)), + (_CLUE_REF_COMPASS_RE, lambda m: ( + _compass_to_bearing(m.group(2)), None, m.group(1), _COMPASS_TOLERANCE_DEG )), - (_CLUE_DISTANCE_RE, lambda m: (None, float(m.group(1)), m.group(2))), ) @@ -525,8 +598,9 @@ def _parse_all_clues(text: str) -> list[Clue]: if overlaps(span): continue consumed.append(span) - bearing, distance, ref = extract(m) - clues.append(Clue(reference=_clean_reference(ref), bearing_deg=bearing, distance_km=distance)) + bearing, distance, ref, tolerance = extract(m) + clues.append(Clue(reference=_clean_reference(ref), bearing_deg=bearing, distance_km=distance, + bearing_tolerance_deg=tolerance)) return clues @@ -575,7 +649,10 @@ def parse_intel_blocks(text: str) -> list[dict]: if current is not None: joined = "\n".join(current["raw"]) current["clues"] = _parse_all_clues(joined) - current["coord"] = _extract_grid_coord(joined) or _extract_requested_on_coord(joined) + current["coord"] = ( + _extract_grid_coord(joined) or _extract_requested_on_coord(joined) + or _extract_large_grid_only_coord(joined) + ) current["shell"] = _extract_shell_request(joined) current["requested_time"] = _extract_requested_time(joined) if (current["clues"] or current["coord"] is not None @@ -632,8 +709,16 @@ def parse_intel_blocks(text: str) -> list[dict]: named_m = next((m for c in candidates if (m := _NAMED_HEADER_RE.match(c))), None) if named_m: - flush() type_word, num, inline = named_m.groups() + if _BARE_CLUE_VALUE_RE.match(inline.strip()): + # Not actually a header, a ': ' clue line + # for whatever block is already open (see + # _BARE_CLUE_VALUE_RE), append rather than start a new + # block over it. + if current is not None: + current["raw"].append(line) + continue + flush() num = _fix_id_digits(num) current = {"kind": "named", "name": f"{type_word}#{num}", "type_word": type_word, "id": num, "raw": [line], "clues": []} diff --git a/src/fenigma/solver.py b/src/fenigma/solver.py index 8ea3814..4e75577 100644 --- a/src/fenigma/solver.py +++ b/src/fenigma/solver.py @@ -215,12 +215,22 @@ def solve_location(location: Location, board: Board) -> SolveResult: 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] + 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: