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:
2026-08-13 22:02:17 +02:00
co-authored by Claude Sonnet 5
parent 5a35ea7776
commit 6a61bffb22
6 changed files with 313 additions and 45 deletions
+62 -19
View File
@@ -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: