Two related changes:
1. TargetType.HOSTILE_ARTILLERY/HOSTILE_TANK renamed to ARTILLERY/TANK,
dropping the baked-in hostility assumption from the type name
itself (a type describes the unit kind now, not an allegiance).
Migration entries added for both, plus the already-existing
COASTAL_BATTERY one, so old save files still load.
2. A friendly contact ('FriendlyTank#1:', detected by stripping a
leading 'Friendly'/'Hostile' word off the type word before matching
it, see ocr.py's _resolve_target_type()) is NOT a Target with a flag
flipped, it's tracked as a new, entirely separate Ally
(Board.allies), with its own id namespace: an ally Tank#1 and a
hostile Target Tank#1 are two unrelated things that happen to share
an id, not a collision (verified directly, see the rendered
screenshot both coexisting). Ally intentionally has none of Target's
firing-relevant fields (shell/powder_charges/assignment/alive),
allies are never fired on. 'Hostile' and no prefix at all both mean
a regular (non-ally) Target, not-ally is the default.
Wired through: Board.add_ally/remove_ally, placed_entities_all()/
ambiguous_entities_all() (new 'ally' category, cyan on the map,
distinct from every other category's color), solver.resolve_board()
(allies' own clues resolve too), find_by_name() (an ally can be a
clue reference target), save/load round-trip, a new 'Allies' header
popover mirroring Targets' (position/hide/geo-overlay/remove, no
shell/charge/alive controls), and ParsedInfo.allies as a same-shaped
but separate dict from ParsedInfo.targets, merged by a new
_merge_allies() alongside _merge_targets() in _merge_all().
Verified: full test suite (added a dedicated OCR test for the Friendly/
Hostile/bare-prefix routing), a GTK smoke test round-tripping an ally
through save/load and the popover build, and a rendered screenshot
showing an ally Tank#1 and a hostile Target Tank#1 both on the map at
once with the same id, distinct colors, no collision.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1042 lines
47 KiB
Python
1042 lines
47 KiB
Python
"""Text OCR pipeline: screenshot -> cleaned-up text -> parsed board info.
|
|
|
|
Only the text sub-pipeline is implemented. There's no image/icon
|
|
recognition sub-pipeline yet (spotting markers, ship icons, etc.), that's
|
|
a separate future pipeline, out of scope here.
|
|
|
|
Preprocessing matters more than the regexes: the typewriter photo has an
|
|
uneven vignette (in-game light falloff) that a single global threshold
|
|
can't handle, it either loses faint corners or blobs-out dark ones. We
|
|
flatten that by dividing by a heavily blurred copy of itself (crude local
|
|
background normalization) before thresholding, which recovers text in the
|
|
darkened areas reliably.
|
|
|
|
Parsing is deliberately fuzzy: OCR on an in-game screenshot will misread
|
|
the odd character (keywords slightly garbled, '0'/'O', '1'/'I'/'l',
|
|
'5'/'S', '8'/'B' confused, stray specks turning ':' into ';' or '.'). We
|
|
match keywords by similarity rather than exact spelling, and normalize
|
|
digit-shaped letters before parsing numbers, so a couple of misrecognized
|
|
characters don't drop an otherwise-good line.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import difflib
|
|
import html
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
|
|
import numpy as np
|
|
import pytesseract
|
|
from PIL import Image, ImageFilter
|
|
|
|
from . import solver
|
|
from .models import Board, Clue, Coord, Location, TargetType
|
|
from .shells import Shell
|
|
|
|
# --- preprocessing -----------------------------------------------------------
|
|
|
|
_BLUR_RADIUS = 35
|
|
_THRESHOLD = 200
|
|
|
|
|
|
def preprocess(image: Image.Image) -> Image.Image:
|
|
gray = image.convert("L")
|
|
arr = np.asarray(gray, dtype=np.float32)
|
|
background = np.asarray(gray.filter(ImageFilter.GaussianBlur(_BLUR_RADIUS)), dtype=np.float32)
|
|
normalized = np.clip(arr / (background + 1e-3) * 255.0, 0, 255).astype(np.uint8)
|
|
normalized_img = Image.fromarray(normalized)
|
|
return normalized_img.point(lambda p: 255 if p > _THRESHOLD else 0)
|
|
|
|
|
|
def ocr_text(image: Image.Image) -> str:
|
|
return pytesseract.image_to_string(preprocess(image), config="--psm 6")
|
|
|
|
|
|
# --- fuzzy keyword matching ---------------------------------------------------
|
|
|
|
NEST_KEYWORD = "IRON NEST"
|
|
SPOTTER_KEYWORD = "SPOTTER"
|
|
TARGET_IS_AT_KEYWORD = "Target is at"
|
|
_FUZZY_THRESHOLD = 0.65
|
|
|
|
|
|
def _fuzzy_locate(line: str, keyword: str, threshold: float = _FUZZY_THRESHOLD) -> tuple[int, int] | None:
|
|
"""Span of the best approximate match of `keyword` in `line`, or None."""
|
|
line_u = line.upper()
|
|
kw = keyword.upper()
|
|
n = len(kw)
|
|
best_ratio = 0.0
|
|
best_span = None
|
|
for size in range(max(n - 2, 1), n + 3):
|
|
for start in range(0, max(len(line_u) - size, 0) + 1):
|
|
end = start + size
|
|
ratio = difflib.SequenceMatcher(None, line_u[start:end], kw).ratio()
|
|
if ratio > best_ratio:
|
|
best_ratio = ratio
|
|
best_span = (start, end)
|
|
return best_span if best_ratio >= threshold else None
|
|
|
|
|
|
def _fuzzy_contains(line: str, keyword: str, threshold: float = _FUZZY_THRESHOLD) -> bool:
|
|
"""True if some run of characters in `line` approximately matches `keyword`."""
|
|
return _fuzzy_locate(line, keyword, threshold) is not None
|
|
|
|
|
|
# --- coordinate / id extraction, tolerant of digit<->letter OCR mixups --------
|
|
|
|
_DIGIT_CLASS = r"[0-9OoIiLlSsBbZzGg]"
|
|
_DIGIT_FIX = str.maketrans({
|
|
"O": "0", "o": "0",
|
|
"I": "1", "i": "1", "L": "1", "l": "1",
|
|
"S": "5", "s": "5",
|
|
"B": "8", "b": "8",
|
|
"Z": "2", "z": "2",
|
|
"G": "6", "g": "6",
|
|
})
|
|
# The full-precision coordinate shape ('H3 5:5', 'Q4 4:2'), one big-grid
|
|
# letter, one big-grid number, then the sub-grid x:y pair. Every coord
|
|
# regex in this module that captures a full coordinate (not just the
|
|
# large-cell letter+number, see _LARGE_GRID_ONLY_RE) embeds this exact
|
|
# fragment rather than restating it, so its four capture groups are
|
|
# always (letter, big_y, x, y) in that order, matching what
|
|
# _coord_from_groups() below expects.
|
|
_COORD_FRAGMENT = rf"([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})"
|
|
_COORD_RE = re.compile(_COORD_FRAGMENT)
|
|
|
|
|
|
def _coord_from_groups(letter: str, y: str, x: str, yy: str) -> Coord | None:
|
|
"""Build a Coord from a _COORD_FRAGMENT match's four groups, digit-
|
|
fixing each numeric one, None on an out-of-range result rather than
|
|
raising (OCR garbage can still parse as *a* number, just not a valid
|
|
grid position)."""
|
|
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
|
|
# No literal '#' required, it's just as OCR-corruptible as anything else
|
|
# (missing entirely, or misread as e.g. 'H'). We instead anchor to *where*
|
|
# the fuzzy keyword match ended and take the first run of digit-shaped
|
|
# characters after that, skipping over whatever separator survived.
|
|
_ID_RE = re.compile(rf"({_DIGIT_CLASS}+)")
|
|
|
|
# Not seen in a real screenshot yet, so no extraction for these, add a
|
|
# keyword + extractor here (plus a field below and a case in parse_text)
|
|
# once we know the format:
|
|
# - reference points
|
|
# - targets
|
|
|
|
|
|
def _fix_digits(s: str) -> str:
|
|
return s.translate(_DIGIT_FIX)
|
|
|
|
|
|
def _fix_id_digits(raw: str) -> str:
|
|
"""Like _fix_digits, but for id-length runs specifically: also
|
|
collapses a 2-character run where one char is a genuine digit and the
|
|
other a look-alike letter that maps to the *same* digit, e.g. '1l' or
|
|
'S5' both fix to '11'/'55', but a real 2-digit id wouldn't plausibly
|
|
render as one numeral plus one letter of the identical value; that
|
|
shape is the signature of OCR ghosting a single thin glyph twice
|
|
(seen repeatedly: 'AmmoCache#l1' -> #1, 'HostileTank#S5' -> #5).
|
|
A genuine two-digit id (both chars already real digits, e.g. '11')
|
|
is left alone."""
|
|
fixed = _fix_digits(raw)
|
|
if len(raw) == 2 and len(set(fixed)) == 1:
|
|
has_digit = any(c.isdigit() for c in raw)
|
|
has_letter = any(c.isalpha() for c in raw)
|
|
if has_digit and has_letter:
|
|
return fixed[0]
|
|
return fixed
|
|
|
|
|
|
# A target can also be spotted with an absolute grid ref directly
|
|
# ("Target#10 Spotted. Grid Q3 9:0") instead of/alongside bearing/distance
|
|
# clues, same coordinate shape as _COORD_RE, just anchored after "Grid".
|
|
_GRID_COORD_RE = re.compile(rf"Grid\s+{_COORD_FRAGMENT}", re.IGNORECASE)
|
|
|
|
|
|
def _extract_grid_coord(text: str) -> Coord | None:
|
|
m = _GRID_COORD_RE.search(text)
|
|
if not m:
|
|
return None
|
|
return _coord_from_groups(*m.groups())
|
|
|
|
|
|
# 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+{_COORD_FRAGMENT}", 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
|
|
return _coord_from_groups(*m.groups())
|
|
|
|
|
|
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
|
|
|
|
|
|
# "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:
|
|
return None
|
|
return _coord_from_groups(*m.groups())
|
|
|
|
|
|
def _extract_leading_id(remainder: str) -> int | None:
|
|
"""First digit-shaped run in `remainder` (text after the keyword match)."""
|
|
m = _ID_RE.search(remainder)
|
|
if not m:
|
|
return None
|
|
try:
|
|
return int(_fix_id_digits(m.group(1)))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
# --- "field intelligence" blocks: relative Bearing/Distance descriptions ------
|
|
#
|
|
# Target#5 Spotted. 088, 12.10km from Spotter#1
|
|
# Reference Point Alpha:
|
|
# Bearing 094 from Spotter#1
|
|
# Distance 13.26km from Spotter#2
|
|
# .
|
|
# AmmoCache#3:
|
|
# Bearing 217 & Distance 10.48km from AmmoCache#2
|
|
#
|
|
# Each named entity is a block of one or more clue lines, terminated by a
|
|
# blank line or a lone '.'. Degree signs, colons, and the 'km' unit are all
|
|
# treated as optional/lossy, OCR drops them unpredictably.
|
|
|
|
_RP_HEADER_RE = re.compile(r"Reference\s+Point\s+([A-Za-z][\w-]*)\s*:?", re.IGNORECASE)
|
|
# '#' isn't required literally, same reasoning as the spotter-id fix: OCR
|
|
# drops it or renders it as noise (seen: '€'). Up to 2 junk characters
|
|
# between the type word and its digits is enough slack without risking a
|
|
# false match elsewhere.
|
|
# ^ the junk-class run is REQUIRED (1-2 chars, not 0-2): the digit class
|
|
# deliberately overlaps the alphabet (g/s/i/l/o/... look like digits), so
|
|
# with a 0-width separator allowed, "Bearing" backtracks into itself,
|
|
# word="Bearin", "digit"=its own trailing 'g', and falsely matches as a
|
|
# header. Requiring real punctuation between word and digits (true of
|
|
# every observed header: '#', a misread substitute, ...) rules that out,
|
|
# and also stops a bare "Word 094" clue line (space only) from matching.
|
|
#
|
|
# This '<word><junk><digits>' shape ('AmmoCache#3', 'HostileTank#3') is
|
|
# what a "Type#id" reference/header looks like anywhere in this module,
|
|
# _NAMED_HEADER_RE, _REF_NAMED_RE, and _DESTROYED_RE below all embed this
|
|
# same fragment rather than restating it.
|
|
_TYPE_ID_FRAGMENT = rf"([A-Za-z]+)[^A-Za-z0-9\s]{{1,2}}({_DIGIT_CLASS}+)"
|
|
_NAMED_HEADER_RE = re.compile(rf"^{_TYPE_ID_FRAGMENT}\s*:?\s*(.*)$")
|
|
|
|
# Last-resort header: a name with no keyword and no id at all, just
|
|
# '<Name>:' after squashing ('HMS Rockingham:' -> 'HMSRockingham:'). The
|
|
# colon is REQUIRED here (unlike every other header regex's optional
|
|
# one), nothing else anchors this match, so an optional colon would
|
|
# false-positive on an ordinary clue-continuation line's leading word.
|
|
_BARE_NAME_HEADER_RE = re.compile(r"^([A-Za-z][A-Za-z0-9]*)\s*:\s*(.*)$")
|
|
|
|
# Ad-hoc enemy installations are named in plain English rather than given a
|
|
# Type#N id ("Enemy Signal Station:", "Bearing 034 from Enemy Signal
|
|
# Station"), which breaks two assumptions everywhere else in this module:
|
|
# headers/references are always a single whitespace-free token, and an id
|
|
# is always digit-shaped. squash_enemy_names() fixes both up front by
|
|
# collapsing 'Enemy' + however many Title Case words follow it into one
|
|
# token in our own 'Type#id' shape ('Enemy#SignalStation'), before
|
|
# anything else tries to parse the text, so every from-<ref> clue pattern
|
|
# (which just captures \S+) and _NAMED_HEADER_RE both work unmodified
|
|
# except NAMED_HEADER_RE's id group is digit-only, so a second header
|
|
# regex below handles the now-squashed, letter-only id.
|
|
_ENEMY_NAME_RE = re.compile(r"\bEnemy(?:[ \t]+[A-Z][a-zA-Z]*){1,4}\b")
|
|
_ENEMY_HEADER_RE = re.compile(r"^(Enemy)#([A-Za-z]+)\s*:?\s*(.*)$", re.IGNORECASE)
|
|
|
|
|
|
def squash_enemy_names(text: str) -> str:
|
|
"""'Enemy Signal Station' -> 'Enemy#SignalStation', anywhere it
|
|
appears, header or reference alike. The word-separator inside a name
|
|
is deliberately [ \\t]+, not \\s+: it must not cross a newline, or a
|
|
header right at the end of a line ('...Enemy Signal Station') would
|
|
swallow the next line's 'Bearing ...' clue into the same 'name'."""
|
|
return _ENEMY_NAME_RE.sub(lambda m: "Enemy#" + "".join(m.group(0).split()[1:]), text)
|
|
|
|
|
|
# Some named things have a multi-word type ("Coastal Battery#2",
|
|
# "Listening Post#1") but, unlike the Enemy case above, already come with
|
|
# a real digit id attached, nothing to invent. Only the embedded space
|
|
# needs collapsing so _NAMED_HEADER_RE and every from-<ref> clue pattern
|
|
# see the single token they expect ('CoastalBattery#2'). Requires 2+
|
|
# words specifically, a single-word id like 'AmmoCache#3' already works
|
|
# and shouldn't be touched here. Each word must be Title Case, same
|
|
# reasoning as _ENEMY_NAME_RE: without it, ordinary lowercase prose right
|
|
# before some unrelated '#N' ("...South-East from Listening Post#1" ->
|
|
# matching backward from "Post#1" through "from") gets swallowed into the
|
|
# 'name' too, an all-lowercase connector word is never actually part of
|
|
# one of these names.
|
|
_MULTIWORD_ID_RE = re.compile(rf"\b([A-Z][a-zA-Z]*(?:[ \t]+[A-Z][a-zA-Z]*){{1,3}})\s*#\s*({_DIGIT_CLASS}+)")
|
|
|
|
|
|
def squash_multiword_ids(text: str) -> str:
|
|
"""'Coastal Battery#2' -> 'CoastalBattery#2', run before
|
|
squash_enemy_names() (order doesn't actually matter, the two patterns
|
|
can't overlap: this one requires a literal '#'+digits, the Enemy one
|
|
requires no digits at all)."""
|
|
return _MULTIWORD_ID_RE.sub(lambda m: "".join(m.group(1).split()) + "#" + m.group(2), text)
|
|
|
|
|
|
# A named anchor can also be given inline, all on one line, rather than as
|
|
# its own block: "Listening Post#1 at K6 7:8 audio reports on:". RP-shaped
|
|
# same as everything else that resolves to a name + coord, searched for
|
|
# anywhere rather than tied to a section keyword, this shape is generic
|
|
# enough (not specific to "Listening Post") to catch whatever else turns
|
|
# up named this way.
|
|
_NAMED_AT_COORD_RE = re.compile(rf"([A-Za-z]+)#({_DIGIT_CLASS}+)\s+at\s+{_COORD_FRAGMENT}", re.IGNORECASE)
|
|
|
|
|
|
def parse_named_at_coord(text: str) -> list[dict]:
|
|
"""Parse every '<Name>#<id> at <Coord>' anchor into an RP-shaped
|
|
entry. Call after squash_multiword_ids(), so a multi-word name here
|
|
is already a single 'Name#id' token by the time this regex sees it."""
|
|
entries = []
|
|
for m in _NAMED_AT_COORD_RE.finditer(text):
|
|
word, num, letter, y, x, yy = m.groups()
|
|
coord = _coord_from_groups(letter, y, x, yy)
|
|
if coord is None:
|
|
continue
|
|
entries.append({
|
|
"kind": "rp", "name": f"{word}#{_fix_id_digits(num)}", "type_word": None, "id": None,
|
|
"raw": m.group(0).strip(), "clues": [], "coord": coord,
|
|
})
|
|
return entries
|
|
|
|
|
|
# Forward-observer reports: several standalone one-liners, each an
|
|
# independent FO position (a literal one-off coordinate right there in
|
|
# the line, not a name referencing some other known entity) giving one
|
|
# bearing/distance reading on a named target:
|
|
# FO#5 Audio report on HMS Rockingham: 2.24km From I8 6:9 . . .
|
|
# FO#4 Eyes on HMS Rockingham: 087° From G7 6:7 . . .
|
|
# FO Eyes on HMS Rockingham: 099° From C9 1:2 . . .
|
|
# ('FO' alone, no '#N', for exactly one report is fine, still its own
|
|
# distinct position.) Distance tried before bearing, same reasoning as
|
|
# _CLUE_PATTERNS elsewhere: an ambiguous run of digits could otherwise
|
|
# let the bearing pattern's optional '°' swallow part of a distance
|
|
# reading if bearing ran first.
|
|
_FO_TARGET_NAME = r"([A-Za-z][A-Za-z0-9'\s]*?)"
|
|
_FO_DISTANCE_RE = re.compile(
|
|
rf"FO(?:#({_DIGIT_CLASS}+))?\s+(?:Audio\s+report\s+on|Eyes\s+on)\s+{_FO_TARGET_NAME}\s*:\s*"
|
|
rf"([\d.]+)\s*k?m\s+From\s+{_COORD_FRAGMENT}",
|
|
re.IGNORECASE,
|
|
)
|
|
_FO_BEARING_RE = re.compile(
|
|
rf"FO(?:#({_DIGIT_CLASS}+))?\s+(?:Audio\s+report\s+on|Eyes\s+on)\s+{_FO_TARGET_NAME}\s*:\s*"
|
|
rf"({_DIGIT_CLASS}{{1,3}})\s*°?\s+From\s+{_COORD_FRAGMENT}",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def parse_fo_reports(text: str) -> dict[str, tuple[str, Coord | None]]:
|
|
"""An FO position isn't tracked as a real board entity, nothing to
|
|
re-reference later, no ongoing observation post, just a one-off
|
|
fix, so this triangulates immediately: for each named target, build
|
|
a throwaway scratch Board with just that target's FO positions as
|
|
(never-persisted) reference points, run the exact same
|
|
solve_location() geometry/priority the real board uses, keep only
|
|
the resulting Coord (or None, if the readings under- or
|
|
over-determine it in a way solve_location() can't resolve), then
|
|
discard the scratch board and every ephemeral FO entity in it.
|
|
Returns target_name -> (raw report text, resolved coord or None)."""
|
|
consumed: list[tuple[int, int]] = []
|
|
|
|
def overlaps(span: tuple[int, int]) -> bool:
|
|
return any(span[0] < e and span[1] > s for s, e in consumed)
|
|
|
|
# target_key -> list of (fo_name, fo_coord, bearing_deg, distance_km, raw_line)
|
|
groups: dict[str, list[tuple[str, Coord, float | None, float | None, str]]] = {}
|
|
|
|
def collect(m, is_distance: bool) -> None:
|
|
span = m.span()
|
|
if overlaps(span):
|
|
return
|
|
consumed.append(span)
|
|
fo_num, target_name, value, letter, y, x, yy = m.groups()
|
|
fo_name = f"FO#{_fix_id_digits(fo_num)}" if fo_num else "FO"
|
|
coord = _coord_from_groups(letter, y, x, yy)
|
|
if coord is None:
|
|
return
|
|
target_key = "".join(re.sub(r"[^A-Za-z0-9]", "", w) for w in target_name.split())
|
|
bearing = None if is_distance else float(_fix_digits(value))
|
|
distance = float(_fix_digits(value)) if is_distance else None
|
|
groups.setdefault(target_key, []).append((fo_name, coord, bearing, distance, m.group(0).strip()))
|
|
|
|
for m in _FO_DISTANCE_RE.finditer(text):
|
|
collect(m, is_distance=True)
|
|
for m in _FO_BEARING_RE.finditer(text):
|
|
collect(m, is_distance=False)
|
|
|
|
results: dict[str, tuple[str, Coord | None]] = {}
|
|
for target_key, readings in groups.items():
|
|
scratch = Board()
|
|
clues = []
|
|
placed: set[str] = set()
|
|
for fo_name, coord, bearing, distance, _raw in readings:
|
|
if fo_name not in placed:
|
|
placed.add(fo_name)
|
|
scratch.add_reference_point(coord, name=fo_name)
|
|
clues.append(Clue(reference=fo_name, bearing_deg=bearing, distance_km=distance))
|
|
raw_text = "\n".join(r[4] for r in readings)
|
|
result = solver.solve_location(Location.from_desc(raw_text, clues), scratch)
|
|
results[target_key] = (raw_text, result.coord)
|
|
return results
|
|
|
|
|
|
_BLOCK_END_RE = re.compile(r"^[.\s]{1,6}$")
|
|
_LEADING_NOISE_RE = re.compile(r"^[^A-Za-z]{1,3}(?=[A-Za-z])")
|
|
|
|
# Reference capture is (\S+), not (.+): references are always a single
|
|
# token with no spaces, and being non-greedy this way is what lets
|
|
# finditer() find more than one clue per line/block, "Bearing 118 from
|
|
# Spotter#2 & Bearing 125 from Spotter#1" needs two separate matches, and
|
|
# a greedy (.+) would let the first one swallow the rest of the string.
|
|
#
|
|
# _GAP sits right before "from": plain whitespace normally, but an OCR
|
|
# line-wrap can drop a stray junk token right at the break ("Bearing 125°"
|
|
# / "P; from Spotter#1"), and that junk can itself contain a letter (the
|
|
# 'P' above), so this isn't just non-alnum noise like the header-bullet
|
|
# case; tolerate any single short token, not just punctuation.
|
|
_GAP = r"[\s]*(?:\S{1,3}\s*)?"
|
|
_CLUE_COMBINED_RE = re.compile(
|
|
rf"Bearing\s*(\d{{1,3}})\s*°?\s*&\s*Distance\s*([\d.]+)\s*k?m?{_GAP}from\s+(\S+)", re.IGNORECASE
|
|
)
|
|
_CLUE_INLINE_RE = re.compile(
|
|
rf"(\d{{1,3}})\s*°?\s*,\s*([\d.]+)\s*k?m{_GAP}from\s+(\S+)", re.IGNORECASE
|
|
)
|
|
_CLUE_BEARING_RE = re.compile(rf"Bearing\s*(\d{{1,3}})\s*°?{_GAP}from\s+(\S+)", re.IGNORECASE)
|
|
|
|
# A listening post gives distance readings by ear, an approximate 8-point
|
|
# compass direction instead of a precise degree bearing: "Distance 6.28km
|
|
# South-East from Listening Post#1". Word order differs from every other
|
|
# clue shape too (the direction sits between the distance and 'from',
|
|
# there's no separate 'Bearing' keyword at all), so this needs its own
|
|
# pattern rather than reusing _CLUE_COMBINED_RE with a looser bearing group.
|
|
_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,
|
|
}
|
|
# 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, and "CoastalBattery" for what's just a HostileArtillery
|
|
# under a different name, treat both as the same type rather than
|
|
# dropping the target or inventing a redundant enum member for it.
|
|
_TYPE_WORD_ALIASES = {"AmmoCache": "SupplyCache", "CoastalBattery": "HostileArtillery"}
|
|
|
|
|
|
_REF_NAMED_RE = re.compile(rf"^{_TYPE_ID_FRAGMENT}")
|
|
|
|
|
|
def _clean_reference(raw: str) -> str:
|
|
"""Leading name-shaped token, dropping trailing OCR noise (stray dots,
|
|
double spaces, etc.), reference names never contain spaces. Named
|
|
references ('Spotter#1', 'AmmoCache#2') get their digit part fixed up
|
|
and their separator normalized to '#'; plain word references ('Alpha')
|
|
are left untouched, don't run digit-fixing over them or real letters
|
|
like the 'l' in 'Alpha' get corrupted into '1'. 'Enemy#SignalStation'
|
|
(squash_enemy_names() already normalized the separator, and its id is
|
|
letters, not digits) is passed through as-is rather than falling into
|
|
the digit-fixing path below, which would otherwise stop at the first
|
|
letter outside _DIGIT_CLASS and truncate it (e.g. down to 'Enemy#Sig')."""
|
|
token = re.match(r"\S+", raw.strip())
|
|
token = token.group(0) if token else raw.strip()
|
|
|
|
enemy = _ENEMY_HEADER_RE.match(token)
|
|
if enemy:
|
|
return f"{enemy.group(1)}#{enemy.group(2)}"
|
|
|
|
named = _REF_NAMED_RE.match(token)
|
|
if named:
|
|
word, num = named.groups()
|
|
word = _TYPE_WORD_ALIASES.get(word, word)
|
|
return f"{word}#{_fix_id_digits(num)}"
|
|
|
|
return re.sub(r"[^\w-]+$", "", token) # trim trailing punctuation off a plain name
|
|
|
|
|
|
# Priority order matters: try the "both bearing and distance" shape before
|
|
# the single-value shapes, so e.g. "Bearing 217 & Distance 10.48km from X"
|
|
# 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. 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), 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_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: '<ref>: <value>', 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
|
|
)),
|
|
)
|
|
|
|
|
|
def _parse_all_clues(text: str) -> list[Clue]:
|
|
"""Every Bearing/Distance clue found anywhere in `text`, a block can
|
|
have several (one per clue line, or more than one on a single line
|
|
joined with '&')."""
|
|
clues: list[Clue] = []
|
|
consumed: list[tuple[int, int]] = []
|
|
|
|
def overlaps(span: tuple[int, int]) -> bool:
|
|
return any(span[0] < e and span[1] > s for s, e in consumed)
|
|
|
|
for pattern, extract in _CLUE_PATTERNS:
|
|
for m in pattern.finditer(text):
|
|
span = m.span()
|
|
if overlaps(span):
|
|
continue
|
|
consumed.append(span)
|
|
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
|
|
|
|
|
|
def parse_clues_from_text(text: str) -> list[Clue]:
|
|
"""Parse every Bearing/Distance clue found in free-form text, used
|
|
for manually-typed descriptions in the coord dialog, sharing the
|
|
exact same clue grammar as the OCR'd intel blocks."""
|
|
text = strip_html(squash_bold_spans(text))
|
|
return _parse_all_clues(squash_enemy_names(squash_multiword_ids(text)))
|
|
|
|
|
|
_ALLY_PREFIX_RE = re.compile(r"^(Friendly|Hostile)", re.IGNORECASE)
|
|
|
|
|
|
def _resolve_target_type(type_word: str) -> tuple[TargetType | None, bool]:
|
|
"""(TargetType, is_ally). A leading 'Friendly'/'Hostile' word is
|
|
stripped off the type word first ('FriendlyTank' -> ally, TANK;
|
|
'HostileTank' or bare 'Tank' -> not ally, TANK, an explicit
|
|
'Hostile' and no prefix at all mean the same thing, not-ally is the
|
|
default). What's left is matched exactly against the type word
|
|
(after aliasing), falling back to fuzzy (OCR can garble the type
|
|
word itself, e.g. 'AmmoCoche')."""
|
|
is_ally = False
|
|
prefix_m = _ALLY_PREFIX_RE.match(type_word)
|
|
if prefix_m:
|
|
is_ally = prefix_m.group(1).lower() == "friendly"
|
|
type_word = type_word[prefix_m.end():]
|
|
|
|
type_word = _TYPE_WORD_ALIASES.get(type_word, type_word)
|
|
if type_word in _TYPE_BY_SHORT:
|
|
return _TYPE_BY_SHORT[type_word], is_ally
|
|
best, best_ratio = None, 0.0
|
|
for short, target_type in _TYPE_BY_SHORT.items():
|
|
ratio = difflib.SequenceMatcher(None, type_word.upper(), short.upper()).ratio()
|
|
if ratio > best_ratio:
|
|
best, best_ratio = target_type, ratio
|
|
return (best if best_ratio >= _FUZZY_THRESHOLD else None), is_ally
|
|
|
|
|
|
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, 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
|
|
what lets a clue split across an OCR line-wrap ("...Bearing 125°" /
|
|
"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, 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
|
|
|
|
def flush():
|
|
nonlocal current
|
|
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)
|
|
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
|
|
or current["shell"] is not None or current["requested_time"] is not None):
|
|
current["raw"] = joined
|
|
entries.append(current)
|
|
current = None
|
|
|
|
for raw_line in text.splitlines():
|
|
line = raw_line.strip()
|
|
if not line:
|
|
continue # blank lines are just visual spacing here, not a section boundary
|
|
if _BLOCK_END_RE.match(line):
|
|
flush()
|
|
continue
|
|
|
|
# The '.' block-separator bullet often survives OCR as 1-3 stray
|
|
# junk characters glued onto the *next* line ('i AmmoCache#1:',
|
|
# '* AmmoCache#2:') instead of its own line, which would otherwise
|
|
# defeat the column-0-anchored header regexes below. Try the line
|
|
# as-is first, and only if that fails, retry with its first
|
|
# whitespace-delimited token stripped (covers symbol junk *and*
|
|
# a misread bullet that happened to OCR as a stray letter), but
|
|
# only when that leading token is bullet-length (<=3 chars), else
|
|
# a genuine wrapped clue continuation like "from Spotter#2" gets
|
|
# its "from" stripped and "Spotter#2" misread as a new header.
|
|
first_token_m = re.match(r"\S+", line)
|
|
strip_first_token = (
|
|
re.sub(r"^\S+\s+", "", line, count=1)
|
|
if first_token_m and len(first_token_m.group(0)) <= 3
|
|
else line
|
|
)
|
|
candidates = (line, _LEADING_NOISE_RE.sub("", line), strip_first_token)
|
|
|
|
rp_m = next((m for c in candidates if (m := _RP_HEADER_RE.search(c))), None)
|
|
if rp_m:
|
|
flush()
|
|
current = {"kind": "rp", "name": rp_m.group(1), "type_word": None, "id": None,
|
|
"raw": [line], "clues": []}
|
|
continue
|
|
|
|
# Tried before _NAMED_HEADER_RE: its digit class overlaps plain
|
|
# letters (S/B/Z/G/O/I/L), so on a squashed 'Enemy#SignalStation'
|
|
# header it would otherwise partial-match ('Sig' -> digit-fixed
|
|
# into the bogus id '516') before this more specific check ever
|
|
# gets a look.
|
|
enemy_m = next((m for c in candidates if (m := _ENEMY_HEADER_RE.match(c))), None)
|
|
if enemy_m:
|
|
flush()
|
|
type_word, letter_id, inline = enemy_m.groups()
|
|
current = {"kind": "named", "name": f"{type_word}#{letter_id}", "type_word": type_word,
|
|
"id": letter_id, "raw": [line], "clues": []}
|
|
continue
|
|
|
|
named_m = next((m for c in candidates if (m := _NAMED_HEADER_RE.match(c))), None)
|
|
if named_m:
|
|
type_word, num, inline = named_m.groups()
|
|
if _BARE_CLUE_VALUE_RE.match(inline.strip()):
|
|
# Not actually a header, a '<ref>: <value>' 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": []}
|
|
continue
|
|
|
|
# Last resort: a bare name with none of the above (no 'Reference
|
|
# Point'/'Enemy' keyword, no digit id), just '<Name>:' -
|
|
# ('HMS Rockingham:'). Requiring the colon (not optional, unlike
|
|
# the other header regexes) matters here specifically: without
|
|
# any keyword or id anchoring the match, an optional colon would
|
|
# false-positive on an ordinary clue-continuation line's first
|
|
# word ('Distance 6.14km...' -> 'Distance' read as a header).
|
|
# Treated as a Target (TargetType.UNKNOWN, whose short form IS
|
|
# 'Target'), not a Reference Point, a named thing giving its own
|
|
# clues is being spotted, not a fixed landmark spotters aim off
|
|
# of, same reasoning as the "Target is at-" calibration line.
|
|
bare_m = next((m for c in candidates if (m := _BARE_NAME_HEADER_RE.match(c))), None)
|
|
if bare_m:
|
|
flush()
|
|
name = bare_m.group(1)
|
|
current = {"kind": "named", "name": f"Target#{name}", "type_word": "Target",
|
|
"id": name, "raw": [line], "clues": []}
|
|
continue
|
|
|
|
if current is not None:
|
|
current["raw"].append(line)
|
|
|
|
flush()
|
|
return entries
|
|
|
|
|
|
# Train-arrival intel is a different shape entirely, a station's absolute
|
|
# grid ref, the rail's bearing from it, and a handful of waypoints given
|
|
# only as a distance along that same bearing, plus a T=HH:MM:SS timestamp
|
|
# on every line that this app makes no attempt to use, there's no game
|
|
# clock to compare it against, so it's simply ignored:
|
|
# ARRIVAL STATION:
|
|
# Valle de Mula MainStation: J6 0:4
|
|
# Estimated arrival: T=10:16:50
|
|
# TRACK ALIGNMENT:
|
|
# Rail line runs straight. Bearing 090° from MainStation.
|
|
# FINAL APPROACH:
|
|
# Waypoint A - 6.00km from station: T=10:06:50
|
|
# Waypoint B - 4.00km from station: T=10:10:10
|
|
# The station and each waypoint all come out RP-shaped (kind='rp'), same
|
|
# as parse_intel_blocks()'s entries, so app.py can merge them the exact
|
|
# same way, no separate code path needed on that side.
|
|
_ARRIVAL_STATION_KEYWORD = "ARRIVAL STATION"
|
|
_TRACK_ALIGNMENT_KEYWORD = "TRACK ALIGNMENT"
|
|
_FINAL_APPROACH_KEYWORD = "FINAL APPROACH"
|
|
|
|
_STATION_LINE_RE = re.compile(rf"(\S+):\s*{_COORD_FRAGMENT}")
|
|
_WAYPOINT_RE = re.compile(r"Waypoint\s+(\S+)\s*-\s*([\d.]+)\s*k?m\s+from\s+(\S+)", re.IGNORECASE)
|
|
|
|
|
|
def _section(text: str, start_keyword: str, end_keywords: tuple[str, ...]) -> str | None:
|
|
"""Text between a fuzzy match of `start_keyword` and whichever comes
|
|
first: a block-end '.' line, one of `end_keywords`, or the end of the
|
|
text. None if `start_keyword` isn't found at all. Scopes each
|
|
train-intel sub-parser to just its own section, so e.g. a
|
|
coincidental ':' + coord shape elsewhere in the text can't be
|
|
mistaken for the arrival station line."""
|
|
lines = text.splitlines()
|
|
start = next((i + 1 for i, line in enumerate(lines) if _fuzzy_contains(line.strip(), start_keyword)), None)
|
|
if start is None:
|
|
return None
|
|
collected = []
|
|
for line in lines[start:]:
|
|
stripped = line.strip()
|
|
if _BLOCK_END_RE.match(stripped) or any(_fuzzy_contains(stripped, k) for k in end_keywords):
|
|
break
|
|
collected.append(line)
|
|
return "\n".join(collected)
|
|
|
|
|
|
def parse_train_intel(text: str) -> list[dict]:
|
|
"""Parse a train-arrival intel block into RP-shaped entries: the
|
|
named station itself (absolute grid ref) plus one entry per waypoint,
|
|
each resolved via bearing (the rail's alignment) + distance (that
|
|
waypoint's distance along it) from the station, solve_location()'s
|
|
simplest case, one clue with both. Returns [] if this doesn't look
|
|
like a train intel block at all (no ARRIVAL STATION section)."""
|
|
station_block = _section(text, _ARRIVAL_STATION_KEYWORD, (_TRACK_ALIGNMENT_KEYWORD, _FINAL_APPROACH_KEYWORD))
|
|
if station_block is None:
|
|
return []
|
|
station_m = _STATION_LINE_RE.search(station_block)
|
|
if station_m is None:
|
|
return []
|
|
station_name, letter, y, x, yy = station_m.groups()
|
|
station_coord = _coord_from_groups(letter, y, x, yy)
|
|
if station_coord is None:
|
|
return []
|
|
|
|
entries = [{
|
|
"kind": "rp", "name": station_name, "type_word": None, "id": None,
|
|
"raw": station_block.strip(), "clues": [], "coord": station_coord,
|
|
}]
|
|
|
|
track_block = _section(text, _TRACK_ALIGNMENT_KEYWORD, (_FINAL_APPROACH_KEYWORD,))
|
|
track_clues = _parse_all_clues(track_block) if track_block else []
|
|
bearing = next((c.bearing_deg for c in track_clues if c.bearing_deg is not None), None)
|
|
if bearing is None:
|
|
return entries # the station alone is still useful even without the rail's bearing
|
|
|
|
approach_block = _section(text, _FINAL_APPROACH_KEYWORD, ())
|
|
if approach_block is None:
|
|
return entries
|
|
|
|
for m in _WAYPOINT_RE.finditer(approach_block):
|
|
letter_id, distance, ref_word = m.groups()
|
|
ref = station_name if ref_word.strip(".:").lower() == "station" else _clean_reference(ref_word)
|
|
entries.append({
|
|
"kind": "rp", "name": f"Waypoint {letter_id}", "type_word": None, "id": None,
|
|
"raw": m.group(0).strip(),
|
|
"clues": [Clue(reference=ref, bearing_deg=bearing, distance_km=float(_fix_digits(distance)))],
|
|
"coord": None,
|
|
})
|
|
return entries
|
|
|
|
|
|
# Destruction reports are standalone one-liners, not tied to a block:
|
|
# "SupplyCache#2 Destroyed. Additional Requisition Granted."
|
|
# "Direct Hit! HostileTank#3 Destroyed."
|
|
# Just need "<Type>#<id>" immediately followed by "Destroyed", the
|
|
# "Direct Hit!" prefix (or its absence) doesn't matter, search() finds
|
|
# the name+Destroyed pair anywhere in the line either way.
|
|
_DESTROYED_RE = re.compile(rf"{_TYPE_ID_FRAGMENT}\s*Destroyed", re.IGNORECASE)
|
|
# Same digit-vs-letter-id split as the header regexes: an ad-hoc "Enemy
|
|
# X Y" installation's id (after squash_enemy_names()) is letters, not
|
|
# digits, so it needs its own pattern, _DESTROYED_RE's digit class won't
|
|
# match it at all ("Enemy#SignalStation Destroyed" was silently dropped).
|
|
_ENEMY_DESTROYED_RE = re.compile(r"(Enemy)#([A-Za-z]+)\s*Destroyed", re.IGNORECASE)
|
|
|
|
|
|
def parse_destroyed(text: str) -> set[tuple[TargetType, str]]:
|
|
destroyed = set()
|
|
for m in _DESTROYED_RE.finditer(text):
|
|
type_word, num = m.groups()
|
|
target_type, _is_ally = _resolve_target_type(type_word)
|
|
if target_type is None:
|
|
continue
|
|
destroyed.add((target_type, _fix_id_digits(num)))
|
|
for m in _ENEMY_DESTROYED_RE.finditer(text):
|
|
type_word, letter_id = m.groups()
|
|
target_type, _is_ally = _resolve_target_type(type_word)
|
|
if target_type is None:
|
|
continue
|
|
destroyed.add((target_type, letter_id))
|
|
return destroyed
|
|
|
|
|
|
_BOLD_SPAN_RE = re.compile(r"<b>(.*?)</b>", re.IGNORECASE | re.DOTALL)
|
|
# Same two shapes squash_enemy_names()/squash_multiword_ids() look for,
|
|
# but anchored to fullmatch just one bold span's own content rather than
|
|
# searched for across the whole document. That's strictly safer: those
|
|
# two functions have to *guess* where a multi-word name ends using
|
|
# capitalization (and got that wrong once already, swallowing 'from' into
|
|
# a name it bordered), where here the closing </b> tag itself is the
|
|
# actual boundary, nothing to guess.
|
|
_ENEMY_SPAN_RE = re.compile(r"Enemy(?:\s+[A-Za-z]+){1,4}$", re.IGNORECASE)
|
|
_MULTIWORD_ID_SPAN_RE = re.compile(r"([A-Za-z]+(?:\s+[A-Za-z]+){1,3})\s*#\s*(\d+)$")
|
|
|
|
|
|
def _squash_span_content(content: str) -> str:
|
|
if _ENEMY_SPAN_RE.match(content):
|
|
words = content.split()
|
|
return "Enemy#" + "".join(words[1:])
|
|
multiword_id = _MULTIWORD_ID_SPAN_RE.match(content)
|
|
if multiword_id:
|
|
return "".join(multiword_id.group(1).split()) + "#" + multiword_id.group(2)
|
|
words = content.split()
|
|
if len(words) > 1 and not any(re.search(r"\d", w) for w in words):
|
|
# Neither shape above, but still a multi-word span with no digits
|
|
# in it, a plain multi-word name ('The Mole', "Dockmaster's
|
|
# House"): still one logical name, collapse it into one
|
|
# alphanumeric token the same way every reference elsewhere is
|
|
# matched (_RP_HEADER_RE, the (\S+) clue-reference capture),
|
|
# stripping punctuation too (apostrophes etc.), those aren't in
|
|
# _RP_HEADER_RE's word-character class and would otherwise
|
|
# truncate the match. The no-digits guard matters: a multi-word
|
|
# bold span can just as easily be a coordinate ('C9 7:9'), and
|
|
# squashing THAT the same way corrupts it instead of a name.
|
|
return "".join(re.sub(r"[^A-Za-z0-9]", "", w) for w in words)
|
|
return content
|
|
|
|
|
|
def squash_bold_spans(text: str) -> str:
|
|
"""Rich-text pastes wrap each atomic value/name in its own
|
|
'<b>...</b>' ('<b>Enemy Signal Station</b>', '<b>Listening
|
|
Post#1</b>'), collapse a multi-word one down to our single-token
|
|
'Type#id' shape using that tag boundary as ground truth, before the
|
|
tags themselves get stripped. Must run before strip_html(), it needs
|
|
the tags still there to know a span's extent. A no-op on plain OCR
|
|
text, which never has '<b>' in it to begin with, squash_enemy_names()/
|
|
squash_multiword_ids() (run afterward regardless) are what handle
|
|
that case, guessing from capitalization since there's no markup left
|
|
to lean on."""
|
|
return _BOLD_SPAN_RE.sub(lambda m: "<b>" + _squash_span_content(m.group(1)) + "</b>", text)
|
|
|
|
|
|
_HTML_BREAK_RE = re.compile(r"(?i)<br\s*/?>|</(?:p|div|li|tr)>")
|
|
_HTML_TAG_RE = re.compile(r"<[^>]+>")
|
|
|
|
|
|
def strip_html(text: str) -> str:
|
|
"""Drop markup from rich-text clipboard pastes (e.g. '<b>145</b>°'),
|
|
leaving plain text the rest of the pipeline can parse. Block-ish
|
|
closing tags become newlines first so pastes that rely on markup
|
|
rather than real line breaks don't get run together; harmless no-op
|
|
on plain OCR text with no '<' in it."""
|
|
text = _HTML_BREAK_RE.sub("\n", text)
|
|
text = _HTML_TAG_RE.sub("", text)
|
|
return html.unescape(text)
|
|
|
|
|
|
@dataclass
|
|
class ParsedInfo:
|
|
nest_coord: Coord | None = None
|
|
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, 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)
|
|
# Same shape as targets, for a 'Friendly'-prefixed type word (see
|
|
# _resolve_target_type()), a separate collection entirely, not a
|
|
# flag on a target, an ally's id doesn't share a namespace with a
|
|
# same-typed hostile target and it's never fired on.
|
|
allies: dict[
|
|
tuple[TargetType, str], tuple[str, list[Clue], Coord | None]
|
|
] = field(default_factory=dict)
|
|
# (type, id) of targets reported destroyed
|
|
destroyed: set[tuple[TargetType, str]] = field(default_factory=set)
|
|
|
|
def is_empty(self) -> bool:
|
|
return not (self.nest_coord or self.spotters or self.reference_points
|
|
or self.targets or self.allies or self.destroyed)
|
|
|
|
|
|
def parse_text(text: str) -> ParsedInfo:
|
|
text = strip_html(squash_bold_spans(text))
|
|
text = squash_enemy_names(squash_multiword_ids(text))
|
|
info = ParsedInfo()
|
|
|
|
for raw_line in text.splitlines():
|
|
line = raw_line.strip()
|
|
if not line:
|
|
continue
|
|
|
|
if info.nest_coord is None and _fuzzy_contains(line, NEST_KEYWORD):
|
|
coord = _extract_coord(line)
|
|
if coord is not None:
|
|
info.nest_coord = coord
|
|
continue
|
|
|
|
spotter_span = _fuzzy_locate(line, SPOTTER_KEYWORD)
|
|
if spotter_span is not None:
|
|
spotter_id = _extract_leading_id(line[spotter_span[1]:])
|
|
coord = _extract_coord(line)
|
|
if spotter_id is not None and coord is not None:
|
|
info.spotters[spotter_id] = coord
|
|
|
|
# The game's opening calibration order gives the target as plain
|
|
# prose ("Target is at- Q4 4:2") rather than the usual "Target#N:"
|
|
# block shape, there's only ever one of these, so it's stored under
|
|
# a fixed id ("1") rather than one parsed from the text.
|
|
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, None, None)
|
|
continue
|
|
|
|
for entry in parse_intel_blocks(text):
|
|
if entry["kind"] == "rp":
|
|
info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"])
|
|
continue
|
|
target_type, is_ally = _resolve_target_type(entry["type_word"])
|
|
if target_type is None:
|
|
continue
|
|
if is_ally:
|
|
info.allies[(target_type, entry["id"])] = (entry["raw"], entry["clues"], entry["coord"])
|
|
else:
|
|
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"])
|
|
|
|
for entry in parse_named_at_coord(text):
|
|
info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"])
|
|
|
|
for target_key, (raw, coord) in parse_fo_reports(text).items():
|
|
key = (TargetType.UNKNOWN, target_key)
|
|
if key in info.targets:
|
|
old_raw, old_clues, old_coord, shell, requested_time = info.targets[key]
|
|
info.targets[key] = (
|
|
f"{old_raw}\n{raw}", old_clues, old_coord if old_coord is not None else coord,
|
|
shell, requested_time,
|
|
)
|
|
else:
|
|
info.targets[key] = (raw, [], coord, None, None)
|
|
|
|
info.destroyed = parse_destroyed(text)
|
|
return info
|
|
|
|
|
|
def run(image: Image.Image) -> ParsedInfo:
|
|
return parse_text(ocr_text(image))
|