diff --git a/TODO.md b/TODO.md index e0caeac..3b504ac 100644 --- a/TODO.md +++ b/TODO.md @@ -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 + \" and "Requested before - \ -" phrasing. A third + deadline grammar to add. +- [ ] A same-message "Important: ... Answer by \" follow-up line + gets misread as a brand new named entity header (the last-resort + bare-`:` 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] "\#\ 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). diff --git a/packaging/windows/oem/build.bat b/packaging/windows/oem/build.bat index 3b6f31c..ce0c1c9 100644 --- a/packaging/windows/oem/build.bat +++ b/packaging/windows/oem/build.bat @@ -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 diff --git a/packaging/windows/oem/install.bat b/packaging/windows/oem/install.bat index 8e90939..5f416d4 100644 --- a/packaging/windows/oem/install.bat +++ b/packaging/windows/oem/install.bat @@ -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 diff --git a/packaging/windows/oem/watch_build.bat b/packaging/windows/oem/watch_build.bat index 3594d5c..99a6d1a 100644 --- a/packaging/windows/oem/watch_build.bat +++ b/packaging/windows/oem/watch_build.bat @@ -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 diff --git a/src/fenigma/ocr.py b/src/fenigma/ocr.py index 297815f..1c6c684 100644 --- a/src/fenigma/ocr.py +++ b/src/fenigma/ocr.py @@ -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) +# "# 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: diff --git a/tests/test_ocr.py b/tests/test_ocr.py index e7f5021..c08821d 100644 --- a/tests/test_ocr.py +++ b/tests/test_ocr.py @@ -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 # 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