OCR: 'ref: value' clue grammar, 16-point compass, grid-only coords, wedge overlay
New shapes, all genuinely new features (checked full git history, none
of this ever existed before):
- '<ref>: <value>' clue grammar (Spotter#2: 4.04km / Spotter#3: 298deg
/ Spotter#1: West), no keyword, no 'from', reference comes first.
This collided hard with the existing 'Type#id:' header shape,
'Spotter#2: 4.04km' is structurally identical to a real header like
'AmmoCache#3:', so it was hijacking the block before any clues could
attach to the entity above it. Fixed by checking whether a would-be
header's trailing content is itself just a bare clue reading with
nothing else (_BARE_CLUE_VALUE_RE); a real header's never is.
- 16-point compass words ('North Northwest'), alongside the existing
8-point ones, longest-alternative-first in the regex so the compound
form doesn't get cut off at the bare first word.
- A compass word names a whole sector, not a single ray, so a clue
built from one now carries a bearing_tolerance_deg (11.25deg, half a
16-point sector) and solve_location() deliberately never tries to
triangulate it into an exact point, precise math on an imprecise
reading would misrepresent the confidence. The map draws it as a
wedge (two bounding rays + fill) instead of a single ray.
- 'Reported active in grid D10': large-grid-cell-only, no sub-grid x:y
at all, defaults to the cell's rough middle (5:5).
Verified against the exact reported example end to end (parse ->
solver correctly resolving what it can and leaving the rest
unresolved -> map draw with the wedge overlay) plus the full existing
regression sweep across every previously-added format.
This commit is contained in:
parent
90a26b3f85
commit
9c7588eb12
@ -566,6 +566,30 @@ class GridCanvas(Gtk.DrawingArea):
|
|||||||
cr.set_source_rgb(*YELLOW)
|
cr.set_source_rgb(*YELLOW)
|
||||||
cr.set_line_width(2)
|
cr.set_line_width(2)
|
||||||
self._draw_arrow(cr, rx, ry, tx, ty)
|
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:
|
elif clue.bearing_deg is not None:
|
||||||
far_km = solver.point_from_bearing_distance(ref_km, clue.bearing_deg, OVERLAY_RAY_LENGTH_KM)
|
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)
|
fx, fy = self._km_to_px(far_km, cell_w, cell_h, grid_h)
|
||||||
|
|||||||
@ -116,17 +116,29 @@ class Clue:
|
|||||||
named entity (e.g. 'Spotter#1', 'Alpha', 'AmmoCache#2'). At least one
|
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
|
of bearing/distance is set; a single clue with both fully determines a
|
||||||
position given the reference, two clues (from different references)
|
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
|
reference: str
|
||||||
bearing_deg: float | None = None
|
bearing_deg: float | None = None
|
||||||
distance_km: float | None = None
|
distance_km: float | None = None
|
||||||
|
bearing_tolerance_deg: float | None = None
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"reference": self.reference,
|
"reference": self.reference,
|
||||||
"bearing_deg": self.bearing_deg,
|
"bearing_deg": self.bearing_deg,
|
||||||
"distance_km": self.distance_km,
|
"distance_km": self.distance_km,
|
||||||
|
"bearing_tolerance_deg": self.bearing_tolerance_deg,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -135,6 +147,7 @@ class Clue:
|
|||||||
reference=d["reference"],
|
reference=d["reference"],
|
||||||
bearing_deg=d.get("bearing_deg"),
|
bearing_deg=d.get("bearing_deg"),
|
||||||
distance_km=d.get("distance_km"),
|
distance_km=d.get("distance_km"),
|
||||||
|
bearing_tolerance_deg=d.get("bearing_tolerance_deg"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -195,6 +195,26 @@ def _extract_requested_time(text: str) -> str | None:
|
|||||||
return m.group(1) if m else 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:
|
def _extract_coord(line: str) -> Coord | None:
|
||||||
m = _COORD_RE.search(line)
|
m = _COORD_RE.search(line)
|
||||||
if not m:
|
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 = {
|
_COMPASS_BEARINGS = {
|
||||||
"NORTH": 0.0, "NORTHEAST": 45.0, "EAST": 90.0, "SOUTHEAST": 135.0,
|
"NORTH": 0.0, "NORTHEAST": 45.0, "EAST": 90.0, "SOUTHEAST": 135.0,
|
||||||
"SOUTH": 180.0, "SOUTHWEST": 225.0, "WEST": 270.0, "NORTHWEST": 315.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(
|
_CLUE_DISTANCE_COMPASS_RE = re.compile(
|
||||||
rf"Distance\s*([\d.]+)\s*k?m?\s*{_COMPASS_WORD_RE}{_GAP}from\s+(\S+)", re.IGNORECASE
|
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)
|
_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}
|
_TYPE_BY_SHORT = {t.short: t for t in TargetType}
|
||||||
# The game's typewriter has used "AmmoCache" for what's now modeled as
|
# 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.
|
# 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
|
# 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
|
# text. `\s` already matches a literal newline, so this bridges an OCR
|
||||||
# line-wrap ("...Bearing 125°" / "from Spotter#1" split across two lines)
|
# 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_PATTERNS = (
|
||||||
(_CLUE_COMBINED_RE, lambda m: (float(m.group(1)), float(m.group(2)), m.group(3))),
|
(_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))),
|
(_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))),
|
(_CLUE_BEARING_RE, lambda m: (float(m.group(1)), None, m.group(2), None)),
|
||||||
(_CLUE_DISTANCE_COMPASS_RE, lambda m: (
|
(_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: '<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
|
||||||
)),
|
)),
|
||||||
(_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):
|
if overlaps(span):
|
||||||
continue
|
continue
|
||||||
consumed.append(span)
|
consumed.append(span)
|
||||||
bearing, distance, ref = extract(m)
|
bearing, distance, ref, tolerance = extract(m)
|
||||||
clues.append(Clue(reference=_clean_reference(ref), bearing_deg=bearing, distance_km=distance))
|
clues.append(Clue(reference=_clean_reference(ref), bearing_deg=bearing, distance_km=distance,
|
||||||
|
bearing_tolerance_deg=tolerance))
|
||||||
return clues
|
return clues
|
||||||
|
|
||||||
|
|
||||||
@ -575,7 +649,10 @@ 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) 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["shell"] = _extract_shell_request(joined)
|
||||||
current["requested_time"] = _extract_requested_time(joined)
|
current["requested_time"] = _extract_requested_time(joined)
|
||||||
if (current["clues"] or current["coord"] is not None
|
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)
|
named_m = next((m for c in candidates if (m := _NAMED_HEADER_RE.match(c))), None)
|
||||||
if named_m:
|
if named_m:
|
||||||
flush()
|
|
||||||
type_word, num, inline = named_m.groups()
|
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)
|
num = _fix_id_digits(num)
|
||||||
current = {"kind": "named", "name": f"{type_word}#{num}", "type_word": type_word,
|
current = {"kind": "named", "name": f"{type_word}#{num}", "type_word": type_word,
|
||||||
"id": num, "raw": [line], "clues": []}
|
"id": num, "raw": [line], "clues": []}
|
||||||
|
|||||||
@ -215,12 +215,22 @@ def solve_location(location: Location, board: Board) -> SolveResult:
|
|||||||
return SolveResult()
|
return SolveResult()
|
||||||
|
|
||||||
for clue, pt in resolved:
|
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:
|
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))
|
coord = point_to_coord(point_from_bearing_distance(pt, clue.bearing_deg, clue.distance_km))
|
||||||
if coord is not None:
|
if coord is not None:
|
||||||
return SolveResult(coord=coord)
|
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]
|
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:
|
if len(bearings) >= 2:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user