OCR: parse train-arrival intel into MainStation + waypoint RPs

A different intel shape entirely: a station's absolute grid ref, the
rail's bearing from it, and waypoints given only as a distance along
that same bearing. ocr.parse_train_intel() turns the station and each
waypoint into RP-shaped entries (bearing+distance-from-station is
solve_location()'s simplest case), merged through the exact same
_merge_reference_points() path as any other RP, no new UI or entity
type needed.

The T=HH:MM:SS timestamp on every line is deliberately never parsed,
there's no game clock to compare it against.

Verified end to end against the real screenshot: MainStation resolves
from its own grid ref, and all three waypoints resolve correctly along
the bearing at their reported distances.
This commit is contained in:
Dominik Moritz Roth 2026-08-09 11:57:43 +02:00
parent 980be86908
commit 82b3fca0a0

View File

@ -388,6 +388,98 @@ def parse_intel_blocks(text: str) -> list[dict]:
return entries 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: # Destruction reports are standalone one-liners, not tied to a block:
# "SupplyCache#2 Destroyed. Additional Requisition Granted." # "SupplyCache#2 Destroyed. Additional Requisition Granted."
# "Direct Hit! HostileTank#3 Destroyed." # "Direct Hit! HostileTank#3 Destroyed."
@ -466,6 +558,9 @@ def parse_text(text: str) -> ParsedInfo:
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"])
for entry in parse_train_intel(text):
info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"])
info.destroyed = parse_destroyed(text) info.destroyed = parse_destroyed(text)
return info return info