Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc74d62774 | ||
|
|
d66b881239 | ||
|
|
d3246328e8 | ||
|
|
22fbb33923 | ||
|
|
6e34f50b6a | ||
|
|
6a61bffb22 | ||
|
|
5a35ea7776 | ||
|
|
6e18d60eb5 | ||
|
|
218909b6cf | ||
|
|
d2f70675b8 | ||
|
|
8109db2f39 | ||
|
|
086b871e3a | ||
|
|
896c7dc36a | ||
|
|
e43c3478c9 |
@@ -119,15 +119,302 @@ Status legend: [x] fixed+tested, [~] partially addressed, [ ] open/needs input
|
||||
accept attempt after that silently died before the ally/target
|
||||
ever got added, popover already closed by the time it happened.
|
||||
Fixed there; not a separate bug.
|
||||
- [x] "Accept as…" (the type-picker submenu on a proposal, and "Change
|
||||
type" on an already-placed entity) opening to a visibly empty/
|
||||
unchanged popover. This one left no traceback at all -- confirmed
|
||||
live with temporary debug prints that the button's `clicked` signal
|
||||
fires, the icon grid builds successfully (all N types), and
|
||||
`Popover.set_child()` on the already-open outer popover reports the
|
||||
right `visible=True`/width/height afterward... but the compositor
|
||||
never actually repaints that reused surface, so nothing new ever
|
||||
appeared on screen. Fixed by not resizing the existing open
|
||||
popover at all: popping it down and opening a genuinely new one
|
||||
(fresh native surface) at the same anchor point instead. Same fix
|
||||
applied to both call sites (`_open_proposal_menu`'s `show_type`,
|
||||
`_open_entity_menu`'s `show_type`, the latter refactored to share
|
||||
the same `_reopen_with()` helper).
|
||||
- [x] New: mark a Target as underground, at a hardening tier (1-3),
|
||||
rendered as the game's own Armor-tier additive badge stacked on
|
||||
the icon. `Target.underground_tier: int | None`, a "Mark
|
||||
underground" entry in the entity-edit popover (tier picker reusing
|
||||
the same fresh-popover fix above), and `GridCanvas` draws the
|
||||
badge above the marker's icon, overlapping down into it by
|
||||
`_ADDITIVE_OVERLAP_PX` -- both the diamond icon's top corner and
|
||||
the badge's bottom are tapered to a near-point, not a flat edge,
|
||||
so bbox-exact touching still read as a gap; a real pixel overlap
|
||||
is what actually looks contiguous (confirmed against the game's
|
||||
own stacked-badge screenshots). Badge is scaled/positioned off the
|
||||
art's real opaque content (PIL `getbbox()`), not its PNG canvas --
|
||||
the additive files carry a lot of off-center transparent padding
|
||||
that made the badge look tiny and floating if sized off the raw
|
||||
canvas.
|
||||
|
||||
## Needs more scope / your input before I keep going
|
||||
|
||||
- [ ] Enemy type detection needs to be more robust; read the entity id
|
||||
- [~] Enemy type detection needs to be more robust; read the entity id
|
||||
label so dedup is reliable; detect death from the log.
|
||||
All three are real computer-vision/OCR feature work (better marker
|
||||
classification in `map_vision.py`'s `classify_marker`, a new OCR pass
|
||||
reading each marker's id label off the map screenshot, and a
|
||||
"<Type>#<id> Destroyed" log-scan tied into a dedup key that includes
|
||||
that read id) rather than bugs with a small fix. Worth its own pass
|
||||
once there's a batch of the `debug_capture` failure/maybe_map
|
||||
screenshots above to develop against.
|
||||
|
||||
Started on the id-reading piece: `map_vision.read_marker_id` reads
|
||||
each marker's own small "#<N>" label (distinct from the big
|
||||
per-cell grid label `read_cell_label` reads) via the SAME template-
|
||||
correlation approach as `read_cell_label`, not OCR -- this text
|
||||
sits over the same aerial-photo backdrop that this module's own
|
||||
docstring says defeated every detection-based approach tried for
|
||||
grid labels, so pytesseract (already tried elsewhere in this repo,
|
||||
`ocr.py`, for a different image domain: flat scanned paper, not
|
||||
photo-textured) was skipped in favor of the approach already proven
|
||||
here. Wired end-to-end: `find_markers` -> `Proposal.detected_id` ->
|
||||
`debug_capture.save_marker_ground_truth`'s JSON. Reads against
|
||||
`ScreenshotImport.full_image` (sharper than the WORK_W image
|
||||
detection itself runs against) when available. Crop region and
|
||||
`MIN_MARKER_ID_SCORE` are a single-screenshot calibration (see
|
||||
`read_marker_id`'s own docstring) -- UNVALIDATED against a real
|
||||
ground-truth batch (none of the 6 existing captures have a
|
||||
confirmed id to check against, they all predate this). New unit
|
||||
tests (`tests/test_map_vision_marker_id.py`) only cover the
|
||||
synthetic-render round-trip, not real-screenshot accuracy.
|
||||
|
||||
Measured type-detection reliability against the 6 existing
|
||||
`marker_ground_truth` captures (72 accepted proposals total,
|
||||
2026-08-13): **0/72 (0%) had ANY confident `detected_unit` guess**
|
||||
-- `classify_marker` returned `None` on every single one, every
|
||||
side, every capture. Not "guesses wrong" -- never confident enough
|
||||
to answer at all. Spot-checked directly against one real marker
|
||||
crop (a hostile Infantry, confirmed by the user): best match was
|
||||
"Underground Fort" at score 0.376 (Infantry wasn't even in the top
|
||||
8), against a `min_score=0.55` floor `classify_marker` requires --
|
||||
not a close miss, a real correlation failure. The clean rendered
|
||||
icon templates `icon_bank()` matches against apparently don't
|
||||
correlate well with how markers actually look in a real screenshot
|
||||
(compression/blur/aerial-photo texture underneath), unlike text
|
||||
glyphs (`read_cell_label`'s measured 0.73-0.87 vs 0.40-0.56) where
|
||||
the same template-correlation idea works well. Added `unit_score`/
|
||||
`unit_margin` to `Proposal`/ground-truth JSON (previously only
|
||||
pass/fail `unit` was logged) so every future capture shows exactly
|
||||
how far off a guess was, not just None -- there was no way to tell
|
||||
"barely missed the bar" from "wildly wrong" before this.
|
||||
|
||||
Death-detection-from-log is still fully unstarted -- no log-parsing
|
||||
code exists in this repo at all yet, real scope work (find/access
|
||||
the game's log, agree a "<Type>#<id> Destroyed" grammar, wire it
|
||||
into a dedup key) rather than a quick pass.
|
||||
|
||||
Follow-ups from user feedback after the above landed:
|
||||
- `_accept_proposal` now actually USES `detected_id` (it was only
|
||||
being logged before, never applied) -- an accepted proposal's
|
||||
entity id prefers the detected number over auto-assignment,
|
||||
falling back on a collision. 4 new regression tests
|
||||
(`tests/test_app_accept_proposal.py`).
|
||||
- Auto-assignment itself (`Board.add_target`/`add_ally` with no
|
||||
`id_`/no usable detection) changed from one shared letter
|
||||
sequence per group (targets, or allies) to its own sequence per
|
||||
TYPE within each group -- Tank#A/Infantry#A rather than
|
||||
Tank#A/Infantry#B. This directly reverses an earlier deliberate
|
||||
fix in this same file (see the "Allies and enemies seem to share
|
||||
indices" entry above, which moved FROM per-type TO
|
||||
shared-per-group) -- that fix is still correct for what it fixed
|
||||
(targets-vs-allies must stay separate namespaces), just not for
|
||||
per-type-vs-shared, which the user has now clarified the other
|
||||
way. Tests in `test_models.py` updated to match (renamed
|
||||
`test_auto_id_is_shared_across_types...` ->
|
||||
`test_auto_id_is_per_type...`, since it now asserts the opposite).
|
||||
|
||||
First pass at this ALSO switched auto-assignment from letters to
|
||||
plain numbers (1/2/3...), reasoning that it should match what
|
||||
`detected_id` looks like when read successfully. Wrong -- caught
|
||||
by the user immediately: auto-assignment (no real id known, a
|
||||
manual add or an accept with no confident read) and a genuinely
|
||||
detected id need to stay visually distinct, or a made-up
|
||||
auto-assigned number could collide with, or be mistaken for, a
|
||||
real one. Reverted back to `_next_free_id` (letters, rolling
|
||||
over to "AA"/"AB"/... past 26 rather than raising
|
||||
`StopIteration`) as the auto-assignment fallback, scoped per
|
||||
type same as above; plain numbers are reserved for an id
|
||||
`_accept_proposal` is actually confident was read off the
|
||||
marker itself (`Proposal.detected_id`), passed straight through
|
||||
as `id_` and never touching auto-assignment at all.
|
||||
- `_accept_proposal` now actually USES `detected_id` (it was only
|
||||
being logged before, never applied) -- an accepted proposal's
|
||||
entity id prefers the detected number over auto-assignment,
|
||||
falling back on a collision (scoped per type, same bug fixed
|
||||
in two places: this collision pre-check, and the "Change ID"
|
||||
popover's own check, which still enforced the OLD shared-per-
|
||||
group rule after the auto-assignment change above and rejected
|
||||
valid renames across types). 4 new regression tests
|
||||
(`tests/test_app_accept_proposal.py`).
|
||||
- `detected_id` is now shown, not just logged: the proposal
|
||||
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.
|
||||
- [x] `light.exe` (final MSI linking) did not finish within 15 minutes
|
||||
on a first retry (4 CPU / 8GB RAM VM) before the RAM-conscious
|
||||
auto-shutdown killed it -- turned out to be genuinely just slow
|
||||
(process was active, 343MB working set, not hung on a dialog),
|
||||
not a real bug: retried with a 40-minute budget and it finished
|
||||
`light.exe` itself in a few more minutes.
|
||||
- [x] ...and then failed for a REAL reason right at the very end:
|
||||
`light.exe`'s ICE80 validation rejected essentially every
|
||||
harvested file -- "This 32BitComponent ... uses 64BitDirectory".
|
||||
`product.wxs`'s own `INSTALLFOLDER` is correctly under
|
||||
`ProgramFiles64Folder` (a 64-bit mingw64 toolchain is what's
|
||||
actually being packaged), but nothing was making the components
|
||||
agree -- a real, on-disk mismatch, not a transient VM issue.
|
||||
First fix attempt (`-platform x64` on `heat.exe`'s harvest) was
|
||||
WRONG -- re-verified live, identical ICE80 failures afterward
|
||||
(confirmed the corrected `build.bat` had actually reached the VM
|
||||
this time, ruling out a stale-copy repeat of the earlier watcher
|
||||
bug). WiX v3's `heat.exe -platform` only affects registry-key
|
||||
harvesting, it never stamps `Win64="yes"` on components. Real
|
||||
fix: `-arch x64` on `candle.exe` (the COMPILE step, not the
|
||||
harvest step) -- sets the default Win64/Platform for every
|
||||
component compiled from either source file, hand-authored
|
||||
(`product.wxs`) or harvested (`files.wxs`) alike, the standard
|
||||
WiX v3 way to make a whole package consistently 64-bit. Kept the
|
||||
harmless-but-insufficient `-platform x64` on `heat.exe` too.
|
||||
|
||||
**CONFIRMED live**, third attempt: `build_errorlevel=0`,
|
||||
`BUILD_DONE`, and a real 958MB `FEnigma-0.1.0.msi` written to
|
||||
`Z:\dist` -- the first ever fully successful build this pipeline
|
||||
has produced. Copied to `dist-windows/FEnigma-0.1.0.msi` in the
|
||||
repo root (gitignored, same as `build_windows.sh` itself would
|
||||
do). NOT yet installed/launched on a real Windows machine to
|
||||
confirm the app actually runs (see the "Not tested against a
|
||||
real GTK4/libadwaita Windows install at all" line in this repo's
|
||||
own `packaging/windows/README.md` -- still true, packaging
|
||||
succeeding is not the same claim as the app working once
|
||||
installed).
|
||||
|
||||
Follow-up, now that a clean build exists to measure against:
|
||||
`light.exe` alone took ~15-18 minutes even with ICE80 fixed --
|
||||
revisit the ~1GB+ bulk-copied mingw64 dist tree (README's own
|
||||
"not lean" note) as a real perf issue, not just a packaging-
|
||||
correctness one.
|
||||
|
||||
## 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) into the structured `shell` field -- "then HE Shell" isn't
|
||||
parsed into anything. Less urgent than it first looked though: the
|
||||
full original message text (both shells, in order) is already
|
||||
preserved as-is and shown to the player via the coord dialog's
|
||||
description view (`Location.desc_raw`, set from the same `raw`
|
||||
every merged target/ally carries) -- nothing is silently LOST, it's
|
||||
just not machine-parsed into a queryable second-shell field. Real
|
||||
scope question before building that: does the board/firing-panel
|
||||
data model even have a place to put a second shell for one strike
|
||||
request today, or does this need a new field/shape entirely --
|
||||
worth confirming it's actually wanted (vs. "read the raw text
|
||||
yourself, it's right there") before spending the design effort.
|
||||
- [x] "Answer by 10:30:00" turned out to be one bug, not two. The
|
||||
phrasing itself was never the problem -- `_TAKING_FIRE_TIME_RE`
|
||||
already matches any `before|by <time>`, "Answer BY 10:30:00"
|
||||
included. The REAL bug: a same-message "Important: ..." follow-up
|
||||
line was misread as a brand new named entity header (the
|
||||
last-resort bare-`<name>:` fallback rule matched "Important:"
|
||||
itself with nothing excluding common prose lead-ins), creating a
|
||||
bogus `Target#Important` that stole "Answer by 10:30:00" into ITS
|
||||
own `requested_time` instead of the real report's. Fixed with a
|
||||
blocklist (`_BARE_NAME_HEADER_BLOCKLIST`: important/note/warning/
|
||||
attention/caution/alert/reminder/priority) on that fallback rule --
|
||||
once the phantom split stopped happening, the deadline resolved
|
||||
onto the right entry with no separate fix needed. New regression
|
||||
test, confirmed 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).
|
||||
|
||||
- [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).
|
||||
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -19,15 +19,25 @@ happen at http://localhost:8006 (dockur's noVNC viewer) if it stalls.
|
||||
- **SELinux blocks the bind mounts** ("Storage folder (/storage) is not
|
||||
writeable!") — fixed with `:Z` on every volume in `docker-compose.yml`.
|
||||
Harmless no-op on a host without SELinux.
|
||||
- **BTRFS + QEMU disk images boot-loop Windows Setup for hours** with no
|
||||
error, just the same boot-manager log lines repeating forever and the
|
||||
disk barely growing — dockur/windows warns about this itself
|
||||
("you are using the BTRFS filesystem for /storage, this might
|
||||
introduce issues with Windows Setup!"), and on this host it wasn't
|
||||
idle chatter. Fixed by disabling copy-on-write on `storage/` before
|
||||
the VM ever writes to it (`chattr +C`, only affects files created
|
||||
after it's set, so it needs an empty directory) — `build_windows.sh`
|
||||
now does this itself on every run. No-op on a non-btrfs filesystem.
|
||||
- **The repeated "loading/starting Boot0004" log lines are NOT a boot
|
||||
loop** — that was an earlier misdiagnosis here (blamed on BTRFS+QEMU,
|
||||
"fixed" with `chattr +C` on `storage/`). Checked the actual noVNC
|
||||
screen instead of just the text log and it was genuine, progressing
|
||||
Windows Setup the whole time ("This might take a few minutes" →
|
||||
"Please keep your PC on and plugged in" → desktop) — Setup legitimately
|
||||
reboots the VM multiple times, each one re-prints those same firmware
|
||||
log lines. The `chattr +C` disables copy-on-write for the VM's disk
|
||||
image regardless (a real, independently-documented dockur/QEMU/BTRFS
|
||||
caveat), so it's staying, but it likely wasn't fixing an actual
|
||||
problem this time. **Lesson: check the screen, not just the log,
|
||||
before concluding something's stuck.**
|
||||
- **The build watcher registered as a SYSTEM-context Scheduled Task
|
||||
never fires in practice**: `Z:\` (the `/shared` mount) is mapped per
|
||||
interactive session, invisible to a task with no session of its own,
|
||||
so it spun forever on `watch_build.bat`'s own `if not exist Z:\`
|
||||
wait — confirmed by a build request sitting unclaimed for hours.
|
||||
Fixed in `install.bat`: an All-Users Startup-folder entry instead,
|
||||
which runs in whichever user's session actually logs in.
|
||||
|
||||
## How it fits together
|
||||
|
||||
|
||||
@@ -49,7 +49,13 @@ REM here (see this file's top-of-file note).
|
||||
robocopy C:\msys64\mingw64 C:\build\dist\mingw64 /e /xd include share\doc share\man share\gtk-doc /nfl /ndl /njh /njs >> %LOG% 2>&1
|
||||
|
||||
echo [build.bat] harvesting WiX components ... >> %LOG%
|
||||
C:\wix\heat.exe dir C:\build\dist -cg AppFiles -gg -scom -sreg -sfrag -srd -sw5150 -dr INSTALLFOLDER -var var.DistDir -out C:\build\files.wxs >> %LOG% 2>&1
|
||||
REM -platform x64 alone is NOT enough (confirmed live: still every
|
||||
REM component ICE80'd afterward) -- WiX v3 heat.exe's -platform flag
|
||||
REM doesn't actually stamp Win64="yes" on harvested components itself,
|
||||
REM it only affects registry-key harvesting. Kept anyway (harmless,
|
||||
REM correct in spirit), but the real fix is candle.exe's -arch x64
|
||||
REM below, see its own comment.
|
||||
C:\wix\heat.exe dir C:\build\dist -platform x64 -cg AppFiles -gg -scom -sreg -sfrag -srd -sw5150 -dr INSTALLFOLDER -var var.DistDir -out C:\build\files.wxs >> %LOG% 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [build.bat] FAILED: heat.exe harvest failed >> %LOG%
|
||||
copy %LOG% Z:\build.log.failed >nul
|
||||
@@ -57,10 +63,18 @@ 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
|
||||
REM -arch x64: the actual fix for the ICE80 "32BitComponent uses
|
||||
REM 64BitDirectory" failure (confirmed live -- heat.exe's own -platform
|
||||
REM x64 above does NOT set this, only affects registry harvesting).
|
||||
REM -arch sets the default Win64/Platform for every component compiled
|
||||
REM from EITHER source file, hand-authored (product.wxs) or harvested
|
||||
REM (files.wxs) alike, without needing per-component authoring -- the
|
||||
REM standard WiX v3 way to make a whole package consistently 64-bit,
|
||||
REM matching product.wxs's own ProgramFiles64Folder.
|
||||
C:\wix\candle.exe -arch x64 -dDistDir=C:\build\dist -dAppVersion=%APPVER% -out C:\build\ C:\build\product.wxs C:\build\files.wxs >> %LOG% 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [build.bat] FAILED: candle.exe failed >> %LOG%
|
||||
copy %LOG% Z:\build.log.failed >nul
|
||||
|
||||
@@ -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.
|
||||
@@ -61,13 +92,19 @@ powershell -NoProfile -Command ^
|
||||
"Expand-Archive -Path 'C:\wix311-binaries.zip' -DestinationPath 'C:\wix' -Force"
|
||||
del C:\wix311-binaries.zip
|
||||
|
||||
REM -- Register the build watcher to run at every boot from here on, plus
|
||||
REM kick it off right now too (ONSTART won't retroactively fire for this
|
||||
REM already-in-progress boot). Runs as SYSTEM so it works with no user
|
||||
REM logged in.
|
||||
call :log "registering build watcher ..."
|
||||
schtasks /create /tn "FenigmaBuildWatcher" /sc onstart /ru SYSTEM /rl HIGHEST /tr "C:\OEM\watch_build.bat" /f >> C:\OEM\install.log 2>&1
|
||||
start "" cmd /c C:\OEM\watch_build.bat
|
||||
REM -- Register the build watcher to run at every login from here on,
|
||||
REM plus kick it off right now too (a fresh login won't retroactively
|
||||
REM fire for this already-logged-in session). Deliberately an All-Users
|
||||
REM Startup-folder entry, NOT a SYSTEM-context Scheduled Task: confirmed
|
||||
REM on a real run that a /ru SYSTEM task can't see Z:\ at all and spins
|
||||
REM forever on watch_build.bat's own "if not exist Z:\" wait -- Z:\ (the
|
||||
REM /shared mount) is mapped per INTERACTIVE session, invisible to a
|
||||
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:\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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,6 +14,8 @@ from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import pickle
|
||||
import signal
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
@@ -272,6 +274,15 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
self._import_job = None # in-flight map_import.ImportJob, if any
|
||||
self.screenshot_import = None # the map screenshot currently on the board
|
||||
|
||||
# Dev-only: SIGUSR1 pickles {board, screenshot_import} to a fixed
|
||||
# path so a `kill -USR1` + relaunch (e.g. while bisecting a live
|
||||
# bug) can restore the in-progress board/screenshot/proposals
|
||||
# instead of losing them. One-shot: the restore consumes and
|
||||
# deletes the file. Not wired to any UI -- debugging aid only.
|
||||
GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGUSR1,
|
||||
self._dev_dump_session_for_restart)
|
||||
self._dev_maybe_restore_session()
|
||||
|
||||
self.toast_overlay = Adw.ToastOverlay()
|
||||
self.set_content(self.toast_overlay)
|
||||
|
||||
@@ -629,7 +640,11 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
both solutions as ground truth, useful later for improving the
|
||||
grid solver against exactly the case it got wrong."""
|
||||
if solution is not imp.solution:
|
||||
debug_capture.save_grid_correction(imp.image, imp.solution, solution)
|
||||
# full_image over image: sharper source for a human reviewing
|
||||
# the capture later, same reasoning as save_marker_ground_truth's.
|
||||
debug_capture.save_grid_correction(
|
||||
imp.full_image if imp.full_image is not None else imp.image,
|
||||
imp.solution, solution)
|
||||
imp.solution = solution
|
||||
# A screenshot already on the board (never explicitly dropped, the
|
||||
# user just pasted a new one straight over it) still deserves its
|
||||
@@ -685,16 +700,74 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
self._drop_shot_btn.set_visible(imp is not None)
|
||||
self._accept_all_btn.set_sensitive(bool(imp is not None and imp.pending()))
|
||||
|
||||
_DEV_SESSION_PATH = Path(tempfile.gettempdir()) / "fenigma_dev_session.pkl"
|
||||
|
||||
def _dev_dump_session_for_restart(self, *_a) -> bool:
|
||||
"""SIGUSR1 handler: pickle {board, screenshot_import} so a
|
||||
following relaunch can pick this session right back up. See the
|
||||
SIGUSR1 registration in __init__ for why this exists."""
|
||||
try:
|
||||
with open(self._DEV_SESSION_PATH, "wb") as f:
|
||||
pickle.dump({"board": self.board, "screenshot_import": self.screenshot_import}, f)
|
||||
print(f"fenigma: dev session dumped to {self._DEV_SESSION_PATH}", flush=True)
|
||||
except Exception as exc:
|
||||
print(f"fenigma: dev session dump failed: {exc!r}", flush=True)
|
||||
return GLib.SOURCE_CONTINUE
|
||||
|
||||
def _dev_maybe_restore_session(self) -> None:
|
||||
"""Counterpart to `_dev_dump_session_for_restart`: one-shot restore
|
||||
on startup if a dump is sitting there. Sets `self.board`/
|
||||
`self.screenshot_import` directly (before the rest of __init__
|
||||
builds the widgets that reference them) but defers the actual
|
||||
redraw to an idle callback, since `self.canvas` doesn't exist yet
|
||||
at this point in __init__."""
|
||||
if not self._DEV_SESSION_PATH.exists():
|
||||
return
|
||||
try:
|
||||
with open(self._DEV_SESSION_PATH, "rb") as f:
|
||||
data = pickle.load(f)
|
||||
self.board = data["board"]
|
||||
self.screenshot_import = data["screenshot_import"]
|
||||
self._DEV_SESSION_PATH.unlink()
|
||||
print("fenigma: dev session restored", flush=True)
|
||||
except Exception as exc:
|
||||
print(f"fenigma: dev session restore failed: {exc!r}", flush=True)
|
||||
return
|
||||
GLib.idle_add(self._dev_finish_session_restore)
|
||||
|
||||
def _dev_finish_session_restore(self) -> bool:
|
||||
self._refresh()
|
||||
self._refresh_proposals()
|
||||
if self.screenshot_import is not None:
|
||||
self.canvas.set_screenshot(self.screenshot_import.overlay, self.screenshot_import.px_per_km)
|
||||
return GLib.SOURCE_REMOVE
|
||||
|
||||
def _accept_proposal(self, proposal, type_=None) -> None:
|
||||
coord = _coord_from_proposal(proposal)
|
||||
if coord is None:
|
||||
return
|
||||
if type_ is None:
|
||||
type_ = icons.target_type_from_icon(proposal.unit) or TargetType.UNKNOWN
|
||||
if proposal.side == "friendly":
|
||||
self.board.add_ally(type_, coord)
|
||||
is_ally = proposal.side == "friendly"
|
||||
# Prefer the marker's own detected "#<N>" id (map_vision.read_marker_id,
|
||||
# already past its own confidence floor by the time it's non-None)
|
||||
# over an auto-assigned letter -- lets an accepted unit's id match
|
||||
# what's actually on screen, rather than every accept silently
|
||||
# becoming A/B/C regardless of what the game itself calls it.
|
||||
# Falls back to auto-assign (id_=None) on a collision: two markers
|
||||
# misread to the same id, or a real id that just happens to match
|
||||
# one already auto-assigned, either way a duplicate id is worse
|
||||
# than losing this one accept's traceability to the game's own
|
||||
# number.
|
||||
id_ = proposal.detected_id
|
||||
group = self.board.allies if is_ally else self.board.targets
|
||||
existing = {o.id for o in group if o.type == type_} # per-type, same as Board.add_target/add_ally
|
||||
if id_ in existing:
|
||||
id_ = None
|
||||
if is_ally:
|
||||
self.board.add_ally(type_, coord, id_=id_)
|
||||
else:
|
||||
self.board.add_target(type_, coord)
|
||||
self.board.add_target(type_, coord, id_=id_)
|
||||
proposal.accepted = True
|
||||
proposal.confirmed_type = type_.name
|
||||
|
||||
@@ -722,7 +795,12 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
moment it can still be tied to this specific image."""
|
||||
added_targets = [t for t in self.board.targets if t not in imp.baseline_targets]
|
||||
added_allies = [a for a in self.board.allies if a not in imp.baseline_allies]
|
||||
debug_capture.save_marker_ground_truth(imp.image, imp.proposals, added_targets, added_allies)
|
||||
# full_image over image: a human checking a detected_id against
|
||||
# this capture later needs to actually read that tiny text, see
|
||||
# save_marker_ground_truth's own docstring.
|
||||
debug_capture.save_marker_ground_truth(
|
||||
imp.full_image if imp.full_image is not None else imp.image,
|
||||
imp.proposals, added_targets, added_allies)
|
||||
|
||||
def _remove_screenshot(self) -> None:
|
||||
"""Dropping the screenshot also drops every proposal never accepted:
|
||||
@@ -773,9 +851,17 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
box = page()
|
||||
lbl = Gtk.Label(xalign=0, margin_start=4, margin_bottom=2)
|
||||
side = "friendly" if proposal.side == "friendly" else "hostile"
|
||||
# detected_id (map_vision.read_marker_id's best-effort read of
|
||||
# the marker's own "#<N>" label, see its own docstring) is
|
||||
# shown here so it's visible right when there's still a
|
||||
# screenshot to actually check it against -- accept already
|
||||
# uses it for the entity's id when present (see
|
||||
# _accept_proposal), this is just making that fact visible
|
||||
# before the click, not a separate signal.
|
||||
id_part = f", id #{proposal.detected_id}" if proposal.detected_id else ""
|
||||
lbl.set_markup(
|
||||
f"<b>{GLib.markup_escape_text(proposal.coord)}</b> — {side}, "
|
||||
f"{detected.value if detected else 'type unknown'}")
|
||||
f"{detected.value if detected else 'type unknown'}{GLib.markup_escape_text(id_part)}")
|
||||
box.append(lbl)
|
||||
box.append(Gtk.Separator(margin_top=2, margin_bottom=2))
|
||||
button(box, f"Accept as {detected.value if detected else TargetType.UNKNOWN.value}",
|
||||
@@ -794,15 +880,29 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
# real pickable unit types (see their own comments in
|
||||
# models.py) and shouldn't have been offered as "what this
|
||||
# detected marker actually is".
|
||||
grid = icons.build_target_type_grid(
|
||||
detected, lambda t: accept(t), is_ally=(proposal.side == "friendly"),
|
||||
)
|
||||
scroller = Gtk.ScrolledWindow(propagate_natural_height=True,
|
||||
propagate_natural_width=True,
|
||||
max_content_height=340,
|
||||
hscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||
scroller.set_child(icons.build_target_type_grid(
|
||||
detected, lambda t: accept(t), is_ally=(proposal.side == "friendly"),
|
||||
))
|
||||
scroller.set_child(grid)
|
||||
box.append(scroller)
|
||||
# Swapping the child of an ALREADY-open Popover and re-popup()ing
|
||||
# it reports the right size internally (visible=True, sane
|
||||
# width/height) but the compositor never actually repaints the
|
||||
# reused surface -- confirmed live: nothing appears on screen no
|
||||
# matter how many times it's reopened. Popping the OLD popover
|
||||
# down and opening a genuinely NEW one (fresh native surface,
|
||||
# same anchor point) instead of resizing the existing one
|
||||
# sidesteps that.
|
||||
nonlocal popover
|
||||
old_popover = popover
|
||||
popover = self._popover_at(x, y)
|
||||
popover.set_child(box)
|
||||
old_popover.popdown()
|
||||
popover.popup()
|
||||
|
||||
show_main()
|
||||
popover.popup()
|
||||
@@ -1521,10 +1621,31 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
# panel's own alive button offers, just reachable from the
|
||||
# map too rather than only from the sidebar.
|
||||
button(box, "Mark destroyed" if obj.alive else "Mark alive", toggle_alive)
|
||||
# Underground is Target-only too, see Target.underground_tier's
|
||||
# own comment -- no such thing as an underground Ally.
|
||||
ug_label = ("Mark underground" if obj.underground_tier is None
|
||||
else f"Underground (tier {obj.underground_tier})")
|
||||
button(box, ug_label, show_underground)
|
||||
if not isinstance(obj, Nest):
|
||||
button(box, "Delete", delete, css="destructive-action")
|
||||
popover.set_child(box)
|
||||
|
||||
def _reopen_with(box) -> None:
|
||||
"""Swapping an ALREADY-open Popover's child via set_child()
|
||||
alone reports the right size (visible=True, sane width/height)
|
||||
but the compositor never actually repaints the reused surface
|
||||
on some setups -- confirmed live, nothing appears on screen no
|
||||
matter how many times it's reopened. Popping the OLD popover
|
||||
down and opening a genuinely NEW one at the same anchor point
|
||||
(fresh native surface, not an in-place resize) sidesteps it.
|
||||
Shared by every page past show_main() in this menu."""
|
||||
nonlocal popover
|
||||
old_popover = popover
|
||||
popover = self._popover_at(x, y)
|
||||
popover.set_child(box)
|
||||
old_popover.popdown()
|
||||
popover.popup()
|
||||
|
||||
def show_type():
|
||||
box = page()
|
||||
heading(box, "Type")
|
||||
@@ -1542,7 +1663,7 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
obj.type, lambda t: set_type(t), is_ally=isinstance(obj, Ally),
|
||||
))
|
||||
box.append(scroller)
|
||||
popover.set_child(box)
|
||||
_reopen_with(box)
|
||||
|
||||
def set_type(t):
|
||||
obj.type = t
|
||||
@@ -1551,6 +1672,26 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
self.toast(f"{_display_name(obj)} is now a "
|
||||
f"{icons.target_type_label(t, isinstance(obj, Ally))}.")
|
||||
|
||||
def show_underground():
|
||||
box = page()
|
||||
heading(box, "Underground")
|
||||
scroller = Gtk.ScrolledWindow(propagate_natural_height=True,
|
||||
propagate_natural_width=True,
|
||||
max_content_height=340,
|
||||
hscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||
scroller.set_child(icons.build_underground_tier_grid(
|
||||
obj.underground_tier, lambda tier: set_underground(tier),
|
||||
))
|
||||
box.append(scroller)
|
||||
_reopen_with(box)
|
||||
|
||||
def set_underground(tier):
|
||||
obj.underground_tier = tier
|
||||
self._refresh()
|
||||
popover.popdown()
|
||||
self.toast(f"{_display_name(obj)} is no longer underground." if tier is None
|
||||
else f"{_display_name(obj)} is now underground (tier {tier}).")
|
||||
|
||||
def show_id():
|
||||
box = page()
|
||||
heading(box, "ID")
|
||||
@@ -1581,16 +1722,18 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
return
|
||||
elif field == "id" and isinstance(obj, (Target, Ally)):
|
||||
# Same invariant as Board.add_target/add_ally's auto-id
|
||||
# (see their comments): one shared id namespace per group,
|
||||
# targets vs allies, never split further by type. A manual
|
||||
# rename has to keep that too, or you get two entities
|
||||
# that both read as e.g. "...#A" with only the type prefix
|
||||
# telling them apart.
|
||||
# (see their comments): id namespace is targets-vs-allies,
|
||||
# AND scoped per type within that -- a Tank#1 and an
|
||||
# Infantry#1 are not a collision, only two entities of the
|
||||
# SAME type sharing an id are. A manual rename has to keep
|
||||
# that too, or you get two entities that both read as
|
||||
# e.g. "Infantry#1" with nothing telling them apart.
|
||||
value = text
|
||||
siblings = self.board.targets if isinstance(obj, Target) else self.board.allies
|
||||
group = self.board.targets if isinstance(obj, Target) else self.board.allies
|
||||
siblings = [o for o in group if o.type == obj.type]
|
||||
if any(o is not obj and o.id == value for o in siblings):
|
||||
kind = "target" if isinstance(obj, Target) else "ally"
|
||||
self.toast(f"Another {kind} already has id {value!r}.")
|
||||
self.toast(f"Another {obj.type.short} {kind} already has id {value!r}.")
|
||||
return
|
||||
else:
|
||||
value = text
|
||||
|
||||
@@ -101,7 +101,17 @@ def save_marker_ground_truth(image, proposals, added_targets=(), added_allies=()
|
||||
matter: a rejected proposal is a false positive to fix, a manually-
|
||||
added unit that had no matching proposal at all is a miss to fix.
|
||||
Skipped entirely if there's nothing to say (no proposals AND no
|
||||
manually-added units), a screenshot nobody ever looked at units on."""
|
||||
manually-added units), a screenshot nobody ever looked at units on.
|
||||
|
||||
Each proposal also carries `detected_id` (map_vision.read_marker_id's
|
||||
best-effort read of the marker's own "#<N>" id label, see its own
|
||||
docstring -- not yet validated against a real batch of this exact
|
||||
ground truth, which is precisely what these captures are for).
|
||||
`image` should be the sharpest one the caller has (full_image over
|
||||
the WORK_W-downscaled one, see ScreenshotImport.full_image) so a
|
||||
human reviewing a capture later can actually read that id text well
|
||||
enough to judge whether detected_id was right -- not just take the
|
||||
detector's word for it."""
|
||||
if not proposals and not added_targets and not added_allies:
|
||||
return None
|
||||
png = _to_png_bytes(image)
|
||||
@@ -120,6 +130,8 @@ def save_marker_ground_truth(image, proposals, added_targets=(), added_allies=()
|
||||
{
|
||||
"side": p.side, "label": p.label, "sub_x": p.sub_x, "sub_y": p.sub_y,
|
||||
"detected_unit": p.unit, "verdict": verdict(p), "confirmed_type": p.confirmed_type,
|
||||
"detected_id": p.detected_id,
|
||||
"unit_score": p.unit_score, "unit_margin": p.unit_margin,
|
||||
}
|
||||
for p in proposals
|
||||
],
|
||||
|
||||
@@ -12,6 +12,7 @@ from collections import namedtuple
|
||||
import cairo
|
||||
import gi
|
||||
import numpy as np
|
||||
from PIL import Image as PILImage
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Gdk", "4.0")
|
||||
@@ -195,6 +196,44 @@ def _icon_for(category: str, obj) -> cairo.ImageSurface | None:
|
||||
return None
|
||||
|
||||
|
||||
# path -> (surface, content_bbox) for additive badges specifically.
|
||||
# Separate from _ICON_SURFACE_CACHE because these also need their real
|
||||
# opaque content's bounding box: unlike the unit icons (already ~edge to
|
||||
# edge in their own canvas, see _draw_icon_marker), the additive art
|
||||
# (assets/icons/targets/additives/) sits inside a lot of transparent
|
||||
# padding that isn't even centered -- scaling/positioning off the full
|
||||
# 256x256 canvas made the badge look tiny and float with a visible gap
|
||||
# above the icon it's supposed to touch. bbox is None for a path that
|
||||
# failed to load, or (l, t, r, b) of its actual opaque pixels.
|
||||
_ADDITIVE_CACHE: dict = {}
|
||||
|
||||
|
||||
def _additive_surface(path) -> tuple:
|
||||
if path not in _ADDITIVE_CACHE:
|
||||
surface, bbox = None, None
|
||||
try:
|
||||
surface = cairo.ImageSurface.create_from_png(str(path))
|
||||
bbox = PILImage.open(str(path)).getbbox()
|
||||
except Exception:
|
||||
pass
|
||||
_ADDITIVE_CACHE[path] = (surface, bbox)
|
||||
return _ADDITIVE_CACHE[path]
|
||||
|
||||
|
||||
def _additive_for(category: str, obj) -> tuple | None:
|
||||
"""The underground-tier badge overlaid on top of a Target's own icon,
|
||||
or None. Target-only (see Target.underground_tier's own comment)."""
|
||||
if category != "target":
|
||||
return None
|
||||
tier = getattr(obj, "underground_tier", None)
|
||||
if tier is None:
|
||||
return None
|
||||
surface, bbox = _additive_surface(icons.underground_icon_path(tier))
|
||||
if surface is None:
|
||||
return None
|
||||
return (surface, bbox)
|
||||
|
||||
|
||||
class GridCanvas(Gtk.DrawingArea):
|
||||
def __init__(self, board: Board) -> None:
|
||||
super().__init__()
|
||||
@@ -599,8 +638,24 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
what a proposal is until the user accepts it."""
|
||||
for p, coord in self._pending_proposals():
|
||||
color = CATEGORY_COLOR["ally" if p.side == "friendly" else "target"]
|
||||
# No coord here: _draw_marker already shows one right below
|
||||
# this label (`coord=coord` below), repeating it in the main
|
||||
# label too was pure noise. detected_type/detected_id
|
||||
# (map_vision.classify_marker/read_marker_id's best-effort
|
||||
# reads) shown instead when known -- same "? Mechanized#3"
|
||||
# shape an accepted entity's own name takes (Target.name),
|
||||
# checkable against the actual screenshot pixels while it's
|
||||
# still up, and detected_id is the same id _accept_proposal
|
||||
# will use for the entity if this gets accepted.
|
||||
detected_type = icons.target_type_from_icon(p.unit)
|
||||
# .short, not target_type_label(): matches Target.name/Ally.name's
|
||||
# own naming exactly ("SupplyCache" not "Supply Cache"), so this
|
||||
# preview label reads the same as what accepting it produces.
|
||||
type_part = detected_type.short if detected_type else ""
|
||||
id_part = f"#{p.detected_id}" if p.detected_id else ""
|
||||
label = f"? {type_part}{id_part}" if (type_part or id_part) else "?"
|
||||
self._draw_marker(cr, view, coord.as_fraction(), color,
|
||||
f"? {coord.label()}", width, height,
|
||||
label, width, height,
|
||||
hollow=True, coord=coord)
|
||||
|
||||
def _hit_test(self, view: _View, x: float, y: float):
|
||||
@@ -817,7 +872,8 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
dim=(category == "target" and not obj.alive) or obj.hidden,
|
||||
selected=(obj is self.selected), coord=obj.coord,
|
||||
extra_line=getattr(obj, "requested_time", None),
|
||||
icon_surface=_icon_for(category, obj))
|
||||
icon_surface=_icon_for(category, obj),
|
||||
additive=_additive_for(category, obj))
|
||||
|
||||
for category, obj in self.board.ambiguous_entities_all():
|
||||
if self._excluded_from_map(obj):
|
||||
@@ -878,7 +934,8 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
|
||||
def _draw_marker(self, cr, view, point_km, color, label,
|
||||
canvas_width, canvas_height, *, hollow=False, dim=False,
|
||||
selected=False, coord=None, extra_line=None, icon_surface=None) -> None:
|
||||
selected=False, coord=None, extra_line=None, icon_surface=None,
|
||||
additive=None) -> None:
|
||||
x, y = self._km_to_px(view, point_km)
|
||||
r, g, b = color
|
||||
alpha = 0.45 if dim else 1.0
|
||||
@@ -898,6 +955,8 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
# plain filled dot is more honest about the current zoom level.
|
||||
if not hollow and icon_surface is not None and view.cell_w >= ICON_MIN_CELL_PX:
|
||||
self._draw_icon_marker(cr, x, y, icon_surface, alpha)
|
||||
if additive is not None:
|
||||
self._draw_additive_badge(cr, x, y, additive, alpha)
|
||||
elif hollow:
|
||||
cr.new_path() # cairo's arc() draws a line from any stale current
|
||||
cr.set_source_rgba(r, g, b, alpha) # point (e.g. the last label's
|
||||
@@ -962,6 +1021,48 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
cr.paint_with_alpha(alpha)
|
||||
cr.restore()
|
||||
|
||||
# How far the badge's content bbox sinks into the icon's, in the
|
||||
# icon's own 32px box units. Both the diamond's top corner and the
|
||||
# Armor badge's bottom are tapered to a near-point, not a flat edge
|
||||
# (see assets/icons/targets/enemy/Enemy_Infantry.png and the Armor
|
||||
# additives) -- lining up their bboxes exactly *touching* leaves them
|
||||
# meeting at a single pixel with no visual mass on either side of it,
|
||||
# which still reads as a gap. A real pixel overlap is what actually
|
||||
# looks contiguous, confirmed against the game's own stacked-badge
|
||||
# screenshots (stars/helmet/diamond all overlapping, not edge-to-edge).
|
||||
_ADDITIVE_OVERLAP_PX = 10.0
|
||||
|
||||
def _draw_additive_badge(self, cr, x, y, additive, alpha) -> None:
|
||||
"""A badge (underground tier, currently the only additive) drawn
|
||||
directly north of the icon marker, overlapping down into it by
|
||||
`_ADDITIVE_OVERLAP_PX`, at the same full size as the marker
|
||||
itself -- stacked above it rather than shrunk into a corner, so
|
||||
it reads as its own clearly-legible symbol, not a tiny decoration
|
||||
obscuring the unit icon it modifies.
|
||||
|
||||
Scaled/positioned off the source art's actual opaque content
|
||||
(`bbox`), not its full canvas: the additive PNGs carry a lot of
|
||||
transparent padding that isn't even centered (see _ADDITIVE_CACHE's
|
||||
comment), so sizing/placing off the raw canvas made the badge look
|
||||
tiny and float with a visible gap above the icon -- using bbox
|
||||
instead makes what's actually drawn sit right against it."""
|
||||
surface, bbox = additive
|
||||
sw, sh = surface.get_width(), surface.get_height()
|
||||
left, top, right, bottom = bbox if bbox is not None else (0, 0, sw, sh)
|
||||
content_w, content_h = right - left, bottom - top
|
||||
if content_w <= 0 or content_h <= 0:
|
||||
return
|
||||
box = 32.0 # same visual size as the icon marker's own box
|
||||
scale = box / max(content_w, content_h)
|
||||
icon_top = y - 16 # _draw_icon_marker's own box=32, centered on y
|
||||
ty = icon_top - bottom * scale + self._ADDITIVE_OVERLAP_PX
|
||||
cr.save()
|
||||
cr.translate(x - (left + right) / 2 * scale, ty)
|
||||
cr.scale(scale, scale)
|
||||
cr.set_source_surface(surface, 0, 0)
|
||||
cr.paint_with_alpha(alpha)
|
||||
cr.restore()
|
||||
|
||||
def _draw_firing_arrows(self, cr, view) -> None:
|
||||
"""Red arrow(s) Nest -> Target, for whatever's hovered or selected.
|
||||
Points at exactly the hovered/selected candidate when one is known
|
||||
|
||||
@@ -449,6 +449,76 @@ def target_type_icon_image(target_type: "TargetType", is_ally: bool = False, wid
|
||||
return _plain_dot(is_ally, width)
|
||||
|
||||
|
||||
_ADDITIVES_DIR = _ICONS_DIR / "targets" / "additives"
|
||||
UNDERGROUND_TIERS = (1, 2, 3)
|
||||
|
||||
|
||||
def underground_icon_path(tier: int) -> Path:
|
||||
"""The badge overlaid on a Target's own icon when it's marked
|
||||
underground at this tier (1..3, harder to hit = higher). Reuses the
|
||||
game's own Armor-tier additive art (assets/icons/targets/additives/
|
||||
Additive_Armor{1,2,3}.png) rather than inventing bespoke "underground"
|
||||
art of our own -- there's nothing else in the game's icon set for
|
||||
"buried/fortified", and Armor's own visual (a plate) already reads
|
||||
right for that."""
|
||||
return _ADDITIVES_DIR / f"Additive_Armor{tier}.png"
|
||||
|
||||
|
||||
def underground_tier_image(tier: int | None, width: int = _TYPE_GRID_ICON_WIDTH) -> Gtk.Widget:
|
||||
"""A widget for one cell of the underground-tier picker: the additive
|
||||
badge itself for a real tier, or a plain dot (this module's usual
|
||||
'nothing chosen' placeholder) for the "not underground" cell."""
|
||||
if tier is None:
|
||||
return _plain_dot(False, width)
|
||||
path = underground_icon_path(tier)
|
||||
if path.exists():
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(path), width, -1, True)
|
||||
picture = Gtk.Picture.new_for_pixbuf(pixbuf)
|
||||
picture.set_content_fit(Gtk.ContentFit.CONTAIN)
|
||||
picture.set_can_shrink(True)
|
||||
picture.set_size_request(pixbuf.get_width(), pixbuf.get_height())
|
||||
return picture
|
||||
return _plain_dot(False, width)
|
||||
|
||||
|
||||
def build_underground_tier_grid(selected: int | None, on_pick) -> Gtk.Widget:
|
||||
"""Same radio-style grid idea as build_target_type_grid, just over
|
||||
(None, 1, 2, 3) instead of TargetType -- None first, as "not
|
||||
underground" (clearing an existing tier) is exactly as valid a pick
|
||||
as any real tier, not a separate "remove" action bolted on
|
||||
afterward."""
|
||||
_ensure_icon_button_css()
|
||||
leader: Gtk.ToggleButton | None = None
|
||||
items = [None, *UNDERGROUND_TIERS]
|
||||
|
||||
def make_button(tier: int | None) -> Gtk.Widget:
|
||||
nonlocal leader
|
||||
cell = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
|
||||
margin_top=4, margin_bottom=4, margin_start=2, margin_end=2)
|
||||
cell.append(underground_tier_image(tier))
|
||||
label = Gtk.Label(label="None" if tier is None else f"Tier {tier}",
|
||||
wrap=False, single_line_mode=True,
|
||||
justify=Gtk.Justification.CENTER, width_chars=9,
|
||||
max_width_chars=9, ellipsize=Pango.EllipsizeMode.END)
|
||||
label.add_css_class("caption")
|
||||
label.add_css_class("dim-label")
|
||||
cell.append(label)
|
||||
btn = Gtk.ToggleButton(child=cell)
|
||||
btn.add_css_class("flat")
|
||||
btn.add_css_class(_ICON_BUTTON_CSS_CLASS)
|
||||
btn.set_tooltip_text("Not underground" if tier is None else f"Underground, tier {tier}")
|
||||
if leader is None:
|
||||
leader = btn
|
||||
else:
|
||||
btn.set_group(leader)
|
||||
if tier is selected:
|
||||
btn.set_active(True)
|
||||
btn.connect("clicked", lambda _b, tier=tier: on_pick(tier))
|
||||
return btn
|
||||
|
||||
return _build_icon_grid(items, _TYPE_GRID_COLUMNS, make_button)
|
||||
|
||||
|
||||
def _has_own_icon(t: "TargetType", is_ally: bool) -> bool:
|
||||
"""Whether THIS side specifically has real art for t -- as opposed to
|
||||
target_icon_path() quietly handing back the other side's icon because
|
||||
|
||||
@@ -41,6 +41,13 @@ class Proposal:
|
||||
box: tuple
|
||||
accepted: bool = False
|
||||
rejected: bool = False
|
||||
# classify_marker's own raw numbers behind `unit` (best-match score,
|
||||
# and its margin over the runner-up) -- unit alone only says whether
|
||||
# it beat min_score/min_margin, not by how much or how close a call
|
||||
# it was. Ground truth needs these to tell "confidently wrong" apart
|
||||
# from "just barely missed the bar", which `unit=None` alone can't.
|
||||
unit_score: float = 0.0
|
||||
unit_margin: float = 0.0
|
||||
# The TargetType.name actually applied when accepted -- usually just
|
||||
# `unit` translated through icons.target_type_from_icon, but can
|
||||
# differ if the user corrected it via "Accept as...". Set by
|
||||
@@ -48,6 +55,15 @@ class Proposal:
|
||||
# debug_capture.save_marker_ground_truth: `unit` is what the
|
||||
# classifier guessed, this is what the user actually confirmed.
|
||||
confirmed_type: str | None = None
|
||||
# The marker's own "#<N>" id label, as read off the screenshot by
|
||||
# map_vision.read_marker_id -- distinct from `label`/sub_x/sub_y
|
||||
# (the grid CELL this marker is in), this is the small per-unit id
|
||||
# the game itself draws. None when unread/unconfident (see
|
||||
# read_marker_id's own docstring: best-effort, not yet validated
|
||||
# against a real ground-truth batch). Meant for future dedup work
|
||||
# (see TODO.md) once there's confidence in the read; not otherwise
|
||||
# consumed yet.
|
||||
detected_id: str | None = None
|
||||
|
||||
@property
|
||||
def coord(self) -> str:
|
||||
@@ -95,7 +111,10 @@ class ScreenshotImport:
|
||||
self.proposals = [
|
||||
Proposal(side=m["side"], label=m["label"], sub_x=m["sub_x"],
|
||||
sub_y=m["sub_y"], unit=m.get("unit"),
|
||||
centre=m["centre"], box=m["box"]) for m in markers]
|
||||
centre=m["centre"], box=m["box"],
|
||||
detected_id=m.get("detected_id"),
|
||||
unit_score=m.get("unit_score", 0.0),
|
||||
unit_margin=m.get("unit_margin", 0.0)) for m in markers]
|
||||
return self.proposals
|
||||
|
||||
def build_overlay(self, px_per_km=150):
|
||||
@@ -200,9 +219,16 @@ class ImportJob:
|
||||
|
||||
Fills imp.proposals and delivers on_done(imp, error). Its own thread,
|
||||
because the user's grid correction sits between the two phases.
|
||||
|
||||
Marker detection itself always runs against imp.image (WORK_W,
|
||||
same as solving used); imp.full_image is passed through only for
|
||||
reading each marker's own tiny id label off a sharper source, see
|
||||
map_vision.find_markers' own id_img param.
|
||||
"""
|
||||
def work():
|
||||
imp.set_proposals(map_vision.find_markers(imp.image, imp.solution))
|
||||
id_img = imp.full_image # None is fine, find_markers falls back to imp.image
|
||||
imp.set_proposals(map_vision.find_markers(
|
||||
imp.image, imp.solution, id_img=id_img, id_scale=imp.full_image_scale))
|
||||
return imp, None
|
||||
|
||||
return self._run(work, on_done, "map-markers")
|
||||
|
||||
@@ -486,6 +486,62 @@ def read_cell_label(cell_gray, glyph_fracs=(0.10, 0.13, 0.17)):
|
||||
return best
|
||||
|
||||
|
||||
# Every marker the game draws also carries a small "#<N>" id label just
|
||||
# above-left of its icon (distinct from the big per-cell grid label
|
||||
# read_cell_label reads) -- calibrated by eye against a real screenshot
|
||||
# saved under debug_captures/marker_ground_truth: it sits roughly one
|
||||
# marker-width to the left and level with the marker's own top edge.
|
||||
# Observed ids in practice are small (single or double digit); 1-99
|
||||
# covers that generously without the search space growing large.
|
||||
MARKER_ID_CANDIDATES = [f"#{n}" for n in range(1, 100)]
|
||||
MIN_MARKER_ID_SCORE = 0.55 # unmeasured starting point, see read_marker_id's own docstring
|
||||
|
||||
|
||||
def read_marker_id(gray, box, glyph_fracs=(0.30, 0.40, 0.50, 0.60)):
|
||||
"""Which '#<N>' id best explains the pixels just above-left of this
|
||||
marker? Same template-correlation approach as read_cell_label, and
|
||||
for the same reason (see this module's own docstring): this text
|
||||
sits over the same aerial-photo backdrop that defeated every
|
||||
detection-based approach tried for grid labels, so glyph correlation
|
||||
against a known-position crop is used here too rather than OCR.
|
||||
|
||||
`box` is the marker's own detected (x, y, w, h), in `gray`'s pixel
|
||||
space -- the caller is responsible for scaling it if `gray` isn't
|
||||
the same image the marker was detected in (see find_markers' own
|
||||
id_img/id_scale params, for reading against a sharper source than
|
||||
detection ran on).
|
||||
|
||||
Best-effort and NOT validated against a real ground-truth batch yet
|
||||
(unlike read_cell_label's measured 0.73-0.87 vs 0.40-0.56 -- there's
|
||||
no equivalent number here): both the crop region and
|
||||
MIN_MARKER_ID_SCORE are a single-screenshot calibration, expect this
|
||||
to need retuning once there's a real batch of debug_capture ground
|
||||
truth with confirmed ids to check against (see TODO.md). Returns
|
||||
None below the threshold rather than guessing.
|
||||
"""
|
||||
x, y, w, h = box
|
||||
left = max(0, int(x - 1.0 * w))
|
||||
top = max(0, int(y - 0.45 * h))
|
||||
right = min(gray.shape[1], int(x + 0.65 * w))
|
||||
bottom = min(gray.shape[0], int(y + 0.55 * h))
|
||||
if right - left < 6 or bottom - top < 6:
|
||||
return None
|
||||
patch = np.ascontiguousarray(gray[top:bottom, left:right])
|
||||
best = (None, -1.0)
|
||||
for gf in glyph_fracs:
|
||||
th = max(6, int(gf * h))
|
||||
for cand in MARKER_ID_CANDIDATES:
|
||||
t = glyph_template(cand, th)
|
||||
if t is None or t.shape[0] >= patch.shape[0] or t.shape[1] >= patch.shape[1]:
|
||||
continue
|
||||
sc = float(cv2.matchTemplate(patch, t, cv2.TM_CCOEFF_NORMED).max())
|
||||
if sc > best[1]:
|
||||
best = (cand, sc)
|
||||
if best[1] < MIN_MARKER_ID_SCORE:
|
||||
return None
|
||||
return best[0].lstrip("#")
|
||||
|
||||
|
||||
def visible_cells(H, shape, limit=6):
|
||||
"""Lattice cells whose centre is on screen, nearest the frame centre
|
||||
first (least perspective distortion, so the easiest to read)."""
|
||||
@@ -961,18 +1017,33 @@ def diamonds(mask, cell_px, shape="diamond"):
|
||||
MARKER_SHAPE = {"hostile": "diamond", "friendly": "rect"}
|
||||
|
||||
|
||||
def find_markers(img, sol):
|
||||
"""-> list of dicts: side, unit, label, sub_x, sub_y, coord, centre, box."""
|
||||
def find_markers(img, sol, id_img=None, id_scale=1.0):
|
||||
"""-> list of dicts: side, unit, label, sub_x, sub_y, coord, centre, box,
|
||||
detected_id.
|
||||
|
||||
`id_img`/`id_scale`: read each marker's small "#<N>" id label (see
|
||||
read_marker_id) against a sharper source than detection ran on --
|
||||
ScreenshotImport.full_image over the WORK_W-downscaled `img`, same
|
||||
reasoning as build_overlay's own img_scale (id text is tiny; reading
|
||||
it off the downscaled image loses too much detail). `id_scale` is
|
||||
id_img's width / img's width. Detection itself (marker
|
||||
position/shape/color, unit classification) always runs against `img`
|
||||
-- only the id read benefits from more resolution. Falls back to
|
||||
reading against `img` itself when id_img is None (still better than
|
||||
nothing, just at WORK_W's lower detail)."""
|
||||
cell = max(sol.steps)
|
||||
found = []
|
||||
id_gray = cv2.cvtColor(id_img if id_img is not None else img, cv2.COLOR_BGR2GRAY)
|
||||
for side, mask in zip(("hostile", "friendly"), marker_masks(img)):
|
||||
for (cx, cy, box) in diamonds(mask, cell, MARKER_SHAPE[side]):
|
||||
c = sol.cell_of(cx, cy)
|
||||
if c is None:
|
||||
continue
|
||||
unit, score, margin = classify_marker(img, box, side)
|
||||
id_box = box if id_scale == 1.0 else tuple(v * id_scale for v in box)
|
||||
detected_id = read_marker_id(id_gray, id_box)
|
||||
found.append(dict(side=side, unit=unit, unit_score=score,
|
||||
unit_margin=margin, label=c[0], sub_x=c[1],
|
||||
sub_y=c[2], coord=format_coord(c),
|
||||
centre=(cx, cy), box=box))
|
||||
centre=(cx, cy), box=box, detected_id=detected_id))
|
||||
return found
|
||||
|
||||
@@ -411,6 +411,10 @@ class Target:
|
||||
# raw string as printed, this app doesn't track a game clock to compare
|
||||
# it against, it's shown as-is for the player's own reference.
|
||||
requested_time: str | None = None
|
||||
# None = not underground. 1..3 = underground, at that hardening tier
|
||||
# (see icons.UNDERGROUND_TIERS) -- higher survives more. Target-only:
|
||||
# there's no such thing as an underground Ally in this game.
|
||||
underground_tier: int | None = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -500,19 +504,29 @@ class ScoutFlight:
|
||||
|
||||
|
||||
def _next_free_id(used: set[str]) -> str:
|
||||
"""Next unused id in a short, human-friendly sequence: single
|
||||
"""Next unused id in a short, human-friendly LETTER sequence: single
|
||||
uppercase letters (A..Z) first, then two-letter combinations
|
||||
(AA..ZZ, spreadsheet-column style) once those run out, and so on.
|
||||
Scoped per TYPE, not per group: add_target()/add_ally() only look at
|
||||
existing entities of the SAME type when building `used`, so a Tank
|
||||
and an Infantry added back to back both start at 'A' (Tank#A,
|
||||
Infantry#A), each type keeping its own independent sequence.
|
||||
|
||||
A real regression lived here: `next(c for c in string.ascii_uppercase
|
||||
if c not in used)` raises StopIteration the instant all 26 letters
|
||||
are taken, which used to need 26+ auto-added entities of one TYPE
|
||||
(rare) but, once add_target()/add_ally() moved to one shared id
|
||||
sequence per GROUP instead of per type (so a Tank and an Infantry
|
||||
added back to back get 'A'/'B', not both 'A', see their own
|
||||
comments), needs only 26 auto-added entities of ANY type in that
|
||||
group -- reachable in a single big screenshot import. This can't run
|
||||
out: it just grows the id length instead."""
|
||||
Deliberately letters, not numbers: a manually-added entity (map
|
||||
right-click "Add target", or an accepted screenshot proposal with no
|
||||
confident id read) has no real game id to report, so it gets an
|
||||
obviously-not-a-real-id placeholder instead -- app.py's
|
||||
_accept_proposal reserves plain numbers for an id it's actually
|
||||
confident was read off the marker itself (map_vision.read_marker_id
|
||||
via Proposal.detected_id), passed straight through as this
|
||||
function's caller's `id_` and never touching this auto-assignment at
|
||||
all. Letters can't collide with a real (numeric) detected id either,
|
||||
on top of just reading honestly as 'made up'.
|
||||
|
||||
Can't run out the way `next(c for c in string.ascii_uppercase if c
|
||||
not in used)` used to (a real regression, see TODO.md): rolls over to
|
||||
two-letter ids ('AA', 'AB', ...) past 26 instead of raising
|
||||
StopIteration."""
|
||||
length = 1
|
||||
while True:
|
||||
for combo in itertools.product(string.ascii_uppercase, repeat=length):
|
||||
@@ -592,13 +606,17 @@ class Board:
|
||||
location: Location | Coord | None = None,
|
||||
id_: str | None = None,
|
||||
) -> Target:
|
||||
# One shared A/B/C... sequence across every target regardless of
|
||||
# type, not one sequence per type -- a Tank and an Infantry auto-
|
||||
# assigned back to back get 'A' and 'B', never both 'A'. Only
|
||||
# targets-vs-allies is a separate id namespace (see add_ally),
|
||||
# type never subdivides it further.
|
||||
# Own A/B/C... sequence per TYPE, not one shared across every
|
||||
# target regardless of type -- a Tank and an Infantry auto-
|
||||
# assigned back to back both start at 'A' (Tank#A, Infantry#A).
|
||||
# targets-vs-allies is still its own separate id namespace (see
|
||||
# add_ally); type now subdivides it further too. Letters, not
|
||||
# numbers, when auto-assigning here specifically: see
|
||||
# _next_free_id's own docstring for why (a real detected id, when
|
||||
# there is one, is passed in as `id_` and never reaches this
|
||||
# auto-assignment at all).
|
||||
if not id_:
|
||||
used = {t.id for t in self.targets}
|
||||
used = {t.id for t in self.targets if t.type == type_}
|
||||
id_ = _next_free_id(used)
|
||||
t = Target(type=type_, id=id_, location=_as_location(location))
|
||||
self.targets.append(t)
|
||||
@@ -614,13 +632,13 @@ class Board:
|
||||
location: Location | Coord | None = None,
|
||||
id_: str | None = None,
|
||||
) -> Ally:
|
||||
# A separate id namespace from add_target()'s: an ally Tank#1
|
||||
# and a hostile Target Tank#1 are unrelated, so auto-assignment
|
||||
# here only looks at other allies, never self.targets. Same as
|
||||
# add_target though, that's the ONLY split: one shared A/B/C...
|
||||
# sequence across every ally regardless of type, not one per type.
|
||||
# A separate id namespace from add_target()'s: an ally Tank#A
|
||||
# and a hostile Target Tank#A are unrelated, so auto-assignment
|
||||
# here only looks at other allies, never self.targets. Own
|
||||
# A/B/C... sequence per TYPE too, same as add_target -- see its
|
||||
# own comment and _next_free_id.
|
||||
if not id_:
|
||||
used = {a.id for a in self.allies}
|
||||
used = {a.id for a in self.allies if a.type == type_}
|
||||
id_ = _next_free_id(used)
|
||||
a = Ally(type=type_, id=id_, location=_as_location(location))
|
||||
self.allies.append(a)
|
||||
@@ -770,6 +788,7 @@ class Board:
|
||||
"shell": t.shell.name if t.shell is not None else None,
|
||||
"assignment": t.assignment,
|
||||
"requested_time": t.requested_time,
|
||||
"underground_tier": t.underground_tier,
|
||||
}
|
||||
for t in self.targets
|
||||
],
|
||||
@@ -837,6 +856,7 @@ class Board:
|
||||
shell=Shell[t["shell"]] if t.get("shell") else None,
|
||||
assignment=t.get("assignment", "unassigned"),
|
||||
requested_time=t.get("requested_time"),
|
||||
underground_tier=t.get("underground_tier"),
|
||||
)
|
||||
for t in data.get("targets", [])
|
||||
]
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -368,6 +375,17 @@ _NAMED_HEADER_RE = re.compile(rf"^{_TYPE_ID_FRAGMENT}\s*:?\s*(.*)$")
|
||||
# one), nothing else anchors this match, so an optional colon would
|
||||
# false-positive on an ordinary clue-continuation line's leading word.
|
||||
_BARE_NAME_HEADER_RE = re.compile(r"^([A-Za-z][A-Za-z0-9]*)\s*:\s*(.*)$")
|
||||
# Even requiring the colon isn't quite enough: a prose lead-in word right
|
||||
# before a genuinely useful follow-up line ("Important: TEAR Shell first,
|
||||
# then HE Shell.") false-positives the same way -- confirmed live against
|
||||
# a real taking-fire message, where "Important:" got read as a brand new
|
||||
# named entity ("Target#Important"), stealing the actual report's own
|
||||
# "Answer by <time>" deadline into that bogus entry instead of the real
|
||||
# one. None of these read as an actual thing being spotted/named, only
|
||||
# ever as a prose interjection.
|
||||
_BARE_NAME_HEADER_BLOCKLIST = {
|
||||
"important", "note", "warning", "attention", "caution", "alert", "reminder", "priority",
|
||||
}
|
||||
|
||||
# Ad-hoc enemy installations are named in plain English rather than given a
|
||||
# Type#N id ("Enemy Signal Station:", "Bearing 034 from Enemy Signal
|
||||
@@ -608,10 +626,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 +722,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 +800,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 +840,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
|
||||
@@ -866,7 +919,11 @@ def parse_intel_blocks(text: str) -> list[dict]:
|
||||
# 'Target'), not a Reference Point, a named thing giving its own
|
||||
# clues is being spotted, not a fixed landmark spotters aim off
|
||||
# of, same reasoning as the "Target is at-" calibration line.
|
||||
bare_m = next((m for c in candidates if (m := _BARE_NAME_HEADER_RE.match(c))), None)
|
||||
bare_m = next(
|
||||
(m for c in candidates if (m := _BARE_NAME_HEADER_RE.match(c))
|
||||
and m.group(1).lower() not in _BARE_NAME_HEADER_BLOCKLIST),
|
||||
None,
|
||||
)
|
||||
if bare_m:
|
||||
flush()
|
||||
name = bare_m.group(1)
|
||||
@@ -1130,6 +1187,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:
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""_accept_proposal: an accepted proposal's entity id should prefer the
|
||||
marker's own detected "#<N>" id (map_vision.read_marker_id, via
|
||||
Proposal.detected_id) over an auto-assigned letter, so ids on the board
|
||||
match what's actually on screen -- falling back to auto-assign (a
|
||||
letter, deliberately not a number, so it can't collide with or be
|
||||
mistaken for a real detected id -- see models.py's _next_free_id) only
|
||||
when there's no detection, or it collides with an id already used for
|
||||
that type in that group (see _accept_proposal's own docstring).
|
||||
|
||||
Needs a real Adw/Gtk init (MainWindow.__new__ skips __init__, so no
|
||||
window/widgets are actually built, but Adw.init() is still required for
|
||||
the module import chain), same pattern proven in this repo already for
|
||||
headlessly exercising GTK-adjacent code.
|
||||
"""
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Gdk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
from gi.repository import Adw # noqa: E402
|
||||
|
||||
Adw.init()
|
||||
|
||||
from fenigma.app import MainWindow # noqa: E402
|
||||
from fenigma.map_import import Proposal # noqa: E402
|
||||
from fenigma.models import Board # noqa: E402
|
||||
|
||||
|
||||
def _window() -> MainWindow:
|
||||
win = MainWindow.__new__(MainWindow) # skip __init__: no widgets needed for this
|
||||
win.board = Board()
|
||||
return win
|
||||
|
||||
|
||||
def _proposal(detected_id=None, side="hostile", sub_x=0, sub_y=0) -> Proposal:
|
||||
return Proposal(side=side, label="G8", sub_x=sub_x, sub_y=sub_y, unit=None,
|
||||
centre=(0, 0), box=(0, 0, 0, 0), detected_id=detected_id)
|
||||
|
||||
|
||||
def test_accept_uses_the_detected_id_when_present():
|
||||
win = _window()
|
||||
win._accept_proposal(_proposal(detected_id="8"))
|
||||
assert win.board.targets[0].id == "8"
|
||||
|
||||
|
||||
def test_accept_falls_back_to_auto_id_with_no_detection():
|
||||
win = _window()
|
||||
win._accept_proposal(_proposal(detected_id=None))
|
||||
assert win.board.targets[0].id == "A"
|
||||
|
||||
|
||||
def test_accept_falls_back_to_auto_id_on_a_detected_id_collision():
|
||||
win = _window()
|
||||
win._accept_proposal(_proposal(detected_id="8", sub_x=1))
|
||||
win._accept_proposal(_proposal(detected_id="8", sub_x=2)) # same detected id, must not collide
|
||||
ids = [t.id for t in win.board.targets]
|
||||
assert ids[0] == "8"
|
||||
assert ids[1] != "8"
|
||||
|
||||
|
||||
def test_accept_keeps_target_and_ally_id_detection_in_separate_namespaces():
|
||||
"""A detected id colliding with an existing ALLY id shouldn't force a
|
||||
TARGET accept to fall back -- targets/allies are already a separate
|
||||
id namespace everywhere else (Board.add_target/add_ally), detected-id
|
||||
preference shouldn't quietly merge them."""
|
||||
win = _window()
|
||||
win._accept_proposal(_proposal(detected_id="8", side="friendly"))
|
||||
win._accept_proposal(_proposal(detected_id="8", side="hostile"))
|
||||
assert win.board.allies[0].id == "8"
|
||||
assert win.board.targets[0].id == "8"
|
||||
@@ -0,0 +1,54 @@
|
||||
"""read_marker_id: template-correlation read of a marker's own small
|
||||
"#<N>" id label (see map_vision.read_marker_id's own docstring for why
|
||||
this is template correlation, not OCR -- same reasoning as
|
||||
read_cell_label). Synthetic image, real font, no fixture screenshot or
|
||||
the (slow) detection pipeline needed -- just render the label the way
|
||||
the game does and check it round-trips.
|
||||
"""
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from fenigma import map_vision
|
||||
|
||||
|
||||
def _render_label(text: str, height: int) -> Image.Image:
|
||||
"""Cream glyph, heavy dark outline, same style glyph_template expects
|
||||
to correlate against -- see glyph_template's own docstring."""
|
||||
font = ImageFont.truetype(str(map_vision.FONT_PATH), height)
|
||||
pad = height
|
||||
im = Image.new("L", (height * 4 + pad, height * 2 + pad), 30) # dark "photo" background
|
||||
ImageDraw.Draw(im).text((pad // 2, pad // 4), text, font=font, fill=230,
|
||||
stroke_width=max(1, int(height * 0.10)), stroke_fill=0)
|
||||
return im
|
||||
|
||||
|
||||
def test_reads_a_clean_id_label():
|
||||
# A marker box roughly where a real one measures (see read_marker_id's
|
||||
# own calibration note), with a rendered "#8" sitting where the game
|
||||
# draws it: above-left of the box.
|
||||
box_w, box_h = 40, 40
|
||||
label_h = int(0.45 * box_h)
|
||||
label_im = _render_label("#8", label_h)
|
||||
|
||||
canvas = Image.new("L", (200, 200), 60)
|
||||
label_x, label_y = 60, 60
|
||||
canvas.paste(label_im, (label_x, label_y))
|
||||
gray = np.array(canvas)
|
||||
|
||||
box_x = label_x + int(1.0 * box_w) - 5 # box sits to the right of/below the label
|
||||
box_y = label_y + int(0.45 * box_h)
|
||||
box = (box_x, box_y, box_w, box_h)
|
||||
|
||||
assert map_vision.read_marker_id(gray, box) == "8"
|
||||
|
||||
|
||||
def test_returns_none_on_a_blank_patch():
|
||||
gray = np.full((200, 200), 60, dtype=np.uint8)
|
||||
box = (100, 100, 40, 40)
|
||||
assert map_vision.read_marker_id(gray, box) is None
|
||||
|
||||
|
||||
def test_returns_none_on_a_degenerate_box_at_the_image_edge():
|
||||
gray = np.full((200, 200), 60, dtype=np.uint8)
|
||||
box = (0, 0, 2, 2) # crop region collapses to nothing usable
|
||||
assert map_vision.read_marker_id(gray, box) is None
|
||||
@@ -54,7 +54,11 @@ def test_clear_units_keeps_recon_infrastructure():
|
||||
def test_ally_and_target_ids_are_independent_namespaces():
|
||||
"""An ally Tank#1 and a hostile Target Tank#1 are unrelated -- adding
|
||||
one must never be influenced by the other's ids, and auto-assignment
|
||||
on each side starts from 'A' independently."""
|
||||
on each side starts from 'A' independently. Explicit id_="1" here
|
||||
(as an accepted screenshot proposal's detected_id would pass, see
|
||||
app.py's _accept_proposal) to also check that auto-assignment
|
||||
correctly skips a real numeric id already in use, not just other
|
||||
letters."""
|
||||
board = Board()
|
||||
t1 = board.add_target(TargetType.TANK, _coord(), id_="1")
|
||||
a1 = board.add_ally(TargetType.TANK, _coord(), id_="1")
|
||||
@@ -63,36 +67,36 @@ def test_ally_and_target_ids_are_independent_namespaces():
|
||||
|
||||
t_auto = board.add_target(TargetType.TANK, _coord())
|
||||
a_auto = board.add_ally(TargetType.TANK, _coord())
|
||||
assert t_auto.id == "A" # first free letter among *targets* only
|
||||
assert a_auto.id == "A" # first free letter among *allies* only, unaffected by the target above
|
||||
assert t_auto.id == "A" # first free letter among *target* Tanks only
|
||||
assert a_auto.id == "A" # first free letter among *ally* Tanks only, unaffected by the target above
|
||||
|
||||
|
||||
def test_auto_id_is_shared_across_types_within_targets_and_within_allies():
|
||||
"""The id namespace split is targets-vs-allies ONLY -- different types
|
||||
within the same group (all targets, or all allies) share one A/B/C...
|
||||
sequence, they do NOT each get their own independent sequence. A Tank
|
||||
and an Infantry auto-assigned back to back must get 'A' and 'B', never
|
||||
both 'A'."""
|
||||
def test_auto_id_is_per_type_within_targets_and_within_allies():
|
||||
"""Each TYPE gets its own independent A/B/C... sequence within a group
|
||||
(all targets, or all allies) -- a Tank and an Infantry auto-assigned
|
||||
back to back both start at 'A' (Tank#A, Infantry#A), rather than
|
||||
sharing one sequence across every type in the group."""
|
||||
board = Board()
|
||||
tank = board.add_target(TargetType.TANK, _coord())
|
||||
infantry = board.add_target(TargetType.INFANTRY, _coord())
|
||||
assert tank.id == "A"
|
||||
assert infantry.id == "B" # not 'A' again just because it's a different type
|
||||
assert infantry.id == "A" # own sequence, not 'B' just because a Tank came first
|
||||
|
||||
second_tank = board.add_target(TargetType.TANK, _coord())
|
||||
assert second_tank.id == "B" # but a SECOND Tank does advance the Tank sequence
|
||||
|
||||
ally_tank = board.add_ally(TargetType.TANK, _coord())
|
||||
ally_infantry = board.add_ally(TargetType.INFANTRY, _coord())
|
||||
assert ally_tank.id == "A"
|
||||
assert ally_infantry.id == "B"
|
||||
assert ally_infantry.id == "A"
|
||||
|
||||
|
||||
def test_auto_id_survives_past_26_entities_in_one_group():
|
||||
def test_auto_id_survives_past_26_entities_of_one_type():
|
||||
"""A real crash: `next(c for c in string.ascii_uppercase if c not in
|
||||
used)` raises StopIteration the instant all 26 letters are taken --
|
||||
reachable after accepting 26+ map-screenshot proposals into the same
|
||||
group (targets, or allies) in one session, since the fix making the
|
||||
id sequence shared across types (not per-type) made 26 much easier
|
||||
to hit. Must roll over to two-letter ids ('AA', 'AB', ...) instead of
|
||||
raising."""
|
||||
reachable after accepting/adding 26+ of the same type into one group
|
||||
in a single session. Must roll over to two-letter ids ('AA', 'AB',
|
||||
...) instead of raising."""
|
||||
board = Board()
|
||||
for _ in range(26):
|
||||
board.add_target(TargetType.TANK, _coord())
|
||||
|
||||
@@ -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,14 +373,43 @@ 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")]
|
||||
assert coord == Coord("H", 7, 8, 4)
|
||||
assert shell is Shell.HE
|
||||
assert requested_time == "10:17:37"
|
||||
|
||||
|
||||
def test_taking_fire_important_followup_line_does_not_steal_the_deadline():
|
||||
"""A real user-pasted message: a same-report "Important: ... Answer by
|
||||
<time>" follow-up line was being misread as a brand new named entity
|
||||
header (the last-resort bare-"<Name>:" fallback matched "Important:"
|
||||
itself), creating a bogus Target#Important that stole the actual
|
||||
report's own deadline into that wrong entry instead of the real
|
||||
StrikeRequest. "Answer by <time>" is also a deadline phrasing
|
||||
_TAKING_FIRE_TIME_RE already covers (any 'before'/'by <time>') --
|
||||
once the phantom split stops happening, it resolves correctly with
|
||||
no extra fix needed."""
|
||||
text = ("Infantry#4 taking fire!\n"
|
||||
"Requesting <u><b>TEAR Shell</b></u> first, then <u><b>HE Shell</b></u>,\n"
|
||||
"at bearing <b>308°</b>, distance <b>1.86km</b> from our position, <b>N2 0:9</b>\n"
|
||||
"<u>Important:</u> <u><b>TEAR Shell</b></u> first, then <u><b>HE Shell</b></u>.\n"
|
||||
"Answer by <u>10:30:00</u>")
|
||||
info = ocr.parse_text(text)
|
||||
|
||||
assert (TargetType.UNKNOWN, "Important") not in info.targets
|
||||
|
||||
assert (TargetType.INFANTRY, "4") in info.allies
|
||||
assert (TargetType.INFANTRY, "4") not in info.targets
|
||||
|
||||
assert (TargetType.STRIKE_REQUEST, "Infantry4") in info.targets
|
||||
_, _, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry4")]
|
||||
assert requested_time == "10:30:00"
|
||||
# Known gap, not asserted as fixed here: only the FIRST shell of a
|
||||
# "X first, then Y" sequence is captured -- see TODO.md.
|
||||
assert shell is Shell.TEAR
|
||||
|
||||