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:
2026-08-09 20:23:42 +02:00
co-authored by Claude Sonnet 5
parent d6694b585b
commit 3955fa42c7
7 changed files with 245 additions and 37 deletions
+22 -3
View File
@@ -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
+7 -7
View File
@@ -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),