From 202219abf2b76783d25c9720e24172b57f2c81b9 Mon Sep 17 00:00:00 2001 From: Dominik Roth Date: Sun, 9 Aug 2026 16:20:55 +0200 Subject: [PATCH] OCR: parse fire-support requests ('Marine Garrison#1 pinned!') New shape: '# pinned!' followed by ' Shells requested on ' and 'Requested before - -'. Different from every existing coord shape (no 'Grid' keyword), so it needed its own extractor (_extract_requested_on_coord), plus new ones for the shell code and the deadline string. New TargetType.MARINE_GARRISON, its multi-word name + real digit id already works through the existing squash_multiword_ids() pipeline (same as Coastal Battery/Listening Post) with no new header regex needed. Target gained a requested_time field (raw string, this app doesn't track a game clock to compare it against), persisted through save/load and shown on the map below the coord label. The requested shell sets target.shell directly rather than staying a suggestion, matching how a manually-picked shell already works. info.targets' value tuple grew from 3 to 5 elements (raw, clues, coord, shell, requested_time); updated both call sites in app.py that unpack it. Verified end to end: parse -> merge -> save/load round trip -> map draw. --- src/fenigma/app.py | 15 ++++++-- src/fenigma/grid_widget.py | 15 ++++++-- src/fenigma/models.py | 8 +++++ src/fenigma/ocr.py | 72 +++++++++++++++++++++++++++++++++----- 4 files changed, 96 insertions(+), 14 deletions(-) diff --git a/src/fenigma/app.py b/src/fenigma/app.py index ccbb8d5..d096d5b 100644 --- a/src/fenigma/app.py +++ b/src/fenigma/app.py @@ -539,7 +539,7 @@ class MainWindow(Adw.ApplicationWindow): def _merge_targets(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]: changed = [] - for (target_type, target_id), (raw, clues, coord) in info.targets.items(): + for (target_type, target_id), (raw, clues, coord, shell, requested_time) in info.targets.items(): existing = next( (t for t in self.board.targets if t.type == target_type and t.id == target_id), None ) @@ -549,6 +549,13 @@ class MainWindow(Adw.ApplicationWindow): existing = self.board.add_target( target_type, Location(coord=coord, desc_raw=raw, clues=clues), id_=target_id ) + # A fire-support request's shell/deadline are current-state + # facts from THIS report, not provenance to preserve like + # desc_raw/clues, a later re-read should just overwrite them. + if shell is not None: + existing.shell = shell + if requested_time is not None: + existing.requested_time = requested_time changed.append(existing.name) # "SupplyCache#1 Destroyed." etc. Mark it dead if we already know @@ -606,8 +613,12 @@ class MainWindow(Adw.ApplicationWindow): if result is None: self.toast(f"{target.name} not found in screenshot.") return - raw, clues, coord = result + raw, clues, coord, shell, requested_time = result self._merge_parsed_location(target, raw, clues, coord) + if shell is not None: + target.shell = shell + if requested_time is not None: + target.requested_time = requested_time self._refresh() self.toast(f"{target.name} set from screenshot.") diff --git a/src/fenigma/grid_widget.py b/src/fenigma/grid_widget.py index b713779..6db098f 100644 --- a/src/fenigma/grid_widget.py +++ b/src/fenigma/grid_widget.py @@ -326,7 +326,8 @@ class GridCanvas(Gtk.DrawingArea): self._draw_marker(cr, obj.coord.as_fraction(), CATEGORY_COLOR[category], obj.name, cell_w, cell_h, grid_h, width, height, dim=(category == "target" and not obj.alive) or obj.hidden, - selected=(obj is self.selected), coord=obj.coord) + selected=(obj is self.selected), coord=obj.coord, + extra_line=getattr(obj, "requested_time", None)) for category, obj in self.board.ambiguous_entities_all(): if self._excluded_from_map(obj): @@ -337,7 +338,8 @@ class GridCanvas(Gtk.DrawingArea): self._draw_marker(cr, candidate.as_fraction(), color, f"{obj.name}? ({i + 1})", cell_w, cell_h, grid_h, width, height, hollow=True, dim=obj.hidden or (category == "target" and not obj.alive), - selected=is_selected, coord=candidate) + selected=is_selected, coord=candidate, + extra_line=getattr(obj, "requested_time", None)) for sf in self.board.scout_flights: if sf.hidden: @@ -360,7 +362,7 @@ class GridCanvas(Gtk.DrawingArea): def _draw_marker(self, cr, point_km, color, label, cell_w, cell_h, grid_h, canvas_width, canvas_height, *, hollow=False, dim=False, - selected=False, coord=None) -> None: + selected=False, coord=None, extra_line=None) -> None: x, y = self._km_to_px(point_km, cell_w, cell_h, grid_h) r, g, b = color alpha = 0.45 if dim else 1.0 @@ -414,6 +416,13 @@ class GridCanvas(Gtk.DrawingArea): cr.show_text(coord_text) cr.set_font_size(11) + if extra_line: + cr.set_font_size(9) + cr.set_source_rgba(*COORD_LABEL, alpha) + cr.move_to(label_x, label_y + (24 if coord_text else 12)) + cr.show_text(extra_line) + cr.set_font_size(11) + def _draw_firing_arrows(self, cr, cell_w, cell_h, grid_h) -> None: """Red arrow(s) Nest -> Target, for whatever's hovered or selected. Points at exactly the hovered/selected candidate when one is known diff --git a/src/fenigma/models.py b/src/fenigma/models.py index afe2271..649be95 100644 --- a/src/fenigma/models.py +++ b/src/fenigma/models.py @@ -45,6 +45,7 @@ class TargetType(Enum): HOSTILE_TANK = "Hostile Tank" PILLBOX = "Pillbox" # armoured emplacement, fixed position COASTAL_BATTERY = "Coastal Battery" # heavy fixed emplacement, reported by listening posts + MARINE_GARRISON = "Marine Garrison" # allied unit, requests fire support (see Target.requested_time) ENEMY = "Enemy" # ad-hoc installation named directly in the intel text # ("Enemy Signal Station", "Enemy Field Command"), not one of the # game's fixed unit types, its id is the rest of that name with @@ -309,6 +310,11 @@ class Target: shell: Shell | None = None # Which gun this target is assigned to, if any, "unassigned" | "left" | "right". assignment: str = "unassigned" + # In-game clock deadline from a "Requested before - T10:31:41 -" style + # report (e.g. a Marine Garrison's fire support request), kept as the + # raw string as printed, this app doesn't track a game clock to compare + # it against, it's shown as-is for the player's own reference. + requested_time: str | None = None @property def name(self) -> str: @@ -563,6 +569,7 @@ class Board: "powder_charges": t.powder_charges, "shell": t.shell.name if t.shell is not None else None, "assignment": t.assignment, + "requested_time": t.requested_time, } for t in self.targets ], @@ -619,6 +626,7 @@ class Board: powder_charges=t.get("powder_charges"), shell=Shell[t["shell"]] if t.get("shell") else None, assignment=t.get("assignment", "unassigned"), + requested_time=t.get("requested_time"), ) for t in data.get("targets", []) ] diff --git a/src/fenigma/ocr.py b/src/fenigma/ocr.py index aa27995..0bf262f 100644 --- a/src/fenigma/ocr.py +++ b/src/fenigma/ocr.py @@ -31,6 +31,7 @@ import pytesseract from PIL import Image, ImageFilter from .models import Clue, Coord, TargetType +from .shells import Shell # --- preprocessing ----------------------------------------------------------- @@ -152,6 +153,47 @@ def _extract_grid_coord(text: str) -> Coord | None: return None +# A fire-support request gives its coord differently again: " +# Shells requested on ", no "Grid" keyword. Same shape otherwise. +_REQUESTED_ON_COORD_RE = re.compile( + rf"requested\s+on\s+([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})", + re.IGNORECASE, +) +# "SMK Shells requested on ..." names the shell by its short code, one of +# the Shell enum's own member names. +_SHELL_REQUEST_RE = re.compile(r"([A-Za-z]+)\s+Shells?\s+requested", re.IGNORECASE) +# "Requested before - T10:31:41 -": an in-game clock deadline, kept as +# the raw string, this app doesn't track a game clock to compare it +# against. +_REQUESTED_BEFORE_RE = re.compile(r"Requested\s+before\s*-\s*(T?\d{1,2}:\d{2}:\d{2})\s*-", re.IGNORECASE) + + +def _extract_requested_on_coord(text: str) -> Coord | None: + m = _REQUESTED_ON_COORD_RE.search(text) + if not m: + return None + letter, y, x, yy = m.groups() + try: + return Coord(X=letter.upper(), Y=int(_fix_digits(y)), x=int(_fix_digits(x)), y=int(_fix_digits(yy))) + except ValueError: + return None + + +def _extract_shell_request(text: str) -> Shell | None: + m = _SHELL_REQUEST_RE.search(text) + if not m: + return None + try: + return Shell[m.group(1).upper()] + except KeyError: + return None + + +def _extract_requested_time(text: str) -> str | None: + m = _REQUESTED_BEFORE_RE.search(text) + return m.group(1) if m else None + + def _extract_coord(line: str) -> Coord | None: m = _COORD_RE.search(line) if not m: @@ -422,8 +464,9 @@ def _resolve_target_type(type_word: str) -> TargetType | None: def parse_intel_blocks(text: str) -> list[dict]: """Parse 'field intelligence' blocks into a list of dicts with keys - kind ('rp' | 'named'), name, type_word, id, raw, clues, coord. Entries - with neither a clue nor a grid coord are dropped (nothing to store). + kind ('rp' | 'named'), name, type_word, id, raw, clues, coord, shell, + requested_time. Entries with none of clue/coord/shell/requested_time + are dropped (nothing to store). Clues are extracted once per block, from the whole joined block text, at flush time, not accumulated line-by-line while scanning. That's @@ -431,7 +474,9 @@ def parse_intel_blocks(text: str) -> list[dict]: "from Spotter#1" on separate lines) or two clues on one line ("Bearing X from A & Bearing Y from B") both resolve correctly. A target can also carry an absolute grid ref directly ("Grid Q3 9:0") - instead of/alongside clues.""" + instead of/alongside clues, or a fire-support request's own coord + shape ("SMK Shells requested on J6 8:3"), plus that request's shell + and deadline if given.""" entries: list[dict] = [] current: dict | None = None @@ -440,8 +485,11 @@ 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) - if current["clues"] or current["coord"] is not None: + current["coord"] = _extract_grid_coord(joined) or _extract_requested_on_coord(joined) + current["shell"] = _extract_shell_request(joined) + current["requested_time"] = _extract_requested_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 entries.append(current) current = None @@ -690,8 +738,12 @@ class ParsedInfo: spotters: dict[int, Coord] = field(default_factory=dict) # name -> (raw description, clues, absolute coord if given directly) reference_points: dict[str, tuple[str, list[Clue], Coord | None]] = field(default_factory=dict) - # (type, id) -> (raw description, clues, absolute coord if given directly) - targets: dict[tuple[TargetType, str], tuple[str, list[Clue], Coord | None]] = field(default_factory=dict) + # (type, id) -> (raw description, clues, absolute coord if given + # directly, requested shell if a fire-support request named one, + # requested-before deadline string if given) + targets: dict[ + tuple[TargetType, str], tuple[str, list[Clue], Coord | None, Shell | None, str | None] + ] = field(default_factory=dict) # (type, id) of targets reported destroyed destroyed: set[tuple[TargetType, str]] = field(default_factory=set) @@ -730,7 +782,7 @@ def parse_text(text: str) -> ParsedInfo: if (TargetType.UNKNOWN, "1") not in info.targets and _fuzzy_contains(line, TARGET_IS_AT_KEYWORD): coord = _extract_coord(line) if coord is not None: - info.targets[(TargetType.UNKNOWN, "1")] = (line, [], coord) + info.targets[(TargetType.UNKNOWN, "1")] = (line, [], coord, None, None) continue for entry in parse_intel_blocks(text): @@ -740,7 +792,9 @@ def parse_text(text: str) -> ParsedInfo: target_type = _resolve_target_type(entry["type_word"]) if target_type is None: continue - info.targets[(target_type, entry["id"])] = (entry["raw"], entry["clues"], entry["coord"]) + info.targets[(target_type, entry["id"])] = ( + entry["raw"], entry["clues"], entry["coord"], entry["shell"], entry["requested_time"] + ) for entry in parse_train_intel(text): info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"])