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:
+36
-12
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user