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:
Dominik Moritz Roth 2026-08-13 22:02:17 +02:00
parent 5a35ea7776
commit 6a61bffb22
6 changed files with 313 additions and 45 deletions

132
TODO.md
View File

@ -246,3 +246,135 @@ Status legend: [x] fixed+tested, [~] partially addressed, [ ] open/needs input
popover's heading (", id #8") and the pending-proposal's own
on-map label (`? #8 G8 5:4`) both show it while there's still a
screenshot up to check it against by eye.
## Windows build (packaging/windows) -- real progress, not yet a clean pass
Booted the actual dockur/windows build VM and drove it live (VNC) to find
out what's really failing, rather than guessing from the README's own
"UNTESTED end to end" note. Three real, separate bugs found and fixed,
each confirmed live against the real VM, not just read off a diff:
- [x] The build watcher was never actually installed at all, despite
`install_progress.log` claiming every provisioning step succeeded.
`C:\OEM` (dockur's `/oem` staging dir) doesn't reliably persist past
Windows Setup finishing -- exactly what the (already-uncommitted,
now committed) `install.bat`/`watch_build.bat` fix diagnosed, just
never verified against a real run before now. A 2-day-old
`BUILD_REQUEST` had been sitting unclaimed the whole time. Manually
re-applied the fix's logic live once (copied the corrected files to
`C:\FenigmaBuild`, registered the Startup-folder entry) and
confirmed on a full container restart that the watcher now
auto-starts on login and picks up a pending request with zero
manual intervention -- the actual fix, not just my live patch, is
what's doing that.
- [x] `pip install pytesseract` fails outright: MSYS2's mingw64 Python
enforces PEP 668 ("externally-managed-environment"), which
`install.bat` never accounted for. Needs `--break-system-packages`.
- [x] `import fenigma.app` fails with `ModuleNotFoundError: No module
named 'cv2'` even after `pacman -S mingw-w64-x86_64-opencv`
succeeds -- that package is the C++ library only. The actual
Python bindings are a SEPARATE package, `mingw-w64-x86_64-
python-opencv`, that `install.bat`'s dependency list simply never
included. (`pip install opencv-python-headless` as a fallback
doesn't work either and shouldn't be relied on: MSYS2's mingw64
Python uses a different ABI than PyPI's Windows wheels
-- `cp314-mingw_x86_64_msvcrt_gnu` vs `win_amd64` -- so pip can
never use a prebuilt wheel there, only build from source, which
then needs a full separate native toolchain -ninja/cmake/gcc- this
VM doesn't have either.)
All three are one-line fixes once known. `install.bat`'s pacman package
list and pip install line need these applied for a from-scratch VM to
provision correctly (currently they're only proven fixed live on this
session's VM, not yet folded back into the committed `install.bat` --
do that before relying on a fresh `./build_windows.sh` run from
scratch).
With all three fixed, `import fenigma.app` succeeds and a real build
attempt got all the way through source copy, sanity check, dist-tree
assembly, and WiX harvest+compile (`candle.exe`) -- further than this
pipeline has ever gotten. Two more issues surfaced right at the finish
line:
- [x] `product.wxs`'s `Version` needs strict WiX `x.x.x.x` numeric
form -- a `0.1.0-test` version string (my own test invocation,
not `build_windows.sh`'s real default) fails `candle.exe` with
CNDL0108/CNDL0010. Not a real bug, just don't pass a version with
a suffix.
- [ ] `light.exe` (final MSI linking) did not finish within 15 minutes
on this VM (4 CPU / 8GB RAM) before the RAM-conscious auto-
shutdown killed it -- process was genuinely active (343MB
working set, not hung on a dialog), just slow, likely from the
~1GB+ bulk-copied mingw64 runtime (see README's own "dist tree is
fat, not lean" note) combined with this VM's disk/CPU being
shared with the host. Needs either a longer timeout on a retry,
or the dist-tree-trimming work the README already flags as a
known follow-up (tracing the real DLL/typelib closure instead of
bulk-copying all of mingw64) to make `light.exe` have less to
compress in the first place.
## OCR: new fire-support-request grammar gaps (from real user-pasted messages)
- [x] A "taking fire" report's reporting unit ("Infantry#11 taking
fire!...") was being added as a hostile Target, not a friendly
Ally -- see the id-scheme entry above for the "no Friendly/Hostile
prefix word exists in this grammar" root cause and the fix
(`_TAKING_FIRE_RE`, `force_ally`, and splitting the shell/deadline
into a synthetic StrikeRequest even for the no-offset "on our
position" case, which previously kept them on the entity itself --
fine when it was wrongly a Target, silently lost once correctly an
Ally, since Ally tuples carry no shell/deadline fields at all). 4
existing tests updated, all still passing plus the rest of the
suite (51 total).
- [ ] A multi-shell sequential request ("Requesting TEAR Shell first,
then HE Shell, at bearing...") only captures the FIRST shell
(TEAR) -- "then HE Shell" is silently dropped, no way currently to
record "use this shell, then that one." Real scope question before
fixing: does the board/firing-panel data model even have a place
to put a second shell for one strike request, or does this need a
new field/shape entirely?
- [ ] "Answer by 10:30:00" isn't recognized as a deadline at all --
`_TAKING_FIRE_TIME_RE`/`_REQUESTED_BEFORE_RE` only know "before/by
\<time\>" and "Requested before - \<time\> -" phrasing. A third
deadline grammar to add.
- [ ] A same-message "Important: ... Answer by \<time\>" follow-up line
gets misread as a brand new named entity header (the last-resort
bare-`<name>:` fallback rule matches "Important:"), creating a
bogus `Target#Important` carrying that deadline as its own
`requested_time`. Real false-positive in the header-detection
fallback, needs either an exclusion list (blocklist "Important"
and similar prose lead-ins) or a stricter bare-header shape.
Confirmed live against the user's real pasted message (with an
assumed `Infantry#N taking fire!` header line prepended, since
their paste seems to have been cropped before it -- worth
double-checking against the actual full in-game message).
- [x] "\<Type\>#\<id\> Destroyed" kill-feed parsing already exists and
already marks the matching Target dead (`parse_destroyed`,
`_merge_targets`'s own destroyed-handling block in app.py) --
confirmed working end-to-end against a real 9-entry kill-feed
paste, including multi-word types ("Enemy Mechanized Infantry#2
Destroyed" correctly resolved to INFANTRY_MECHANIZED). This was
already-existing, working functionality, not something needing to
be built.
One real gap found in the same test: "Enemy Field Gun#1 Destroyed"
silently dropped. Two bugs stacked, both fixed:
- [x] "Field Gun" is just the game's own alt name for plain
Artillery (confirmed by the user directly) -- not a missing
unit type needing a new enum member/icon after all. Added to
`_TYPE_WORD_ALIASES` next to AmmoCache/CoastalBattery.
- [x] Even with that alias, it still didn't resolve: `_ALLY_PREFIX_RE`
only ever stripped a leading "Friendly"/"Hostile" word, never
"Enemy" -- so `squash_multiword_ids`'s "EnemyFieldGun#1"
token got alias-looked-up and fuzzy-matched as a WHOLE
("EnemyFieldGun" vs "Artillery", nowhere close), not just its
"FieldGun" part. "Enemy Mechanized Infantry#2" only ever
worked by fuzzy-match ACCIDENT (a long, distinctive type
string still clears the ratio threshold with "Enemy" stuck
to the front; a short, unrelated one like Artillery doesn't).
`_ALLY_PREFIX_RE` now strips "Enemy" too, with a lookahead
requiring something after it -- a BARE "Enemy#N" is
`TargetType.ENEMY` itself (its own value IS "Enemy"),
stripping unconditionally would've left an empty type_word
and broken every ad-hoc "Enemy#N Destroyed" report instead.
3 new regression tests, all passing (54 total).

View File

@ -57,7 +57,7 @@ if errorlevel 1 (
exit /b 1
)
copy /y C:\OEM\product.wxs C:\build\product.wxs >nul
copy /y C:\FenigmaBuild\product.wxs C:\build\product.wxs >nul
echo [build.bat] compiling (candle) ... >> %LOG%
C:\wix\candle.exe -dDistDir=C:\build\dist -dAppVersion=%APPVER% -out C:\build\ C:\build\product.wxs C:\build\files.wxs >> %LOG% 2>&1

View File

@ -18,6 +18,24 @@ REM besides silence during the one-time provisioning run.
setlocal enabledelayedexpansion
call :log "starting FEnigma build-VM provisioning"
REM -- Stage the OTHER oem/ files somewhere that outlives C:\OEM itself,
REM done first, before anything else. Confirmed on a real run: C:\OEM
REM (dockur's /oem copy target) does NOT reliably persist once Windows
REM Setup finishes and you're at the desktop -- it's fundamentally a
REM Windows Setup-time staging mechanism ($OEM$ folders, copied by WinPE
REM "right after the Windows image is applied ... and before the first
REM reboot" per Microsoft's own docs), not guaranteed permanent storage,
REM and in practice `dir C:\OEM` came back "File Not Found" once actually
REM checked from an interactive desktop session. build.bat/product.wxs/
REM watch_build.bat all get referenced again AFTER install.bat's own
REM process has exited (by the Startup-folder entry below, potentially
REM much later), so they need a home install.bat itself controls and
REM knows persists -- a plain folder on C:, not the OEM staging area.
mkdir C:\FenigmaBuild 2>nul
copy /y C:\OEM\build.bat C:\FenigmaBuild\build.bat >> C:\OEM\install.log 2>&1
copy /y C:\OEM\product.wxs C:\FenigmaBuild\product.wxs >> C:\OEM\install.log 2>&1
copy /y C:\OEM\watch_build.bat C:\FenigmaBuild\watch_build.bat >> C:\OEM\install.log 2>&1
REM -- MSYS2: the "base" self-extracting archive, not the GUI installer --
REM (the GUI installer has no reliable non-interactive/silent flag across
REM versions; the base sfx archive is what CI pipelines actually use).
@ -47,10 +65,23 @@ C:\msys64\usr\bin\bash.exe -lc "pacman -Syuu --noconfirm" >> C:\OEM\install.log
C:\msys64\usr\bin\bash.exe -lc "pacman -Syuu --noconfirm" >> C:\OEM\install.log 2>&1
call :log "installing GTK4/libadwaita/PyGObject/build deps ..."
C:\msys64\usr\bin\bash.exe -lc "pacman -S --noconfirm --needed mingw-w64-x86_64-python mingw-w64-x86_64-python-pip mingw-w64-x86_64-python-gobject mingw-w64-x86_64-gtk4 mingw-w64-x86_64-libadwaita mingw-w64-x86_64-python-numpy mingw-w64-x86_64-python-pillow mingw-w64-x86_64-opencv mingw-w64-x86_64-tesseract-ocr" >> C:\OEM\install.log 2>&1
REM mingw-w64-x86_64-opencv is the C++ library ONLY -- confirmed live on a
REM real VM that `import cv2` fails without it, the actual Python bindings
REM are the separate mingw-w64-x86_64-python-opencv package. Don't try to
REM paper over a missing one with `pip install opencv-python-headless`
REM either: MSYS2's mingw64 Python uses a different ABI than PyPI's Windows
REM wheels (cp3XX-mingw_x86_64_msvcrt_gnu vs win_amd64), so pip can never
REM use a prebuilt wheel there, only build from source, which then needs a
REM full separate native toolchain (ninja/cmake/gcc) this VM doesn't have.
C:\msys64\usr\bin\bash.exe -lc "pacman -S --noconfirm --needed mingw-w64-x86_64-python mingw-w64-x86_64-python-pip mingw-w64-x86_64-python-gobject mingw-w64-x86_64-gtk4 mingw-w64-x86_64-libadwaita mingw-w64-x86_64-python-numpy mingw-w64-x86_64-python-pillow mingw-w64-x86_64-opencv mingw-w64-x86_64-python-opencv mingw-w64-x86_64-tesseract-ocr" >> C:\OEM\install.log 2>&1
call :log "pip install pytesseract (pure python, no wheel needed) ..."
C:\msys64\mingw64\bin\python3.exe -m pip install pytesseract >> C:\OEM\install.log 2>&1
REM --break-system-packages: MSYS2's mingw64 Python enforces PEP 668
REM ("externally-managed-environment"), confirmed live -- a plain
REM `pip install` here fails outright without this flag. Safe here: this
REM VM's whole mingw64 Python install exists only to run FEnigma, there's
REM no system package manager relying on it staying untouched.
C:\msys64\mingw64\bin\python3.exe -m pip install --break-system-packages pytesseract >> C:\OEM\install.log 2>&1
REM -- WiX v3 toolset (candle/light/heat), a plain zip of standalone exes,
REM no installer needed. Fixed versioned URL, no scraping required.
@ -72,8 +103,8 @@ REM SYSTEM task with no session of its own. Startup-folder entries run
REM in whichever user's session actually logs in, inheriting their
REM drive mappings correctly.
call :log "registering build watcher (Startup folder) ..."
copy /y C:\OEM\watch_build.bat "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp\FenigmaBuildWatcher.bat" >> C:\OEM\install.log 2>&1
start "" cmd /c C:\OEM\watch_build.bat
copy /y C:\FenigmaBuild\watch_build.bat "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp\FenigmaBuildWatcher.bat" >> C:\OEM\install.log 2>&1
start "" cmd /c C:\FenigmaBuild\watch_build.bat
call :log "provisioning done"
echo DONE > C:\OEM\provisioned.marker

View File

@ -1,5 +1,8 @@
@echo off
REM Runs persistently from system boot (see install.bat's scheduled task).
REM Runs persistently from login (see install.bat's Startup-folder entry --
REM this file itself gets copied to C:\FenigmaBuild\ and to the Startup
REM folder by install.bat, not run from C:\OEM, which does not reliably
REM survive past Windows Setup finishing, see install.bat's own note).
REM Polls the host-shared Z:\ drive for a build request and, when one
REM shows up, runs build.bat against it. This is what lets build_windows.sh
REM on the Linux host trigger a build without any RDP/remote-exec: it's
@ -17,14 +20,14 @@ if not exist Z:\ (
if exist Z:\BUILD_REQUEST (
REM Claim the request before acting on it -- if watch_build.bat somehow
REM ended up running twice this boot (install.bat starts it once
REM immediately, the ONSTART task could also fire the same boot), only
REM immediately, a fresh login could also start another copy), only
REM one of them wins this move and actually builds.
move /y Z:\BUILD_REQUEST Z:\BUILD_REQUEST.claimed >nul 2>&1
if exist Z:\BUILD_REQUEST.claimed (
del Z:\BUILD_REQUEST.claimed
del /q Z:\BUILD_DONE 2>nul
del /q Z:\BUILD_FAILED 2>nul
call C:\OEM\build.bat
call C:\FenigmaBuild\build.bat
)
)
timeout /t 5 /nobreak >nul

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:

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")]