Fix taking-fire ally misclassification; Windows build fixes; Field Gun
OCR (src/fenigma/ocr.py):
- A "taking fire" report's reporting unit was added as a hostile Target,
not a friendly Ally -- no "Friendly"/"Hostile" prefix word exists in
that grammar for the usual inference to key off of, so it silently
defaulted to not-ally. Fixed with an explicit force_ally override
(_TAKING_FIRE_RE), and the shell/deadline (which an Ally tuple has no
fields for) now always splits into a synthetic StrikeRequest target at
the reporting position, even for the no-offset "on our position" case
that previously kept them on the entity itself.
- "Enemy <Type>#<id> Destroyed" kill-feed lines were silently dropping
for shorter/less distinctive type words (e.g. "Enemy Field Gun#1") --
_ALLY_PREFIX_RE only ever stripped "Friendly"/"Hostile", never
"Enemy", so the whole "EnemyFieldGun" token got alias/fuzzy-matched
against "Artillery" and missed by a mile. Longer type words
("Enemy Mechanized Infantry#2") only ever worked by fuzzy-match
accident. Now strips "Enemy" too (lookahead guards a BARE "Enemy#N"
report, which IS TargetType.ENEMY itself, from being stripped to an
empty, unresolvable string).
- "Field Gun" added to _TYPE_WORD_ALIASES as plain Artillery under
another name (confirmed by the user), not a missing unit type.
7 new/updated regression tests, 54 total passing.
Windows build (packaging/windows/): three real bugs found and fixed by
actually booting and driving the build VM live (VNC), not just guessing
from the README's "UNTESTED end to end" note:
- install.bat's MSYS2/WiX provisioning previously left NOTHING behind
once C:\OEM stopped existing (a 2-day-old BUILD_REQUEST sat unclaimed
the whole time) -- the build.bat/watch_build.bat persistence fix
(C:\FenigmaBuild instead of C:\OEM) is real and now confirmed live:
after a full container restart, the watcher auto-starts on login and
picks up a pending request with zero manual intervention.
- pip install pytesseract needs --break-system-packages (MSYS2's
mingw64 Python enforces PEP 668).
- mingw-w64-x86_64-opencv is the C++ library only; the actual Python
bindings are the separate mingw-w64-x86_64-python-opencv package,
never in install.bat's dependency list.
With all three, import fenigma.app succeeds and a real build attempt
gets through source copy, sanity check, dist-tree assembly, and WiX
harvest+compile -- further than this pipeline has ever gotten. Full
findings, including the still-open light.exe timeout and the OCR
multi-shell/deadline-phrasing/phantom-header gaps found along the way,
logged in TODO.md.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+62
-19
@@ -219,6 +219,13 @@ def _extract_requested_time(text: str) -> str | None:
|
||||
# _extract_requesting_shell()'s docstring.
|
||||
_REQUESTING_SHELL_RE = re.compile(r"Requesting\s+([A-Za-z]+?)\s*Shells?\b", re.IGNORECASE)
|
||||
_TAKING_FIRE_TIME_RE = re.compile(r"\b(?:before|by)\s+(T?\d{1,2}:\d{2}:\d{2})\b", re.IGNORECASE)
|
||||
# "<Type>#<id> taking fire!" always names the REPORTING unit calling in
|
||||
# its own distress -- necessarily a friendly, no hostile ever radios in
|
||||
# about itself under attack. There's no "Friendly"/"Hostile" prefix word
|
||||
# anywhere in this grammar (see this module's own comment above) for
|
||||
# _resolve_target_type to key off of, so without this the reporting unit
|
||||
# defaults to not-ally (its own default) and gets added as an enemy.
|
||||
_TAKING_FIRE_RE = re.compile(r"\btaking fire\b", re.IGNORECASE)
|
||||
_ON_OUR_POSITION_COORD_RE = re.compile(
|
||||
rf"on\s+our\s+position\s+at\s+{_COORD_FRAGMENT}", re.IGNORECASE
|
||||
)
|
||||
@@ -608,10 +615,15 @@ _BARE_CLUE_VALUE_RE = re.compile(
|
||||
|
||||
_TYPE_BY_SHORT = {t.short: t for t in TargetType}
|
||||
# The game's typewriter has used "AmmoCache" for what's now modeled as
|
||||
# SupplyCache, and "CoastalBattery" for what's just a HostileArtillery
|
||||
# under a different name, treat both as the same type rather than
|
||||
# dropping the target or inventing a redundant enum member for it.
|
||||
_TYPE_WORD_ALIASES = {"AmmoCache": "SupplyCache", "CoastalBattery": "HostileArtillery"}
|
||||
# SupplyCache, "CoastalBattery" for what's just a HostileArtillery under
|
||||
# a different name, and "Field Gun" for plain Artillery too (confirmed
|
||||
# by the user against a real "Enemy Field Gun#1 Destroyed" kill-feed line
|
||||
# that was otherwise silently dropping) -- treat all three as the same
|
||||
# type rather than dropping the target or inventing a redundant enum
|
||||
# member for each alternate name.
|
||||
_TYPE_WORD_ALIASES = {
|
||||
"AmmoCache": "SupplyCache", "CoastalBattery": "HostileArtillery", "FieldGun": "Artillery",
|
||||
}
|
||||
|
||||
|
||||
_REF_NAMED_RE = re.compile(rf"^{_TYPE_ID_FRAGMENT}")
|
||||
@@ -699,17 +711,27 @@ def parse_clues_from_text(text: str) -> list[Clue]:
|
||||
return _parse_all_clues(squash_enemy_names(squash_multiword_ids(text)))
|
||||
|
||||
|
||||
_ALLY_PREFIX_RE = re.compile(r"^(Friendly|Hostile)", re.IGNORECASE)
|
||||
# 'Enemy' is also a valid not-ally prefix ("Enemy Field Gun#1 Destroyed",
|
||||
# squashed to "EnemyFieldGun#1" by squash_multiword_ids -- confirmed live
|
||||
# by the user this was silently dropping, the FieldGun->Artillery alias
|
||||
# added below wasn't even reached because "Enemy" was never stripped off
|
||||
# first, so alias/fuzzy lookup ran against "EnemyFieldGun" as a whole,
|
||||
# not just "FieldGun"). The lookahead requires something AFTER the
|
||||
# prefix: a bare "Enemy" alone must NOT match here and fall through
|
||||
# un-stripped instead, since TargetType.ENEMY's own value IS "Enemy" --
|
||||
# stripping it there would leave an empty type_word with nothing left to
|
||||
# resolve, dropping every bare ad-hoc "Enemy#N Destroyed" report.
|
||||
_ALLY_PREFIX_RE = re.compile(r"^(Friendly|Hostile|Enemy)(?=.)", 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')."""
|
||||
"""(TargetType, is_ally). A leading 'Friendly'/'Hostile'/'Enemy' word
|
||||
is stripped off the type word first ('FriendlyTank' -> ally, TANK;
|
||||
'HostileTank'/'EnemyTank'/bare 'Tank' -> not ally, TANK -- explicit
|
||||
'Hostile'/'Enemy' and no prefix at all 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:
|
||||
@@ -767,16 +789,36 @@ def parse_intel_blocks(text: str) -> list[dict]:
|
||||
)
|
||||
shell = _extract_shell_request(joined) or _extract_requesting_shell(joined)
|
||||
requested_time = _extract_requested_time(joined) or _extract_taking_fire_time(joined)
|
||||
# The offset variant's shell/deadline describe the FIRE POINT,
|
||||
# not the reporting unit itself -- they move to the synthetic
|
||||
# StrikeRequest entry below, not kept here too.
|
||||
current["shell"] = None if offset_coord is not None else shell
|
||||
current["requested_time"] = None if offset_coord is not None else requested_time
|
||||
# See _TAKING_FIRE_RE's own comment: overrides whatever
|
||||
# _resolve_target_type would otherwise infer from type_word
|
||||
# alone (no "Friendly"/"Hostile" prefix exists in this
|
||||
# grammar to key off of) -- the reporting unit is always the
|
||||
# friendly calling this in, never the hostile.
|
||||
is_taking_fire = bool(_TAKING_FIRE_RE.search(joined))
|
||||
current["force_ally"] = is_taking_fire
|
||||
# Fire-support info (shell/deadline) always describes the FIRE
|
||||
# POINT, never the reporting/named entity itself. For the
|
||||
# bearing/distance offset variant that's a different place
|
||||
# than the entity's own position. For a plain taking-fire
|
||||
# report ("on our position") it's the SAME coord as the
|
||||
# entity's own position, but the entity itself is now an ally
|
||||
# (force_ally above), and Ally entries carry no shell/deadline
|
||||
# fields at all to hold it -- either way a taking-fire
|
||||
# report's shell+deadline moves to a synthetic StrikeRequest
|
||||
# entry below rather than staying on this one, where it would
|
||||
# either be wrong (offset case) or silently dropped (ally
|
||||
# case, once merge_all only reads (raw, clues, coord) for allies).
|
||||
split_fire_request = offset_coord is not None or is_taking_fire
|
||||
current["shell"] = None if split_fire_request else shell
|
||||
current["requested_time"] = None if split_fire_request else requested_time
|
||||
if (current["clues"] or current["coord"] is not None
|
||||
or current["shell"] is not None or current["requested_time"] is not None):
|
||||
current["raw"] = joined
|
||||
entries.append(current)
|
||||
if offset_coord is not None:
|
||||
# No offset given ("on our position") means the fire point
|
||||
# IS the entity's own position, not a separate place.
|
||||
fire_coord = offset_coord if offset_coord is not None else current["coord"]
|
||||
if split_fire_request and fire_coord is not None and (shell is not None or requested_time is not None):
|
||||
# TargetType.STRIKE_REQUEST, not STRIKE: this is a
|
||||
# friendly unit calling in a strike over the radio, not
|
||||
# one the player placed themselves (see that type's own
|
||||
@@ -787,7 +829,7 @@ def parse_intel_blocks(text: str) -> list[dict]:
|
||||
entries.append({
|
||||
"kind": "named", "name": f"StrikeRequest#{strike_id}",
|
||||
"type_word": "StrikeRequest",
|
||||
"id": strike_id, "raw": joined, "clues": [], "coord": offset_coord,
|
||||
"id": strike_id, "raw": joined, "clues": [], "coord": fire_coord,
|
||||
"shell": shell, "requested_time": requested_time,
|
||||
})
|
||||
current = None
|
||||
@@ -1130,6 +1172,7 @@ def parse_text(text: str) -> ParsedInfo:
|
||||
info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"])
|
||||
continue
|
||||
target_type, is_ally = _resolve_target_type(entry["type_word"])
|
||||
is_ally = is_ally or entry.get("force_ally", False)
|
||||
if target_type is None:
|
||||
continue
|
||||
if is_ally:
|
||||
|
||||
Reference in New Issue
Block a user