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
+77 -18
View File
@@ -52,6 +52,43 @@ def test_destroyed_reports_digit_and_letter_id():
assert info.destroyed == {(TargetType.SUPPLY_CACHE, "2"), (TargetType.TANK, "3")}
def test_destroyed_report_strips_a_leading_enemy_prefix():
"""A real kill-feed paste with an "Enemy <Type>#<id> Destroyed" shape
(squashed by squash_multiword_ids to "EnemyMechanizedInfantry#1"
before this ever runs) was silently dropping every single-word type
("Enemy Infantry#11 Destroyed") -- the un-stripped "Enemy" prefix
only accidentally fuzzy-matched for longer/more distinctive type
words (Mechanized Infantry), not shorter/more different ones (Field
Gun -- see test_field_gun_is_an_artillery_alias). _ALLY_PREFIX_RE now
strips "Enemy" the same as "Hostile"."""
text = ("Enemy Mechanized Infantry#1 Destroyed, +5 Requisition.\n"
"Enemy Infantry#11 Destroyed, +5 Requisition.")
info = ocr.parse_text(text)
assert info.destroyed == {(TargetType.INFANTRY_MECHANIZED, "1"), (TargetType.INFANTRY, "11")}
def test_bare_enemy_destroyed_report_is_still_target_type_enemy():
"""The lookahead in _ALLY_PREFIX_RE (only strip "Enemy" when there's
something AFTER it) matters here specifically: a BARE "Enemy#N" is
TargetType.ENEMY itself (its own value IS "Enemy") -- stripping the
prefix unconditionally would leave an empty type_word and silently
drop every ad-hoc "Enemy#N Destroyed" report instead."""
text = "Enemy#7 Destroyed, +5 Requisition."
info = ocr.parse_text(text)
assert (TargetType.ENEMY, "7") in info.destroyed
def test_field_gun_is_an_artillery_alias():
"""The game calls plain Artillery "Field Gun" in at least this kill-
feed message -- confirmed by the user against a real "Enemy Field
Gun#1 Destroyed" line that was otherwise silently dropping (no
TargetType.FIELD_GUN exists, nor should one -- see _TYPE_WORD_ALIASES'
own comment, same treatment as AmmoCache/CoastalBattery)."""
text = "Priority target Enemy Field Gun#1 Destroyed, +50 Requisition."
info = ocr.parse_text(text)
assert (TargetType.ARTILLERY, "1") in info.destroyed
def test_train_arrival_intel():
text = """ARRIVAL STATION:
Valle de Mula MainStation: J6 0:4
@@ -245,13 +282,29 @@ def test_infantry_taking_fire_direct_position_request():
requested'), deadline is a bare 'before <time>' with no 'Requested'/
dashes. The '<b>id1</b>' attacker mention is just prose here, not
parsed into anything -- only the request itself (shell, position,
deadline) matters."""
deadline) matters.
The reporting unit ('Infantry#1 taking fire') is always a FRIENDLY
calling in its own distress -- no hostile ever radios in about
itself -- so it lands in info.allies, not info.targets (a real bug:
it used to default to not-ally, no "Friendly"/"Hostile" prefix word
exists in this grammar for the usual inference to key off of). The
shell/deadline still need a home a plain Ally tuple doesn't have
room for, so they move to a synthetic StrikeRequest target at the
SAME coord as the reporting unit ('on our position' means the fire
point IS that position, no offset given)."""
text = ("Infantry#1 taking fire from <b>id1</b>!\n"
"Requesting <u><b>SMK Shell</b></u> on our position at <b>J6 2:7</b> "
"before <u>10:38:57</u>!")
info = ocr.parse_text(text)
assert (TargetType.INFANTRY, "1") in info.targets
raw, clues, coord, shell, requested_time = info.targets[(TargetType.INFANTRY, "1")]
assert (TargetType.INFANTRY, "1") in info.allies
assert (TargetType.INFANTRY, "1") not in info.targets
raw, clues, coord = info.allies[(TargetType.INFANTRY, "1")]
assert coord == Coord("J", 6, 2, 7)
assert (TargetType.STRIKE_REQUEST, "Infantry1") in info.targets
raw, clues, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry1")]
assert coord == Coord("J", 6, 2, 7)
assert shell is Shell.SMK
assert requested_time == "10:38:57"
@@ -262,8 +315,14 @@ def test_infantry_taking_fire_no_attacker_mention():
"Requesting <u><b>SMK Shell</b></u> on our position at <b>J6 2:5</b> "
"before <u>10:37:52</u>!")
info = ocr.parse_text(text)
assert (TargetType.INFANTRY, "3") in info.targets
raw, clues, coord, shell, requested_time = info.targets[(TargetType.INFANTRY, "3")]
assert (TargetType.INFANTRY, "3") in info.allies
assert (TargetType.INFANTRY, "3") not in info.targets
raw, clues, coord = info.allies[(TargetType.INFANTRY, "3")]
assert coord == Coord("J", 6, 2, 5)
assert (TargetType.STRIKE_REQUEST, "Infantry3") in info.targets
raw, clues, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry3")]
assert coord == Coord("J", 6, 2, 5)
assert shell is Shell.SMK
assert requested_time == "10:37:52"
@@ -273,22 +332,23 @@ def test_infantry_taking_fire_bearing_distance_from_position():
"""The other request shape: the shell isn't wanted right on top of the
reporting unit, but at a bearing/distance offset from its own
(inline-given) position -- two different places, so this becomes two
entries: Infantry#3 stays at its own reported position (no shell/
deadline, it's not the fire point), and a separate synthetic Strike
entry carries the shell/deadline at the computed offset coord ('our
position' isn't a named board entity to hang a Clue off of, so this
resolves straight to an absolute coord rather than via one)."""
entries: Infantry#3 stays at its own reported position, as an ALLY
(see test_infantry_taking_fire_no_attacker_mention's own docstring --
same reasoning, this is still a taking-fire report), and a separate
synthetic Strike entry carries the shell/deadline at the computed
offset coord ('our position' isn't a named board entity to hang a
Clue off of, so this resolves straight to an absolute coord rather
than via one)."""
text = ("Infantry#3 taking fire!\n"
"Requesting <u><b>HE Shell</b></u> at bearing <b>239°</b>, distance "
"<b>10.76km</b> from our position, <b>J6 2:5</b>, by <u>10:38:18</u> "
"or we will be overrun!")
info = ocr.parse_text(text)
assert (TargetType.INFANTRY, "3") in info.targets
raw, clues, coord, shell, requested_time = info.targets[(TargetType.INFANTRY, "3")]
assert (TargetType.INFANTRY, "3") in info.allies
assert (TargetType.INFANTRY, "3") not in info.targets
raw, clues, coord = info.allies[(TargetType.INFANTRY, "3")]
assert coord == Coord("J", 6, 2, 5)
assert shell is None
assert requested_time is None
assert (TargetType.STRIKE_REQUEST, "Infantry3") in info.targets
raw, clues, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry3")]
@@ -313,11 +373,10 @@ def test_infantry_taking_fire_bearing_distance_short_range():
"or we will be overrun!")
info = ocr.parse_text(text)
assert (TargetType.INFANTRY, "11") in info.targets
_, _, coord, shell, requested_time = info.targets[(TargetType.INFANTRY, "11")]
assert (TargetType.INFANTRY, "11") in info.allies
assert (TargetType.INFANTRY, "11") not in info.targets
_, _, coord = info.allies[(TargetType.INFANTRY, "11")]
assert coord == Coord("I", 7, 0, 8)
assert shell is None
assert requested_time is None
assert (TargetType.STRIKE_REQUEST, "Infantry11") in info.targets
_, _, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry11")]