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),
("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)

View File

@ -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)

View File

@ -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"],

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)))
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"])

View File

@ -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

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

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),