Drop TargetType.HOSTILE_*, track allies as their own Ally collection

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>
This commit is contained in:
Dominik Moritz Roth 2026-08-09 20:23:42 +02:00
parent d6694b585b
commit 3955fa42c7
7 changed files with 245 additions and 37 deletions

View File

@ -275,6 +275,7 @@ class MainWindow(Adw.ApplicationWindow):
("Spotters", self._build_spotters_popover), ("Spotters", self._build_spotters_popover),
("Reference Points", self._build_rp_popover), ("Reference Points", self._build_rp_popover),
("Targets", self._build_targets_popover), ("Targets", self._build_targets_popover),
("Allies", self._build_allies_popover),
("Scout Flights", self._build_scout_flights_popover), ("Scout Flights", self._build_scout_flights_popover),
): ):
header.pack_start(self._make_menu_button(label, popover_builder)) header.pack_start(self._make_menu_button(label, popover_builder))
@ -524,6 +525,7 @@ class MainWindow(Adw.ApplicationWindow):
changed.extend(self._merge_spotters(info, toast=False)) changed.extend(self._merge_spotters(info, toast=False))
changed.extend(self._merge_reference_points(info, toast=False)) changed.extend(self._merge_reference_points(info, toast=False))
changed.extend(self._merge_targets(info, toast=False)) changed.extend(self._merge_targets(info, toast=False))
changed.extend(self._merge_allies(info, toast=False))
if not changed: if not changed:
self.toast("No relevant info found in screenshot.") self.toast("No relevant info found in screenshot.")
@ -620,6 +622,26 @@ class MainWindow(Adw.ApplicationWindow):
self._refresh() self._refresh()
return changed return changed
def _merge_allies(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]:
changed = []
for (ally_type, ally_id), (raw, clues, coord) in info.allies.items():
existing = next(
(a for a in self.board.allies if a.type == ally_type and a.id == ally_id), None
)
if existing is not None:
self._merge_parsed_location(existing, raw, clues, coord)
else:
existing = self.board.add_ally(
ally_type, Location(coord=coord, desc_raw=raw, clues=clues), id_=ally_id
)
changed.append(existing.name)
if toast:
self.toast("No allies found in screenshot." if not changed
else "Loaded from screenshot: " + ", ".join(changed))
self._refresh()
return changed
def _open_coord_dialog( def _open_coord_dialog(
self, *, title, on_submit, show_id=False, show_type=False, id_placeholder=None, self, *, title, on_submit, show_id=False, show_type=False, id_placeholder=None,
initial_location=None, initial_id=None, initial_type=None, initial_location=None, initial_id=None, initial_type=None,
@ -863,6 +885,49 @@ class MainWindow(Adw.ApplicationWindow):
rebuild() rebuild()
self.toast(f"{target.name} converted to {new_rp.name}.") self.toast(f"{target.name} converted to {new_rp.name}.")
# -- Allies ---------------------------------------------------------------
def _build_allies_popover(self, rebuild) -> Gtk.Widget:
"""A friendly contact ('FriendlyTank#1:'), tracked entirely
separately from Targets (see models.py's Ally/Board.allies):
never fired on, so no shell/charge/assignment/alive controls
here, just position/visibility, same as a Reference Point."""
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
box.set_margin_top(6)
box.set_margin_bottom(6)
for a in list(self.board.allies):
box.append(_row(
f"{a.name} ({_location_status(a)})",
on_input=lambda a=a: self._open_coord_dialog(
title=f"Set {a.name} coordinates",
on_submit=lambda loc, _id, _t, a=a: self._apply_and_refresh(a, loc),
initial_location=a.location,
),
on_remove=lambda a=a: self._remove_ally(a, rebuild),
hidden=a.hidden,
on_toggle_hidden=lambda a=a: self._toggle_hidden(a, rebuild),
show_geo=a.show_geo_desc,
on_toggle_show_geo=lambda a=a: self._toggle_show_geo(a, rebuild),
))
box.append(Gtk.Separator())
box.append(_add_row("Add ally", lambda: self._open_coord_dialog(
title="Add ally",
on_submit=lambda loc, id_, type_: self._add_ally(loc, id_, type_),
show_id=True,
show_type=True,
)))
return box
def _add_ally(self, location: Location, id_, type_) -> None:
self.board.add_ally(type_ or TargetType.UNKNOWN, location, id_)
self._refresh()
def _remove_ally(self, ally, rebuild) -> None:
self.board.remove_ally(ally)
self._refresh()
rebuild()
# -- Scout Flights ------------------------------------------------------------ # -- Scout Flights ------------------------------------------------------------
def _build_scout_flights_popover(self, rebuild) -> Gtk.Widget: def _build_scout_flights_popover(self, rebuild) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)

View File

@ -51,6 +51,7 @@ CATEGORY_COLOR = {
"spotter": (0.35, 0.78, 0.40), "spotter": (0.35, 0.78, 0.40),
"rp": (0.95, 0.78, 0.20), "rp": (0.95, 0.78, 0.20),
"target": (0.92, 0.30, 0.28), "target": (0.92, 0.30, 0.28),
"ally": (0.30, 0.85, 0.85), # cyan, distinct from every other category's color
} }
SCOUT_FLIGHT = (0.70, 0.45, 0.92) SCOUT_FLIGHT = (0.70, 0.45, 0.92)

View File

@ -36,15 +36,23 @@ NATO_ALPHABET = [
class TargetType(Enum): class TargetType(Enum):
"""'HOSTILE_ARTILLERY'/'HOSTILE_TANK' used to bake an assumption of
hostility into the type name itself. A type name here doesn't imply
an allegiance any more, ARTILLERY/TANK/etc. describe the unit kind
only; a friendly instance of one of these ('FriendlyTank#1:', see
ocr.py's prefix stripping) is tracked as its own Ally, a separate
collection from Target (see Board.allies), not this same type with
a flag flipped, targets and allies don't share an id-namespace or a
firing-relevant shape (no shell/powder_charges/assignment)."""
UNKNOWN = "Target" # generic contact, spotted but not yet identified; default choice UNKNOWN = "Target" # generic contact, spotted but not yet identified; default choice
SUPPLY_CACHE = "Supply Cache" SUPPLY_CACHE = "Supply Cache"
FDC = "FDC" # Fire Direction Center, coordinates enemy counter-battery fire FDC = "FDC" # Fire Direction Center, coordinates counter-battery fire
INFANTRY = "Infantry" # hostile ground troops INFANTRY = "Infantry" # ground troops
MECHANIZED = "Mechanized" # hostile armored/vehicle unit MECHANIZED = "Mechanized" # armored/vehicle unit
HOSTILE_ARTILLERY = "Hostile Artillery" # "Coastal Battery" is just this, see _TYPE_WORD_ALIASES in ocr.py ARTILLERY = "Artillery" # "Coastal Battery" is just this, see _TYPE_WORD_ALIASES in ocr.py
HOSTILE_TANK = "Hostile Tank" TANK = "Tank"
PILLBOX = "Pillbox" # armoured emplacement, fixed position PILLBOX = "Pillbox" # armoured emplacement, fixed position
MARINE_GARRISON = "Marine Garrison" # allied unit, requests fire support (see Target.requested_time) MARINE_GARRISON = "Marine Garrison" # 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
@ -60,8 +68,16 @@ class TargetType(Enum):
# Renamed/removed enum members, for loading save files written before the # Renamed/removed enum members, for loading save files written before the
# rename. AMMO_CACHE turned out to be a misreading of the game's actual # rename. AMMO_CACHE turned out to be a misreading of the game's actual
# "SupplyCache" name and was dropped in favor of it. COASTAL_BATTERY # "SupplyCache" name and was dropped in favor of it. COASTAL_BATTERY
# turned out to just be a HOSTILE_ARTILLERY under a different name. # turned out to just be an ARTILLERY under a different name.
_TARGET_TYPE_MIGRATIONS = {"AMMO_CACHE": "SUPPLY_CACHE", "COASTAL_BATTERY": "HOSTILE_ARTILLERY"} # HOSTILE_ARTILLERY/HOSTILE_TANK dropped their baked-in 'HOSTILE_'
# prefix, allegiance isn't part of the type any more (see TargetType's
# own docstring and Board.allies).
_TARGET_TYPE_MIGRATIONS = {
"AMMO_CACHE": "SUPPLY_CACHE",
"COASTAL_BATTERY": "ARTILLERY",
"HOSTILE_ARTILLERY": "ARTILLERY",
"HOSTILE_TANK": "TANK",
}
def _migrate_target_type(name: str) -> TargetType: def _migrate_target_type(name: str) -> TargetType:
@ -366,6 +382,36 @@ class Target:
self.location.note = None self.location.note = None
@dataclass(eq=False) # identity equality/hash, these are mutable, used as dict keys/set members
class Ally:
"""A friendly contact ('FriendlyTank#1:', see ocr.py's prefix
stripping in _resolve_target_type()), tracked entirely separately
from Target: it's never fired on, so none of Target's firing-
relevant fields (shell/powder_charges/assignment) apply, and its id
doesn't share a namespace with a same-typed hostile Target, an
ally Tank#1 and a hostile Tank#1 are two different, unrelated
things that happen to have the same id."""
type: TargetType
id: str
location: Location = field(default_factory=Location)
hidden: bool = False
show_geo_desc: bool = False
@property
def name(self) -> str:
return f"{self.type.short}#{self.id}"
@property
def coord(self) -> Coord | None:
return self.location.coord
@coord.setter
def coord(self, value: Coord | None) -> None:
# See Target.coord's setter, same reasoning.
self.location.coord = value
self.location.note = None
@dataclass(eq=False) # identity equality/hash, these are mutable, used as dict keys/set members @dataclass(eq=False) # identity equality/hash, these are mutable, used as dict keys/set members
class ScoutFlight: class ScoutFlight:
"""A planned scout overflight: a rectangle anchored on a large grid """A planned scout overflight: a rectangle anchored on a large grid
@ -397,6 +443,7 @@ class Board:
self.spotters: list[Spotter] = [] self.spotters: list[Spotter] = []
self.reference_points: list[ReferencePoint] = [] self.reference_points: list[ReferencePoint] = []
self.targets: list[Target] = [] self.targets: list[Target] = []
self.allies: list[Ally] = []
self.scout_flights: list[ScoutFlight] = [] self.scout_flights: list[ScoutFlight] = []
self._spotter_seq = 0 self._spotter_seq = 0
self._scout_flight_seq = 0 self._scout_flight_seq = 0
@ -414,6 +461,9 @@ class Board:
for t in self.targets: for t in self.targets:
if t.name == name: if t.name == name:
return t return t
for a in self.allies:
if a.name == name:
return a
return None return None
# -- spotters -------------------------------------------------------- # -- spotters --------------------------------------------------------
@ -462,6 +512,27 @@ class Board:
def remove_target(self, target: Target) -> None: def remove_target(self, target: Target) -> None:
self.targets.remove(target) self.targets.remove(target)
# -- allies --------------------------------------------------------------
def add_ally(
self,
type_: TargetType,
location: Location | Coord | None = None,
id_: str | None = None,
) -> Ally:
# A separate id namespace from add_target()'s: an ally Tank#1
# and a hostile Target Tank#1 are unrelated, so auto-assignment
# here only looks at other allies of the same type, never
# self.targets.
if not id_:
used = {a.id for a in self.allies if a.type == type_}
id_ = next(c for c in string.ascii_uppercase if c not in used)
a = Ally(type=type_, id=id_, location=_as_location(location))
self.allies.append(a)
return a
def remove_ally(self, ally: Ally) -> None:
self.allies.remove(ally)
# -- scout flights -------------------------------------------------- # -- scout flights --------------------------------------------------
def next_scout_flight_id(self) -> int: def next_scout_flight_id(self) -> int:
return self._scout_flight_seq + 1 return self._scout_flight_seq + 1
@ -526,6 +597,9 @@ class Board:
for t in self.targets: for t in self.targets:
if t.coord is not None: if t.coord is not None:
yield "target", t yield "target", t
for a in self.allies:
if a.coord is not None:
yield "ally", a
def ambiguous_entities(self): def ambiguous_entities(self):
"""Yield (category, obj) for RPs/Targets that resolved to two or """Yield (category, obj) for RPs/Targets that resolved to two or
@ -543,6 +617,9 @@ class Board:
for t in self.targets: for t in self.targets:
if t.coord is None and t.location.potential_coords: if t.coord is None and t.location.potential_coords:
yield "target", t yield "target", t
for a in self.allies:
if a.coord is None and a.location.potential_coords:
yield "ally", a
# -- save / load --------------------------------------------------------- # -- save / load ---------------------------------------------------------
def to_dict(self) -> dict: def to_dict(self) -> dict:
@ -586,6 +663,16 @@ class Board:
} }
for t in self.targets for t in self.targets
], ],
"allies": [
{
"type": a.type.name,
"id": a.id,
"location": a.location.to_dict(),
"hidden": a.hidden,
"show_geo_desc": a.show_geo_desc,
}
for a in self.allies
],
"scout_flights": [ "scout_flights": [
{ {
"id": sf.id, "id": sf.id,
@ -644,6 +731,17 @@ class Board:
for t in data.get("targets", []) for t in data.get("targets", [])
] ]
self.allies = [
Ally(
type=_migrate_target_type(a["type"]),
id=a["id"],
location=Location.from_dict(a.get("location")),
hidden=a.get("hidden", False),
show_geo_desc=a.get("show_geo_desc", False),
)
for a in data.get("allies", [])
]
self.scout_flights = [ self.scout_flights = [
ScoutFlight( ScoutFlight(
id=sf["id"], id=sf["id"],

View File

@ -606,18 +606,32 @@ def parse_clues_from_text(text: str) -> list[Clue]:
return _parse_all_clues(squash_enemy_names(squash_multiword_ids(text))) return _parse_all_clues(squash_enemy_names(squash_multiword_ids(text)))
def _resolve_target_type(type_word: str) -> TargetType | None: _ALLY_PREFIX_RE = re.compile(r"^(Friendly|Hostile)", re.IGNORECASE)
"""Exact match on the type word (after aliasing), falling back to
fuzzy (OCR can garble the type word itself, e.g. 'AmmoCoche')."""
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) type_word = _TYPE_WORD_ALIASES.get(type_word, type_word)
if type_word in _TYPE_BY_SHORT: if type_word in _TYPE_BY_SHORT:
return _TYPE_BY_SHORT[type_word] return _TYPE_BY_SHORT[type_word], is_ally
best, best_ratio = None, 0.0 best, best_ratio = None, 0.0
for short, target_type in _TYPE_BY_SHORT.items(): for short, target_type in _TYPE_BY_SHORT.items():
ratio = difflib.SequenceMatcher(None, type_word.upper(), short.upper()).ratio() ratio = difflib.SequenceMatcher(None, type_word.upper(), short.upper()).ratio()
if ratio > best_ratio: if ratio > best_ratio:
best, best_ratio = target_type, ratio best, best_ratio = target_type, ratio
return best if best_ratio >= _FUZZY_THRESHOLD else None return (best if best_ratio >= _FUZZY_THRESHOLD else None), is_ally
def parse_intel_blocks(text: str) -> list[dict]: def parse_intel_blocks(text: str) -> list[dict]:
@ -851,13 +865,13 @@ def parse_destroyed(text: str) -> set[tuple[TargetType, str]]:
destroyed = set() destroyed = set()
for m in _DESTROYED_RE.finditer(text): for m in _DESTROYED_RE.finditer(text):
type_word, num = m.groups() type_word, num = m.groups()
target_type = _resolve_target_type(type_word) target_type, _is_ally = _resolve_target_type(type_word)
if target_type is None: if target_type is None:
continue continue
destroyed.add((target_type, _fix_id_digits(num))) destroyed.add((target_type, _fix_id_digits(num)))
for m in _ENEMY_DESTROYED_RE.finditer(text): for m in _ENEMY_DESTROYED_RE.finditer(text):
type_word, letter_id = m.groups() type_word, letter_id = m.groups()
target_type = _resolve_target_type(type_word) target_type, _is_ally = _resolve_target_type(type_word)
if target_type is None: if target_type is None:
continue continue
destroyed.add((target_type, letter_id)) destroyed.add((target_type, letter_id))
@ -940,12 +954,19 @@ class ParsedInfo:
targets: dict[ targets: dict[
tuple[TargetType, str], tuple[str, list[Clue], Coord | None, Shell | None, str | None] tuple[TargetType, str], tuple[str, list[Clue], Coord | None, Shell | None, str | None]
] = field(default_factory=dict) ] = 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 # (type, id) of targets reported destroyed
destroyed: set[tuple[TargetType, str]] = field(default_factory=set) destroyed: set[tuple[TargetType, str]] = field(default_factory=set)
def is_empty(self) -> bool: def is_empty(self) -> bool:
return not (self.nest_coord or self.spotters or self.reference_points return not (self.nest_coord or self.spotters or self.reference_points
or self.targets or self.destroyed) or self.targets or self.allies or self.destroyed)
def parse_text(text: str) -> ParsedInfo: def parse_text(text: str) -> ParsedInfo:
@ -985,9 +1006,12 @@ def parse_text(text: str) -> ParsedInfo:
if entry["kind"] == "rp": if entry["kind"] == "rp":
info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"]) info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"])
continue continue
target_type = _resolve_target_type(entry["type_word"]) target_type, is_ally = _resolve_target_type(entry["type_word"])
if target_type is None: if target_type is None:
continue continue
if is_ally:
info.allies[(target_type, entry["id"])] = (entry["raw"], entry["clues"], entry["coord"])
else:
info.targets[(target_type, entry["id"])] = ( info.targets[(target_type, entry["id"])] = (
entry["raw"], entry["clues"], entry["coord"], entry["shell"], entry["requested_time"] entry["raw"], entry["clues"], entry["coord"], entry["shell"], entry["requested_time"]
) )

View File

@ -326,17 +326,18 @@ def explain_unresolved(location: Location, board: Board) -> str | None:
def resolve_board(board: Board) -> list[str]: def resolve_board(board: Board) -> list[str]:
"""Resolve every not-yet-resolved RP/Target whose clues can currently """Resolve every not-yet-resolved RP/Target/Ally whose clues can
be satisfied, repeating until a fixed point (handles dependency currently be satisfied, repeating until a fixed point (handles
chains like AmmoCache#3 -> AmmoCache#2 -> Alpha/Spotters). Ambiguous dependency chains like AmmoCache#3 -> AmmoCache#2 -> Alpha/
results are recorded as `potential_coords` and never feed further Spotters). Ambiguous results are recorded as `potential_coords` and
resolution. Returns the names of everything newly *resolved* (not never feed further resolution. Returns the names of everything
counting ones that only became ambiguous) this call.""" newly *resolved* (not counting ones that only became ambiguous)
this call."""
newly_resolved: list[str] = [] newly_resolved: list[str] = []
changed = True changed = True
while changed: while changed:
changed = False changed = False
for entities in (board.reference_points, board.targets): for entities in (board.reference_points, board.targets, board.allies):
for obj in entities: for obj in entities:
if obj.coord is not None or obj.location.potential_coords: if obj.coord is not None or obj.location.potential_coords:
continue # already resolved, or stuck ambiguous, don't reprocess continue # already resolved, or stuck ambiguous, don't reprocess

View File

@ -48,7 +48,7 @@ def test_calibration_target_line():
def test_destroyed_reports_digit_and_letter_id(): def test_destroyed_reports_digit_and_letter_id():
text = "SupplyCache#2 Destroyed. Additional Requisition Granted.\nDirect Hit! HostileTank#3 Destroyed." text = "SupplyCache#2 Destroyed. Additional Requisition Granted.\nDirect Hit! HostileTank#3 Destroyed."
info = ocr.parse_text(text) info = ocr.parse_text(text)
assert info.destroyed == {(TargetType.SUPPLY_CACHE, "2"), (TargetType.HOSTILE_TANK, "3")} assert info.destroyed == {(TargetType.SUPPLY_CACHE, "2"), (TargetType.TANK, "3")}
def test_train_arrival_intel(): def test_train_arrival_intel():
@ -103,8 +103,8 @@ Coastal Battery#2:
""" """
info = ocr.parse_text(text) info = ocr.parse_text(text)
assert info.reference_points["ListeningPost#1"][2] == Coord("K", 6, 7, 8) assert info.reference_points["ListeningPost#1"][2] == Coord("K", 6, 7, 8)
assert (TargetType.HOSTILE_ARTILLERY, "2") in info.targets assert (TargetType.ARTILLERY, "2") in info.targets
_, clues, *_ = info.targets[(TargetType.HOSTILE_ARTILLERY, "2")] _, clues, *_ = info.targets[(TargetType.ARTILLERY, "2")]
assert clues == [ocr.Clue(reference="ListeningPost#1", bearing_deg=135.0, distance_km=6.28)] assert clues == [ocr.Clue(reference="ListeningPost#1", bearing_deg=135.0, distance_km=6.28)]
@ -217,3 +217,22 @@ def test_grid_only_coord_no_sub_position():
info = ocr.parse_text(text) info = ocr.parse_text(text)
_, clues, coord, *_ = info.targets[(TargetType.ENEMY, "SignalStation")] _, clues, coord, *_ = info.targets[(TargetType.ENEMY, "SignalStation")]
assert coord == Coord("D", 10, 5, 5) assert coord == Coord("D", 10, 5, 5)
def test_friendly_prefix_routes_to_allies_hostile_and_bare_stay_targets():
"""A 'Friendly' prefix routes a parsed entry into info.allies
entirely, a separate collection from info.targets, not a flag
alongside it, an ally and a same-typed hostile target don't share
an id namespace. 'Hostile' and no prefix at all both mean a regular
(non-ally) Target."""
text = """FriendlyTank#1 Spotted. 088, 12.10km from Spotter#1
.
HostileTank#2 Spotted. 090, 5.00km from Spotter#1
.
Tank#3 Spotted. 095, 3.00km from Spotter#1
"""
info = ocr.parse_text(text)
assert (TargetType.TANK, "1") in info.allies
assert (TargetType.TANK, "1") not in info.targets
assert (TargetType.TANK, "2") in info.targets
assert (TargetType.TANK, "3") in info.targets

View File

@ -12,7 +12,7 @@ def _board_with_spotters(*coords):
def test_bearing_and_distance_from_one_reference_resolves_directly(): def test_bearing_and_distance_from_one_reference_resolves_directly():
board = _board_with_spotters(Coord("J", 5, 0, 0)) board = _board_with_spotters(Coord("J", 5, 0, 0))
target = board.add_target(TargetType.HOSTILE_TANK, id_="1") target = board.add_target(TargetType.TANK, id_="1")
target.location = Location.from_desc("x", [Clue(reference="Spotter#1", bearing_deg=90.0, distance_km=3.0)]) target.location = Location.from_desc("x", [Clue(reference="Spotter#1", bearing_deg=90.0, distance_km=3.0)])
solver.resolve_board(board) solver.resolve_board(board)
assert target.coord is not None assert target.coord is not None
@ -20,7 +20,7 @@ def test_bearing_and_distance_from_one_reference_resolves_directly():
def test_two_bearings_resolve_via_ray_ray_intersection(): def test_two_bearings_resolve_via_ray_ray_intersection():
board = _board_with_spotters(Coord("J", 5, 0, 0), Coord("L", 4, 3, 0)) board = _board_with_spotters(Coord("J", 5, 0, 0), Coord("L", 4, 3, 0))
target = board.add_target(TargetType.HOSTILE_TANK, id_="1") target = board.add_target(TargetType.TANK, id_="1")
target.location = Location.from_desc("x", [ target.location = Location.from_desc("x", [
Clue(reference="Spotter#1", bearing_deg=90.0), Clue(reference="Spotter#1", bearing_deg=90.0),
Clue(reference="Spotter#2", bearing_deg=180.0), Clue(reference="Spotter#2", bearing_deg=180.0),
@ -31,7 +31,7 @@ def test_two_bearings_resolve_via_ray_ray_intersection():
def test_two_distances_that_actually_cross_are_ambiguous_not_resolved(): def test_two_distances_that_actually_cross_are_ambiguous_not_resolved():
board = _board_with_spotters(Coord("J", 5, 0, 0), Coord("P", 5, 0, 0)) board = _board_with_spotters(Coord("J", 5, 0, 0), Coord("P", 5, 0, 0))
target = board.add_target(TargetType.HOSTILE_TANK, id_="1") target = board.add_target(TargetType.TANK, id_="1")
target.location = Location.from_desc("x", [ target.location = Location.from_desc("x", [
Clue(reference="Spotter#1", distance_km=7.33), Clue(reference="Spotter#1", distance_km=7.33),
Clue(reference="Spotter#2", distance_km=3.43), Clue(reference="Spotter#2", distance_km=3.43),
@ -48,7 +48,7 @@ def test_nested_distance_circles_fall_back_to_compromise_point():
stand-in instead, flagged via Location.note rather than treated as stand-in instead, flagged via Location.note rather than treated as
a clean resolution.""" a clean resolution."""
board = _board_with_spotters(Coord("J", 5, 0, 0), Coord("J", 5, 1, 0)) board = _board_with_spotters(Coord("J", 5, 0, 0), Coord("J", 5, 1, 0))
target = board.add_target(TargetType.HOSTILE_TANK, id_="1") target = board.add_target(TargetType.TANK, id_="1")
target.location = Location.from_desc("x", [ target.location = Location.from_desc("x", [
Clue(reference="Spotter#1", distance_km=7.33), Clue(reference="Spotter#1", distance_km=7.33),
Clue(reference="Spotter#2", distance_km=3.43), Clue(reference="Spotter#2", distance_km=3.43),
@ -64,7 +64,7 @@ def test_toleranced_bearing_is_never_used_to_triangulate():
sector, solve_location() must never use it as if it were a precise sector, solve_location() must never use it as if it were a precise
ray, even when it's the only bearing-shaped clue available.""" ray, even when it's the only bearing-shaped clue available."""
board = _board_with_spotters(Coord("J", 5, 0, 0), Coord("L", 4, 3, 0)) board = _board_with_spotters(Coord("J", 5, 0, 0), Coord("L", 4, 3, 0))
target = board.add_target(TargetType.HOSTILE_TANK, id_="1") target = board.add_target(TargetType.TANK, id_="1")
target.location = Location.from_desc("x", [ target.location = Location.from_desc("x", [
Clue(reference="Spotter#1", distance_km=3.0), Clue(reference="Spotter#1", distance_km=3.0),
Clue(reference="Spotter#2", bearing_deg=270.0, bearing_tolerance_deg=11.25), Clue(reference="Spotter#2", bearing_deg=270.0, bearing_tolerance_deg=11.25),
@ -84,7 +84,7 @@ def test_explain_unresolved_ignores_a_toleranced_bearing_too():
once the toleranced bearing is (correctly) excluded, one clue alone once the toleranced bearing is (correctly) excluded, one clue alone
is never inconsistent with itself, so there's nothing to explain.""" is never inconsistent with itself, so there's nothing to explain."""
board = _board_with_spotters(Coord("J", 5, 0, 0), Coord("L", 4, 3, 0)) board = _board_with_spotters(Coord("J", 5, 0, 0), Coord("L", 4, 3, 0))
target = board.add_target(TargetType.HOSTILE_TANK, id_="1") target = board.add_target(TargetType.TANK, id_="1")
target.location = Location.from_desc("x", [ target.location = Location.from_desc("x", [
Clue(reference="Spotter#1", distance_km=3.0), Clue(reference="Spotter#1", distance_km=3.0),
Clue(reference="Spotter#2", bearing_deg=270.0, bearing_tolerance_deg=11.25), Clue(reference="Spotter#2", bearing_deg=270.0, bearing_tolerance_deg=11.25),
@ -95,7 +95,7 @@ def test_explain_unresolved_ignores_a_toleranced_bearing_too():
def test_manual_coord_override_clears_a_stale_note(): def test_manual_coord_override_clears_a_stale_note():
board = _board_with_spotters(Coord("J", 5, 0, 0), Coord("J", 5, 1, 0)) board = _board_with_spotters(Coord("J", 5, 0, 0), Coord("J", 5, 1, 0))
target = board.add_target(TargetType.HOSTILE_TANK, id_="1") target = board.add_target(TargetType.TANK, id_="1")
target.location = Location.from_desc("x", [ target.location = Location.from_desc("x", [
Clue(reference="Spotter#1", distance_km=7.33), Clue(reference="Spotter#1", distance_km=7.33),
Clue(reference="Spotter#2", distance_km=3.43), Clue(reference="Spotter#2", distance_km=3.43),