diff --git a/src/fenigma/ocr.py b/src/fenigma/ocr.py index ab9e1bd..0cfabf4 100644 --- a/src/fenigma/ocr.py +++ b/src/fenigma/ocr.py @@ -388,6 +388,98 @@ def parse_intel_blocks(text: str) -> list[dict]: 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*([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})" +) +_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() + try: + station_coord = Coord(X=letter.upper(), Y=int(_fix_digits(y)), x=int(_fix_digits(x)), y=int(_fix_digits(yy))) + except ValueError: + 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." @@ -466,6 +558,9 @@ def parse_text(text: str) -> ParsedInfo: continue info.targets[(target_type, entry["id"])] = (entry["raw"], entry["clues"], entry["coord"]) + for entry in parse_train_intel(text): + info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"]) + info.destroyed = parse_destroyed(text) return info