Fix ally/target bugs, OCR fire-support parsing, add debug capture

- Board.clear() now also drops allies; the "clear board?" guard checks
  allies too. New Board.clear_units() + Clear button right-click menu
  ("clear enemies, units & flights", keeps Nest/spotters/RPs).
- An Ally with the ad-hoc TargetType.ENEMY showed "Enemy" on the map
  popover/toast instead of "Ally" (icons.target_type_label already had
  the fix for the picker, now reused everywhere else via app.py's
  _display_name).
- Firing panel drag-reorder no longer triggers a full app refresh
  (solver + dedupe + map redraw) on every drop, just a local rebuild.
- "Always show geo" didn't draw for Allies (missing from the overlay
  candidate list); blast radius only respected selection, not the
  show_geo_desc pin.
- ocr.py: added a second fire-support-request grammar ("Infantry#N
  taking fire ... Requesting X Shell on our position at <coord> before
  <time>", plus a bearing/distance-from-position variant), distinct
  from the existing Marine Garrison one.
- New debug_capture.py: saves screenshots (+ metadata) the app handled
  badly, for later tuning of map_vision/ocr against real failures:
  map-read errors, user grid corrections (paired with the auto-detected
  grid), screenshots that read as text but may have been a map, and
  marker-detection ground truth (every proposal's accept/reject verdict
  plus units added with no matching proposal) captured whenever a
  screenshot stops being the active one.
- README: Known issues section (map screenshot reading, grid + unit
  detection, is unreliable and fails often).
- 14 new tests (tests/test_models.py, tests/test_debug_capture.py, +
  additions to tests/test_ocr.py), 38/38 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 17:35:37 +02:00
co-authored by Claude Sonnet 5
parent 136492b197
commit 1ddb532325
13 changed files with 823 additions and 44 deletions
+81 -2
View File
@@ -197,6 +197,83 @@ def _extract_requested_time(text: str) -> str | None:
return m.group(1) if m else None
# A second, unrelated fire-support-request grammar, seen from Infantry
# under attack ("taking fire") rather than a pinned Marine Garrison:
# Infantry#1 taking fire from id1! Requesting SMK Shell on our
# position at J6 2:7 before 10:38:57!
# Infantry#3 taking fire! Requesting HE Shell at bearing 239°,
# distance 10.76km from our position, J6 2:5, by 10:38:18 or we
# will be overrun!
# Differs from the Marine Garrison shape in every particular: the shell
# word order is reversed ("Requesting X Shell", not "X Shells requested"),
# the deadline has no "Requested"/dashes, just a bare "before"/"by <time>",
# and the target position is either given directly ("on our position at
# <coord>") or as a bearing/distance offset from that same inline
# position (never a *named* reference -- "our position" isn't a board
# entity, so this resolves the offset directly rather than going through
# a Clue).
# \s* (not \s+) between the shell code and 'Shell(s)': a rich-text paste's
# '<b>SMK Shell</b>' span gets squashed into one no-space token 'SMKShell'
# by squash_bold_spans() before this ever runs (same as any other
# multi-word bold span, see _squash_span_content), same reasoning as
# _extract_requesting_shell()'s docstring.
_REQUESTING_SHELL_RE = re.compile(r"Requesting\s+([A-Za-z]+?)\s*Shells?\b", re.IGNORECASE)
_TAKING_FIRE_TIME_RE = re.compile(r"\b(?:before|by)\s+(T?\d{1,2}:\d{2}:\d{2})\b", re.IGNORECASE)
_ON_OUR_POSITION_COORD_RE = re.compile(
rf"on\s+our\s+position\s+at\s+{_COORD_FRAGMENT}", re.IGNORECASE
)
_BEARING_DISTANCE_FROM_POSITION_RE = re.compile(
rf"bearing\s*({_DIGIT_CLASS}{{1,3}})\s*°?\s*,?\s*distance\s*([\d.]+)\s*k?m\s+from\s+our\s+"
rf"position,?\s*{_COORD_FRAGMENT}",
re.IGNORECASE,
)
def _extract_requesting_shell(text: str) -> Shell | None:
"""'Requesting SMK Shell' -- the taking-fire grammar's shell mention,
word order reversed from _extract_shell_request()'s Marine Garrison
one ('SMK Shells requested')."""
m = _REQUESTING_SHELL_RE.search(text)
if not m:
return None
try:
return Shell[m.group(1).upper()]
except KeyError:
return None
def _extract_taking_fire_time(text: str) -> str | None:
m = _TAKING_FIRE_TIME_RE.search(text)
return m.group(1) if m else None
def _extract_on_our_position_coord(text: str) -> Coord | None:
m = _ON_OUR_POSITION_COORD_RE.search(text)
if not m:
return None
return _coord_from_groups(*m.groups())
def _extract_bearing_distance_from_position_coord(text: str) -> Coord | None:
"""The bearing/distance variant of a taking-fire request: the shell is
wanted somewhere OFF the reporting unit's own position, given as a
bearing/distance from it, with that position itself given inline
right there ('...from our position, J6 2:5, by ...'). "our position"
isn't a named board entity to hang a Clue off of, so this resolves
the offset directly via the same polar-projection math solve_location()
uses for an ordinary single bearing+distance clue."""
m = _BEARING_DISTANCE_FROM_POSITION_RE.search(text)
if not m:
return None
bearing, distance, letter, y, x, yy = m.groups()
origin = _coord_from_groups(letter, y, x, yy)
if origin is None:
return None
point = solver.point_from_bearing_distance(
origin.as_fraction(), float(_fix_digits(bearing)), float(distance))
return solver.point_to_coord(point)
# "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
@@ -659,10 +736,12 @@ def parse_intel_blocks(text: str) -> list[dict]:
current["clues"] = _parse_all_clues(joined)
current["coord"] = (
_extract_grid_coord(joined) or _extract_requested_on_coord(joined)
or _extract_on_our_position_coord(joined)
or _extract_bearing_distance_from_position_coord(joined)
or _extract_large_grid_only_coord(joined)
)
current["shell"] = _extract_shell_request(joined)
current["requested_time"] = _extract_requested_time(joined)
current["shell"] = _extract_shell_request(joined) or _extract_requesting_shell(joined)
current["requested_time"] = _extract_requested_time(joined) or _extract_taking_fire_time(joined)
if (current["clues"] or current["coord"] is not None
or current["shell"] is not None or current["requested_time"] is not None):
current["raw"] = joined