From 3955fa42c78b6c6843e2cee703e1598e9aecb4f1 Mon Sep 17 00:00:00 2001 From: Dominik Roth Date: Sun, 9 Aug 2026 20:23:42 +0200 Subject: [PATCH] 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 --- src/fenigma/app.py | 65 +++++++++++++++++++++ src/fenigma/grid_widget.py | 1 + src/fenigma/models.py | 114 ++++++++++++++++++++++++++++++++++--- src/fenigma/ocr.py | 48 ++++++++++++---- src/fenigma/solver.py | 15 ++--- tests/test_ocr.py | 25 +++++++- tests/test_solver.py | 14 ++--- 7 files changed, 245 insertions(+), 37 deletions(-) diff --git a/src/fenigma/app.py b/src/fenigma/app.py index e0ea7f5..d1ba979 100644 --- a/src/fenigma/app.py +++ b/src/fenigma/app.py @@ -275,6 +275,7 @@ class MainWindow(Adw.ApplicationWindow): ("Spotters", self._build_spotters_popover), ("Reference Points", self._build_rp_popover), ("Targets", self._build_targets_popover), + ("Allies", self._build_allies_popover), ("Scout Flights", self._build_scout_flights_popover), ): 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_reference_points(info, toast=False)) changed.extend(self._merge_targets(info, toast=False)) + changed.extend(self._merge_allies(info, toast=False)) if not changed: self.toast("No relevant info found in screenshot.") @@ -620,6 +622,26 @@ class MainWindow(Adw.ApplicationWindow): self._refresh() 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( self, *, title, on_submit, show_id=False, show_type=False, id_placeholder=None, initial_location=None, initial_id=None, initial_type=None, @@ -863,6 +885,49 @@ class MainWindow(Adw.ApplicationWindow): rebuild() 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 ------------------------------------------------------------ def _build_scout_flights_popover(self, rebuild) -> Gtk.Widget: box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) diff --git a/src/fenigma/grid_widget.py b/src/fenigma/grid_widget.py index a8eaf60..f411de8 100644 --- a/src/fenigma/grid_widget.py +++ b/src/fenigma/grid_widget.py @@ -51,6 +51,7 @@ CATEGORY_COLOR = { "spotter": (0.35, 0.78, 0.40), "rp": (0.95, 0.78, 0.20), "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) diff --git a/src/fenigma/models.py b/src/fenigma/models.py index ef2a0f9..3785931 100644 --- a/src/fenigma/models.py +++ b/src/fenigma/models.py @@ -36,15 +36,23 @@ NATO_ALPHABET = [ 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 SUPPLY_CACHE = "Supply Cache" - FDC = "FDC" # Fire Direction Center, coordinates enemy counter-battery fire - INFANTRY = "Infantry" # hostile ground troops - MECHANIZED = "Mechanized" # hostile armored/vehicle unit - HOSTILE_ARTILLERY = "Hostile Artillery" # "Coastal Battery" is just this, see _TYPE_WORD_ALIASES in ocr.py - HOSTILE_TANK = "Hostile Tank" + FDC = "FDC" # Fire Direction Center, coordinates counter-battery fire + INFANTRY = "Infantry" # ground troops + MECHANIZED = "Mechanized" # armored/vehicle unit + ARTILLERY = "Artillery" # "Coastal Battery" is just this, see _TYPE_WORD_ALIASES in ocr.py + TANK = "Tank" 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 Signal Station", "Enemy Field Command"), not one of the # 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 # 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 -# turned out to just be a HOSTILE_ARTILLERY under a different name. -_TARGET_TYPE_MIGRATIONS = {"AMMO_CACHE": "SUPPLY_CACHE", "COASTAL_BATTERY": "HOSTILE_ARTILLERY"} +# turned out to just be an ARTILLERY under a different name. +# 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: @@ -366,6 +382,36 @@ class Target: 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 class ScoutFlight: """A planned scout overflight: a rectangle anchored on a large grid @@ -397,6 +443,7 @@ class Board: self.spotters: list[Spotter] = [] self.reference_points: list[ReferencePoint] = [] self.targets: list[Target] = [] + self.allies: list[Ally] = [] self.scout_flights: list[ScoutFlight] = [] self._spotter_seq = 0 self._scout_flight_seq = 0 @@ -414,6 +461,9 @@ class Board: for t in self.targets: if t.name == name: return t + for a in self.allies: + if a.name == name: + return a return None # -- spotters -------------------------------------------------------- @@ -462,6 +512,27 @@ class Board: def remove_target(self, target: Target) -> None: 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 -------------------------------------------------- def next_scout_flight_id(self) -> int: return self._scout_flight_seq + 1 @@ -526,6 +597,9 @@ class Board: for t in self.targets: if t.coord is not None: yield "target", t + for a in self.allies: + if a.coord is not None: + yield "ally", a def ambiguous_entities(self): """Yield (category, obj) for RPs/Targets that resolved to two or @@ -543,6 +617,9 @@ class Board: for t in self.targets: if t.coord is None and t.location.potential_coords: yield "target", t + for a in self.allies: + if a.coord is None and a.location.potential_coords: + yield "ally", a # -- save / load --------------------------------------------------------- def to_dict(self) -> dict: @@ -586,6 +663,16 @@ class Board: } 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": [ { "id": sf.id, @@ -644,6 +731,17 @@ class Board: 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 = [ ScoutFlight( id=sf["id"], diff --git a/src/fenigma/ocr.py b/src/fenigma/ocr.py index c6654aa..894b22a 100644 --- a/src/fenigma/ocr.py +++ b/src/fenigma/ocr.py @@ -606,18 +606,32 @@ def parse_clues_from_text(text: str) -> list[Clue]: return _parse_all_clues(squash_enemy_names(squash_multiword_ids(text))) -def _resolve_target_type(type_word: str) -> TargetType | None: - """Exact match on the type word (after aliasing), falling back to - fuzzy (OCR can garble the type word itself, e.g. 'AmmoCoche').""" +_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] + 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 + return (best if best_ratio >= _FUZZY_THRESHOLD else None), is_ally def parse_intel_blocks(text: str) -> list[dict]: @@ -851,13 +865,13 @@ 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 = _resolve_target_type(type_word) + 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 = _resolve_target_type(type_word) + target_type, _is_ally = _resolve_target_type(type_word) if target_type is None: continue destroyed.add((target_type, letter_id)) @@ -940,12 +954,19 @@ class ParsedInfo: 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.destroyed) + or self.targets or self.allies or self.destroyed) def parse_text(text: str) -> ParsedInfo: @@ -985,12 +1006,15 @@ def parse_text(text: str) -> ParsedInfo: if entry["kind"] == "rp": info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"]) continue - target_type = _resolve_target_type(entry["type_word"]) + target_type, is_ally = _resolve_target_type(entry["type_word"]) if target_type is None: continue - info.targets[(target_type, entry["id"])] = ( - entry["raw"], entry["clues"], entry["coord"], entry["shell"], entry["requested_time"] - ) + 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"]) diff --git a/src/fenigma/solver.py b/src/fenigma/solver.py index 54b2319..5ac64f0 100644 --- a/src/fenigma/solver.py +++ b/src/fenigma/solver.py @@ -326,17 +326,18 @@ def explain_unresolved(location: Location, board: Board) -> str | None: def resolve_board(board: Board) -> list[str]: - """Resolve every not-yet-resolved RP/Target whose clues can currently - be satisfied, repeating until a fixed point (handles dependency - chains like AmmoCache#3 -> AmmoCache#2 -> Alpha/Spotters). Ambiguous - results are recorded as `potential_coords` and never feed further - resolution. Returns the names of everything newly *resolved* (not - counting ones that only became ambiguous) this call.""" + """Resolve every not-yet-resolved RP/Target/Ally whose clues can + currently be satisfied, repeating until a fixed point (handles + dependency chains like AmmoCache#3 -> AmmoCache#2 -> Alpha/ + Spotters). Ambiguous results are recorded as `potential_coords` and + never feed further resolution. Returns the names of everything + newly *resolved* (not counting ones that only became ambiguous) + this call.""" newly_resolved: list[str] = [] changed = True while changed: changed = False - for entities in (board.reference_points, board.targets): + for entities in (board.reference_points, board.targets, board.allies): for obj in entities: if obj.coord is not None or obj.location.potential_coords: continue # already resolved, or stuck ambiguous, don't reprocess diff --git a/tests/test_ocr.py b/tests/test_ocr.py index abe824b..43d9840 100644 --- a/tests/test_ocr.py +++ b/tests/test_ocr.py @@ -48,7 +48,7 @@ def test_calibration_target_line(): def test_destroyed_reports_digit_and_letter_id(): text = "SupplyCache#2 Destroyed. Additional Requisition Granted.\nDirect Hit! HostileTank#3 Destroyed." 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(): @@ -103,8 +103,8 @@ Coastal Battery#2: """ info = ocr.parse_text(text) assert info.reference_points["ListeningPost#1"][2] == Coord("K", 6, 7, 8) - assert (TargetType.HOSTILE_ARTILLERY, "2") in info.targets - _, clues, *_ = info.targets[(TargetType.HOSTILE_ARTILLERY, "2")] + assert (TargetType.ARTILLERY, "2") in info.targets + _, clues, *_ = info.targets[(TargetType.ARTILLERY, "2")] 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) _, clues, coord, *_ = info.targets[(TargetType.ENEMY, "SignalStation")] 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 diff --git a/tests/test_solver.py b/tests/test_solver.py index 773d4c4..bcbc5e9 100644 --- a/tests/test_solver.py +++ b/tests/test_solver.py @@ -12,7 +12,7 @@ def _board_with_spotters(*coords): def test_bearing_and_distance_from_one_reference_resolves_directly(): 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)]) solver.resolve_board(board) 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(): 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", [ Clue(reference="Spotter#1", bearing_deg=90.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(): 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", [ Clue(reference="Spotter#1", distance_km=7.33), 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 a clean resolution.""" 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", [ Clue(reference="Spotter#1", distance_km=7.33), 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 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)) - 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", distance_km=3.0), 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 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)) - 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", distance_km=3.0), 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(): 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", [ Clue(reference="Spotter#1", distance_km=7.33), Clue(reference="Spotter#2", distance_km=3.43),