OCR: parse fire-support requests ('Marine Garrison#1 pinned!')

New shape: '<Name>#<id> pinned!' followed by '<Shell> Shells requested
on <coord>' and 'Requested before - <T-time> -'. 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.
This commit is contained in:
Dominik Moritz Roth 2026-08-09 16:20:55 +02:00
parent eedea3ea19
commit 202219abf2
4 changed files with 96 additions and 14 deletions

View File

@ -539,7 +539,7 @@ class MainWindow(Adw.ApplicationWindow):
def _merge_targets(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]: def _merge_targets(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]:
changed = [] 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( existing = next(
(t for t in self.board.targets if t.type == target_type and t.id == target_id), None (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( existing = self.board.add_target(
target_type, Location(coord=coord, desc_raw=raw, clues=clues), id_=target_id 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) changed.append(existing.name)
# "SupplyCache#1 Destroyed." etc. Mark it dead if we already know # "SupplyCache#1 Destroyed." etc. Mark it dead if we already know
@ -606,8 +613,12 @@ class MainWindow(Adw.ApplicationWindow):
if result is None: if result is None:
self.toast(f"{target.name} not found in screenshot.") self.toast(f"{target.name} not found in screenshot.")
return return
raw, clues, coord = result raw, clues, coord, shell, requested_time = result
self._merge_parsed_location(target, raw, clues, coord) 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._refresh()
self.toast(f"{target.name} set from screenshot.") self.toast(f"{target.name} set from screenshot.")

View File

@ -326,7 +326,8 @@ class GridCanvas(Gtk.DrawingArea):
self._draw_marker(cr, obj.coord.as_fraction(), CATEGORY_COLOR[category], self._draw_marker(cr, obj.coord.as_fraction(), CATEGORY_COLOR[category],
obj.name, cell_w, cell_h, grid_h, width, height, obj.name, cell_w, cell_h, grid_h, width, height,
dim=(category == "target" and not obj.alive) or obj.hidden, 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(): for category, obj in self.board.ambiguous_entities_all():
if self._excluded_from_map(obj): if self._excluded_from_map(obj):
@ -337,7 +338,8 @@ class GridCanvas(Gtk.DrawingArea):
self._draw_marker(cr, candidate.as_fraction(), color, self._draw_marker(cr, candidate.as_fraction(), color,
f"{obj.name}? ({i + 1})", cell_w, cell_h, grid_h, width, height, 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), 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: for sf in self.board.scout_flights:
if sf.hidden: 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, def _draw_marker(self, cr, point_km, color, label, cell_w, cell_h, grid_h,
canvas_width, canvas_height, *, hollow=False, dim=False, 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) x, y = self._km_to_px(point_km, cell_w, cell_h, grid_h)
r, g, b = color r, g, b = color
alpha = 0.45 if dim else 1.0 alpha = 0.45 if dim else 1.0
@ -414,6 +416,13 @@ class GridCanvas(Gtk.DrawingArea):
cr.show_text(coord_text) cr.show_text(coord_text)
cr.set_font_size(11) 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: def _draw_firing_arrows(self, cr, cell_w, cell_h, grid_h) -> None:
"""Red arrow(s) Nest -> Target, for whatever's hovered or selected. """Red arrow(s) Nest -> Target, for whatever's hovered or selected.
Points at exactly the hovered/selected candidate when one is known Points at exactly the hovered/selected candidate when one is known

View File

@ -45,6 +45,7 @@ class TargetType(Enum):
HOSTILE_TANK = "Hostile Tank" HOSTILE_TANK = "Hostile Tank"
PILLBOX = "Pillbox" # armoured emplacement, fixed position PILLBOX = "Pillbox" # armoured emplacement, fixed position
COASTAL_BATTERY = "Coastal Battery" # heavy fixed emplacement, reported by listening posts 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 = "Enemy" # ad-hoc installation named directly in the intel text
# ("Enemy Signal Station", "Enemy Field Command"), not one of the # ("Enemy Signal Station", "Enemy Field Command"), not one of the
# game's fixed unit types, its id is the rest of that name with # game's fixed unit types, its id is the rest of that name with
@ -309,6 +310,11 @@ class Target:
shell: Shell | None = None shell: Shell | None = None
# Which gun this target is assigned to, if any, "unassigned" | "left" | "right". # Which gun this target is assigned to, if any, "unassigned" | "left" | "right".
assignment: str = "unassigned" 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 @property
def name(self) -> str: def name(self) -> str:
@ -563,6 +569,7 @@ class Board:
"powder_charges": t.powder_charges, "powder_charges": t.powder_charges,
"shell": t.shell.name if t.shell is not None else None, "shell": t.shell.name if t.shell is not None else None,
"assignment": t.assignment, "assignment": t.assignment,
"requested_time": t.requested_time,
} }
for t in self.targets for t in self.targets
], ],
@ -619,6 +626,7 @@ class Board:
powder_charges=t.get("powder_charges"), powder_charges=t.get("powder_charges"),
shell=Shell[t["shell"]] if t.get("shell") else None, shell=Shell[t["shell"]] if t.get("shell") else None,
assignment=t.get("assignment", "unassigned"), assignment=t.get("assignment", "unassigned"),
requested_time=t.get("requested_time"),
) )
for t in data.get("targets", []) for t in data.get("targets", [])
] ]

View File

@ -31,6 +31,7 @@ import pytesseract
from PIL import Image, ImageFilter from PIL import Image, ImageFilter
from .models import Clue, Coord, TargetType from .models import Clue, Coord, TargetType
from .shells import Shell
# --- preprocessing ----------------------------------------------------------- # --- preprocessing -----------------------------------------------------------
@ -152,6 +153,47 @@ def _extract_grid_coord(text: str) -> Coord | None:
return None return None
# A fire-support request gives its coord differently again: "<Shell>
# Shells requested on <coord>", 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: def _extract_coord(line: str) -> Coord | None:
m = _COORD_RE.search(line) m = _COORD_RE.search(line)
if not m: if not m:
@ -422,8 +464,9 @@ def _resolve_target_type(type_word: str) -> TargetType | None:
def parse_intel_blocks(text: str) -> list[dict]: def parse_intel_blocks(text: str) -> list[dict]:
"""Parse 'field intelligence' blocks into a list of dicts with keys """Parse 'field intelligence' blocks into a list of dicts with keys
kind ('rp' | 'named'), name, type_word, id, raw, clues, coord. Entries kind ('rp' | 'named'), name, type_word, id, raw, clues, coord, shell,
with neither a clue nor a grid coord are dropped (nothing to store). 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, Clues are extracted once per block, from the whole joined block text,
at flush time, not accumulated line-by-line while scanning. That's 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 "from Spotter#1" on separate lines) or two clues on one line
("Bearing X from A & Bearing Y from B") both resolve correctly. A ("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") 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] = [] entries: list[dict] = []
current: dict | None = None current: dict | None = None
@ -440,8 +485,11 @@ def parse_intel_blocks(text: str) -> list[dict]:
if current is not None: if current is not None:
joined = "\n".join(current["raw"]) joined = "\n".join(current["raw"])
current["clues"] = _parse_all_clues(joined) current["clues"] = _parse_all_clues(joined)
current["coord"] = _extract_grid_coord(joined) current["coord"] = _extract_grid_coord(joined) or _extract_requested_on_coord(joined)
if current["clues"] or current["coord"] is not None: 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 current["raw"] = joined
entries.append(current) entries.append(current)
current = None current = None
@ -690,8 +738,12 @@ class ParsedInfo:
spotters: dict[int, Coord] = field(default_factory=dict) spotters: dict[int, Coord] = field(default_factory=dict)
# name -> (raw description, clues, absolute coord if given directly) # name -> (raw description, clues, absolute coord if given directly)
reference_points: dict[str, tuple[str, list[Clue], Coord | None]] = field(default_factory=dict) reference_points: dict[str, tuple[str, list[Clue], Coord | None]] = field(default_factory=dict)
# (type, id) -> (raw description, clues, absolute coord if given directly) # (type, id) -> (raw description, clues, absolute coord if given
targets: dict[tuple[TargetType, str], tuple[str, list[Clue], Coord | None]] = field(default_factory=dict) # 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 # (type, id) of targets reported destroyed
destroyed: set[tuple[TargetType, str]] = field(default_factory=set) 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): if (TargetType.UNKNOWN, "1") not in info.targets and _fuzzy_contains(line, TARGET_IS_AT_KEYWORD):
coord = _extract_coord(line) coord = _extract_coord(line)
if coord is not None: if coord is not None:
info.targets[(TargetType.UNKNOWN, "1")] = (line, [], coord) info.targets[(TargetType.UNKNOWN, "1")] = (line, [], coord, None, None)
continue continue
for entry in parse_intel_blocks(text): 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"]) target_type = _resolve_target_type(entry["type_word"])
if target_type is None: if target_type is None:
continue 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): for entry in parse_train_intel(text):
info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"]) info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"])