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:
parent
d6694b585b
commit
3955fa42c7
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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"],
|
||||||
|
|||||||
@ -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,12 +1006,15 @@ 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
|
||||||
info.targets[(target_type, entry["id"])] = (
|
if is_ally:
|
||||||
entry["raw"], entry["clues"], entry["coord"], entry["shell"], entry["requested_time"]
|
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):
|
for entry in parse_train_intel(text):
|
||||||
info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"])
|
info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"])
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user