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
+36 -12
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"])