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:
parent
5a35ea7776
commit
6a61bffb22
132
TODO.md
132
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
|
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
|
on-map label (`? #8 G8 5:4`) both show it while there's still a
|
||||||
screenshot up to check it against by eye.
|
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).
|
||||||
|
|||||||
@ -57,7 +57,7 @@ if errorlevel 1 (
|
|||||||
exit /b 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%
|
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
|
C:\wix\candle.exe -dDistDir=C:\build\dist -dAppVersion=%APPVER% -out C:\build\ C:\build\product.wxs C:\build\files.wxs >> %LOG% 2>&1
|
||||||
|
|||||||
@ -18,6 +18,24 @@ REM besides silence during the one-time provisioning run.
|
|||||||
setlocal enabledelayedexpansion
|
setlocal enabledelayedexpansion
|
||||||
call :log "starting FEnigma build-VM provisioning"
|
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 -- MSYS2: the "base" self-extracting archive, not the GUI installer --
|
||||||
REM (the GUI installer has no reliable non-interactive/silent flag across
|
REM (the GUI installer has no reliable non-interactive/silent flag across
|
||||||
REM versions; the base sfx archive is what CI pipelines actually use).
|
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
|
C:\msys64\usr\bin\bash.exe -lc "pacman -Syuu --noconfirm" >> C:\OEM\install.log 2>&1
|
||||||
|
|
||||||
call :log "installing GTK4/libadwaita/PyGObject/build deps ..."
|
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) ..."
|
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 -- WiX v3 toolset (candle/light/heat), a plain zip of standalone exes,
|
||||||
REM no installer needed. Fixed versioned URL, no scraping required.
|
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 in whichever user's session actually logs in, inheriting their
|
||||||
REM drive mappings correctly.
|
REM drive mappings correctly.
|
||||||
call :log "registering build watcher (Startup folder) ..."
|
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
|
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:\OEM\watch_build.bat
|
start "" cmd /c C:\FenigmaBuild\watch_build.bat
|
||||||
|
|
||||||
call :log "provisioning done"
|
call :log "provisioning done"
|
||||||
echo DONE > C:\OEM\provisioned.marker
|
echo DONE > C:\OEM\provisioned.marker
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
@echo off
|
@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 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 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
|
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 (
|
if exist Z:\BUILD_REQUEST (
|
||||||
REM Claim the request before acting on it -- if watch_build.bat somehow
|
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 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.
|
REM one of them wins this move and actually builds.
|
||||||
move /y Z:\BUILD_REQUEST Z:\BUILD_REQUEST.claimed >nul 2>&1
|
move /y Z:\BUILD_REQUEST Z:\BUILD_REQUEST.claimed >nul 2>&1
|
||||||
if exist Z:\BUILD_REQUEST.claimed (
|
if exist Z:\BUILD_REQUEST.claimed (
|
||||||
del Z:\BUILD_REQUEST.claimed
|
del Z:\BUILD_REQUEST.claimed
|
||||||
del /q Z:\BUILD_DONE 2>nul
|
del /q Z:\BUILD_DONE 2>nul
|
||||||
del /q Z:\BUILD_FAILED 2>nul
|
del /q Z:\BUILD_FAILED 2>nul
|
||||||
call C:\OEM\build.bat
|
call C:\FenigmaBuild\build.bat
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
timeout /t 5 /nobreak >nul
|
timeout /t 5 /nobreak >nul
|
||||||
|
|||||||
@ -219,6 +219,13 @@ def _extract_requested_time(text: str) -> str | None:
|
|||||||
# _extract_requesting_shell()'s docstring.
|
# _extract_requesting_shell()'s docstring.
|
||||||
_REQUESTING_SHELL_RE = re.compile(r"Requesting\s+([A-Za-z]+?)\s*Shells?\b", re.IGNORECASE)
|
_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_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(
|
_ON_OUR_POSITION_COORD_RE = re.compile(
|
||||||
rf"on\s+our\s+position\s+at\s+{_COORD_FRAGMENT}", re.IGNORECASE
|
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}
|
_TYPE_BY_SHORT = {t.short: t for t in TargetType}
|
||||||
# The game's typewriter has used "AmmoCache" for what's now modeled as
|
# The game's typewriter has used "AmmoCache" for what's now modeled as
|
||||||
# SupplyCache, and "CoastalBattery" for what's just a HostileArtillery
|
# SupplyCache, "CoastalBattery" for what's just a HostileArtillery under
|
||||||
# under a different name, treat both as the same type rather than
|
# a different name, and "Field Gun" for plain Artillery too (confirmed
|
||||||
# dropping the target or inventing a redundant enum member for it.
|
# by the user against a real "Enemy Field Gun#1 Destroyed" kill-feed line
|
||||||
_TYPE_WORD_ALIASES = {"AmmoCache": "SupplyCache", "CoastalBattery": "HostileArtillery"}
|
# 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}")
|
_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)))
|
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]:
|
def _resolve_target_type(type_word: str) -> tuple[TargetType | None, bool]:
|
||||||
"""(TargetType, is_ally). A leading 'Friendly'/'Hostile' word is
|
"""(TargetType, is_ally). A leading 'Friendly'/'Hostile'/'Enemy' word
|
||||||
stripped off the type word first ('FriendlyTank' -> ally, TANK;
|
is stripped off the type word first ('FriendlyTank' -> ally, TANK;
|
||||||
'HostileTank' or bare 'Tank' -> not ally, TANK, an explicit
|
'HostileTank'/'EnemyTank'/bare 'Tank' -> not ally, TANK -- explicit
|
||||||
'Hostile' and no prefix at all mean the same thing, not-ally is the
|
'Hostile'/'Enemy' and no prefix at all all mean the same thing,
|
||||||
default). What's left is matched exactly against the type word
|
not-ally is the default). What's left is matched exactly against the
|
||||||
(after aliasing), falling back to fuzzy (OCR can garble the type
|
type word (after aliasing), falling back to fuzzy (OCR can garble
|
||||||
word itself, e.g. 'AmmoCoche')."""
|
the type word itself, e.g. 'AmmoCoche')."""
|
||||||
is_ally = False
|
is_ally = False
|
||||||
prefix_m = _ALLY_PREFIX_RE.match(type_word)
|
prefix_m = _ALLY_PREFIX_RE.match(type_word)
|
||||||
if prefix_m:
|
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)
|
shell = _extract_shell_request(joined) or _extract_requesting_shell(joined)
|
||||||
requested_time = _extract_requested_time(joined) or _extract_taking_fire_time(joined)
|
requested_time = _extract_requested_time(joined) or _extract_taking_fire_time(joined)
|
||||||
# The offset variant's shell/deadline describe the FIRE POINT,
|
# See _TAKING_FIRE_RE's own comment: overrides whatever
|
||||||
# not the reporting unit itself -- they move to the synthetic
|
# _resolve_target_type would otherwise infer from type_word
|
||||||
# StrikeRequest entry below, not kept here too.
|
# alone (no "Friendly"/"Hostile" prefix exists in this
|
||||||
current["shell"] = None if offset_coord is not None else shell
|
# grammar to key off of) -- the reporting unit is always the
|
||||||
current["requested_time"] = None if offset_coord is not None else requested_time
|
# 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
|
if (current["clues"] or current["coord"] is not None
|
||||||
or current["shell"] is not None or current["requested_time"] is not None):
|
or current["shell"] is not None or current["requested_time"] is not None):
|
||||||
current["raw"] = joined
|
current["raw"] = joined
|
||||||
entries.append(current)
|
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
|
# TargetType.STRIKE_REQUEST, not STRIKE: this is a
|
||||||
# friendly unit calling in a strike over the radio, not
|
# friendly unit calling in a strike over the radio, not
|
||||||
# one the player placed themselves (see that type's own
|
# one the player placed themselves (see that type's own
|
||||||
@ -787,7 +829,7 @@ def parse_intel_blocks(text: str) -> list[dict]:
|
|||||||
entries.append({
|
entries.append({
|
||||||
"kind": "named", "name": f"StrikeRequest#{strike_id}",
|
"kind": "named", "name": f"StrikeRequest#{strike_id}",
|
||||||
"type_word": "StrikeRequest",
|
"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,
|
"shell": shell, "requested_time": requested_time,
|
||||||
})
|
})
|
||||||
current = None
|
current = None
|
||||||
@ -1130,6 +1172,7 @@ def parse_text(text: str) -> ParsedInfo:
|
|||||||
info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"])
|
info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"])
|
||||||
continue
|
continue
|
||||||
target_type, is_ally = _resolve_target_type(entry["type_word"])
|
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:
|
if target_type is None:
|
||||||
continue
|
continue
|
||||||
if is_ally:
|
if is_ally:
|
||||||
|
|||||||
@ -52,6 +52,43 @@ def test_destroyed_reports_digit_and_letter_id():
|
|||||||
assert info.destroyed == {(TargetType.SUPPLY_CACHE, "2"), (TargetType.TANK, "3")}
|
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():
|
def test_train_arrival_intel():
|
||||||
text = """ARRIVAL STATION:
|
text = """ARRIVAL STATION:
|
||||||
Valle de Mula MainStation: J6 0:4
|
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'/
|
requested'), deadline is a bare 'before <time>' with no 'Requested'/
|
||||||
dashes. The '<b>id1</b>' attacker mention is just prose here, not
|
dashes. The '<b>id1</b>' attacker mention is just prose here, not
|
||||||
parsed into anything -- only the request itself (shell, position,
|
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"
|
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> "
|
"Requesting <u><b>SMK Shell</b></u> on our position at <b>J6 2:7</b> "
|
||||||
"before <u>10:38:57</u>!")
|
"before <u>10:38:57</u>!")
|
||||||
info = ocr.parse_text(text)
|
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 coord == Coord("J", 6, 2, 7)
|
||||||
assert shell is Shell.SMK
|
assert shell is Shell.SMK
|
||||||
assert requested_time == "10:38:57"
|
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> "
|
"Requesting <u><b>SMK Shell</b></u> on our position at <b>J6 2:5</b> "
|
||||||
"before <u>10:37:52</u>!")
|
"before <u>10:37:52</u>!")
|
||||||
info = ocr.parse_text(text)
|
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 coord == Coord("J", 6, 2, 5)
|
||||||
assert shell is Shell.SMK
|
assert shell is Shell.SMK
|
||||||
assert requested_time == "10:37:52"
|
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
|
"""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
|
reporting unit, but at a bearing/distance offset from its own
|
||||||
(inline-given) position -- two different places, so this becomes two
|
(inline-given) position -- two different places, so this becomes two
|
||||||
entries: Infantry#3 stays at its own reported position (no shell/
|
entries: Infantry#3 stays at its own reported position, as an ALLY
|
||||||
deadline, it's not the fire point), and a separate synthetic Strike
|
(see test_infantry_taking_fire_no_attacker_mention's own docstring --
|
||||||
entry carries the shell/deadline at the computed offset coord ('our
|
same reasoning, this is still a taking-fire report), and a separate
|
||||||
position' isn't a named board entity to hang a Clue off of, so this
|
synthetic Strike entry carries the shell/deadline at the computed
|
||||||
resolves straight to an absolute coord rather than via one)."""
|
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"
|
text = ("Infantry#3 taking fire!\n"
|
||||||
"Requesting <u><b>HE Shell</b></u> at bearing <b>239°</b>, distance "
|
"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> "
|
"<b>10.76km</b> from our position, <b>J6 2:5</b>, by <u>10:38:18</u> "
|
||||||
"or we will be overrun!")
|
"or we will be overrun!")
|
||||||
info = ocr.parse_text(text)
|
info = ocr.parse_text(text)
|
||||||
|
|
||||||
assert (TargetType.INFANTRY, "3") in info.targets
|
assert (TargetType.INFANTRY, "3") in info.allies
|
||||||
raw, clues, coord, shell, requested_time = info.targets[(TargetType.INFANTRY, "3")]
|
assert (TargetType.INFANTRY, "3") not in info.targets
|
||||||
|
raw, clues, coord = info.allies[(TargetType.INFANTRY, "3")]
|
||||||
assert coord == Coord("J", 6, 2, 5)
|
assert coord == Coord("J", 6, 2, 5)
|
||||||
assert shell is None
|
|
||||||
assert requested_time is None
|
|
||||||
|
|
||||||
assert (TargetType.STRIKE_REQUEST, "Infantry3") in info.targets
|
assert (TargetType.STRIKE_REQUEST, "Infantry3") in info.targets
|
||||||
raw, clues, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry3")]
|
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!")
|
"or we will be overrun!")
|
||||||
info = ocr.parse_text(text)
|
info = ocr.parse_text(text)
|
||||||
|
|
||||||
assert (TargetType.INFANTRY, "11") in info.targets
|
assert (TargetType.INFANTRY, "11") in info.allies
|
||||||
_, _, coord, shell, requested_time = info.targets[(TargetType.INFANTRY, "11")]
|
assert (TargetType.INFANTRY, "11") not in info.targets
|
||||||
|
_, _, coord = info.allies[(TargetType.INFANTRY, "11")]
|
||||||
assert coord == Coord("I", 7, 0, 8)
|
assert coord == Coord("I", 7, 0, 8)
|
||||||
assert shell is None
|
|
||||||
assert requested_time is None
|
|
||||||
|
|
||||||
assert (TargetType.STRIKE_REQUEST, "Infantry11") in info.targets
|
assert (TargetType.STRIKE_REQUEST, "Infantry11") in info.targets
|
||||||
_, _, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry11")]
|
_, _, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry11")]
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user