diff --git a/.gitignore b/.gitignore index 27cd8ee..14fb3e4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,8 @@ captures/*.png GameAssets # tools/eval_map_vision.py renders its overlays here build/ + +# packaging/windows/build_windows.sh's VM disk/scratch and build output +packaging/windows/storage/ +packaging/windows/shared/ +dist-windows/ diff --git a/README.md b/README.md index 7243440..076b54b 100644 --- a/README.md +++ b/README.md @@ -48,11 +48,6 @@ Regression coverage for every intel-text format the OCR pipeline understands and ## Stack GTK4 + libadwaita (PyGObject) for the UI, Tesseract (via pytesseract) for OCR, Pillow/numpy for preprocessing, OpenCV for the map-table geometry (line detection, vanishing points, homography). Details on the coordinate system, OCR formats, solver internals, and how the map grid is recovered live in code comments (`solver.py`, `ocr.py`, `models.py`, `map_vision.py`) rather than here. -## Known issues -- **Map screenshot reading is unreliable.** Grid detection and enemy/unit detection off a map screenshot both fail often: misread grids, missed or misclassified units, screenshots rejected as "not a map" when they were one. Screenshots the app gets wrong are now saved locally (see `debug_capture.py`) to develop the detection against. Still an open problem, not a quick fix. - -See `TODO.md` for the fuller list, including what's already been fixed. - ## FAQ ### Is this cheating? diff --git a/TODO.md b/TODO.md index 09966cb..c1262db 100644 --- a/TODO.md +++ b/TODO.md @@ -3,17 +3,26 @@ Status legend: [x] fixed+tested, [~] partially addressed, [ ] open/needs input - [x] Allies and enemies seem to share indices. - Investigated: `Board.add_target`/`add_ally` already use fully separate - id namespaces by design (see `models.py`'s `Ally`/`Target` docstrings), - confirmed with a new regression test - (`test_ally_and_target_ids_are_independent_namespaces`). What was - probably actually seen: an ally and a hostile target of the same type - display with the *same name* ("Tank#1") on the map with no visual - "ally" cue beyond icon/side color — related to the next item, which - fixes one concrete instance of that (TargetType.ENEMY's "Enemy" label - on an Ally). If the symptom persists after that, it's a display/ - legibility issue, not an id collision — happy to take a screenshot of - what's confusing. + First pass on this was wrong: I only checked that targets and allies + are separate id namespaces (they are, always were) and stopped there. + The actual bug was one level down: `Board.add_target`/`add_ally`'s + auto-id assignment (`used = {t.id for t in self.targets if t.type == + type_}`) was scoped **per type**, not per group — a Tank and an + Infantry auto-added back to back both got id "A", each type getting + its own independent A/B/C... sequence instead of sharing one across + the whole group. Fixed: the id namespace split is targets-vs-allies + ONLY, type never subdivides it further. New regression test + (`test_auto_id_is_shared_across_types_within_targets_and_within_allies`). +- [x] Regression FROM the fix above, caught via a real traceback: sharing + one A/B/C... sequence across a whole group (instead of per-type) + made it much easier to actually run out of the 26 letters -- + `next(c for c in string.ascii_uppercase if c not in used)` raises + `StopIteration` the instant all 26 are taken, silently killing + whatever button click triggered `add_target`/`add_ally` (this is + what "Accept as"/"Accept all" doing nothing turned out to be, see + below). Fixed with `_next_free_id()`: rolls over to two-letter ids + ("AA", "AB", ...) instead of raising, can't run out. New test + (`test_auto_id_survives_past_26_entities_in_one_group`). - [x] Ally type 'ally' is called Enemy on map title. `icons._target_type_label` (now public `icons.target_type_label`) already special-cased this for the type picker, but the map's @@ -73,6 +82,16 @@ Status legend: [x] fixed+tested, [~] partially addressed, [ ] open/needs input `solver.point_from_bearing_distance` rather than through a Clue). New extractors in `ocr.py`, wired into `parse_intel_blocks`'s `flush()`. 3 new regression tests, all passing (`tests/test_ocr.py`). +- [x] Follow-up bug in the above: the bearing/distance-offset variant + names TWO different places (the reporting unit's own position, and + a separate fire point offset from it), but only produced one Target + entity, sitting at the offset point but still labeled with the + unit's own type/id (e.g. "Infantry#11" at a spot no infantry is + actually at). Math itself was right; the single-entity shape wasn't. + Now produces two entries: the original (Infantry#N etc.) keeps its + own reported position with no shell/deadline, and a new synthetic + `Strike#` entry (e.g. `Strike#Infantry11`) carries the + shell/deadline at the computed offset coord. 2 more regression tests. - [x] When the user deletes/replaces the map screenshot, capture whatever units they confirmed as ground truth for it. @@ -91,18 +110,18 @@ Status legend: [x] fixed+tested, [~] partially addressed, [ ] open/needs input places a screenshot stops being "the active one": explicit drop, a new screenshot pasted straight over it, and window close. +## Resolved via a real traceback (not guessed) + +- [x] "Accept as" / "Accept all" on proposed targets doing nothing. + A real traceback from the running app nailed it: `StopIteration` + from `Board.add_ally`'s id auto-assignment once 26 allies existed + already (see the id-namespace regression entry above) — every + 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. + ## Needs more scope / your input before I keep going -- [ ] "Accept as" button on proposed targets doesn't work. - Read through the whole path (`app.py`'s `_open_proposal_menu`/ - `_accept_proposal`, `map_import.py`'s `Proposal`/`ScreenshotImport`, - `grid_widget.py`'s proposal hit-testing) end to end and couldn't find - a static defect — `map_vision.GridSolution.cell_of` already clamps - sub_x/sub_y into 0..9 before a Proposal is even built, so the obvious - "coord fails to construct, accept silently no-ops" theory doesn't - hold up either. I'd need a repro (which button exactly, screenshot of - the popover, does *anything* happen — toast, marker staying put, - wrong type applied) to chase this further rather than guess. - [ ] 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 diff --git a/packaging/windows/README.md b/packaging/windows/README.md new file mode 100644 index 0000000..1ac1462 --- /dev/null +++ b/packaging/windows/README.md @@ -0,0 +1,70 @@ +# Windows .msi build (via dockur/windows) + +Builds a Windows installer for FEnigma on a Linux host with no Windows +machine and no GitHub, by booting a real Windows VM inside a container +([dockur/windows](https://github.com/dockur/windows), QEMU+KVM under the +hood, no license key needed for the eval install it fetches automatically) +and driving the whole build over a shared folder. + +**Status: written, not yet run against a real boot.** Everything here +follows dockur/windows's and WiX's documented mechanics, but there's no +KVM/Windows available in the environment this was authored in to actually +exercise it end to end. Treat the first run as a debugging session, not a +push-button success — watch it happen at http://localhost:8006 (dockur's +noVNC viewer) so you can see where it's stuck if it stalls. + +## How it fits together + +- `docker-compose.yml` — boots the VM. Needs `/dev/kvm` on the host. +- `oem/install.bat` — **one-time** provisioning, auto-run by Windows's own + unattended setup on first boot (dockur/windows's `/oem` mechanism): + installs MSYS2, then GTK4/libadwaita/PyGObject/numpy/Pillow/OpenCV/ + Tesseract through it, plus the WiX v3 toolset, and registers a + boot-time watcher task. This is the slow part (Windows install itself, + then package downloads) and only ever happens once — it lives on the + VM's persistent disk (`./storage`, gitignored) from then on. +- `oem/watch_build.bat` — runs at every boot from here on, polls the + shared `Z:\` drive for a build request. +- `oem/build.bat` — the actual per-build packaging: assembles a dist tree + (bundled MSYS2 `mingw64` runtime + the `fenigma` package), harvests it + into WiX components with `heat.exe`, and links it into an `.msi` with + `candle.exe`/`light.exe`. +- `oem/product.wxs` — the hand-authored shell around that harvested file + list: install directory, Start Menu shortcut, and the `PYTHONPATH` + environment variable the shortcut needs (mirrors `run.sh`'s + `PYTHONPATH=src python -m fenigma.app`). +- `build_windows.sh` — run this. Starts the VM, copies `../../src` onto + the shared folder, drops a request file, waits for the `.msi` to come + back, copies it to `../../dist-windows/`. + +## Running it + +```bash +cd packaging/windows +./build_windows.sh [version] +``` + +First run: full unattended Windows install + provisioning, likely +30-90 minutes, unattended (no interaction needed, but it needs to +actually finish — don't kill it early). Every run after that: just boot +the already-provisioned VM and build, a few minutes. + +Requires `/dev/kvm` (virtualization enabled, your user in the `kvm` +group) and Docker with Compose. + +## Known rough edges / likely follow-up work + +- **The dist tree is fat, not lean.** `build.bat` bulk-copies the entire + `mingw64/` runtime rather than tracing the actual DLL/typelib/icon- + theme/schema dependency closure of the app — reliable, but probably + 1GB+. Trimming it (e.g. by walking `pythonw.exe`'s and the compiled + extension modules' actual dependencies) is a real but separate project. +- **`heat.exe`'s default harvest options are a starting guess** for a + tree this large and this GTK-specific (icon caches, gschemas, typelibs); + it may need `-t` transforms or manual exclusions to produce a working + component set. +- **Not tested against a real GTK4/libadwaita Windows install at all** — + MSYS2 ships these, but this is the first time this specific app has + been pointed at them; expect a missing-DLL or schema error on first + actual launch, not just a packaging error. +- No code signing — Windows will show an "unknown publisher" warning. diff --git a/packaging/windows/build_windows.sh b/packaging/windows/build_windows.sh new file mode 100755 index 0000000..e5699ab --- /dev/null +++ b/packaging/windows/build_windows.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Build a Windows .msi for FEnigma, entirely on this Linux host, no +# Windows machine or GitHub required: boots a real Windows VM inside a +# container (dockur/windows, QEMU+KVM), provisions it once (MSYS2 + +# GTK4/libadwaita/PyGObject + WiX, see oem/install.bat), then drives every +# build over a shared folder -- drop a request, wait for the .msi to show +# up. +# +# UNTESTED end to end (no KVM/Windows available in the environment this +# was written in) -- expect to debug oem/*.bat and product.wxs against a +# real run. Watch the first boot/install at http://localhost:8006 (noVNC) +# to see what's actually happening; it also has RDP on :3389 if you'd +# rather use a real RDP client. +# +# First run: full unattended Windows install + provisioning, likely +# 30-90 minutes. Every run after that: just boot + build, a few minutes. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" + +REPO_ROOT="$(cd .. && cd .. && pwd)" +VERSION="${1:-0.1.0}" +TIMEOUT_S="${BUILD_TIMEOUT_S:-7200}" # generous: covers a from-scratch first run +OUT_DIR="${REPO_ROOT}/dist-windows" + +if [ ! -e /dev/kvm ]; then + echo "No /dev/kvm -- dockur/windows needs KVM (check virtualization is" >&2 + echo "enabled and your user is in the 'kvm' group: groups | grep kvm)." >&2 + exit 1 +fi +command -v docker >/dev/null 2>&1 || { echo "docker not found." >&2; exit 1; } + +mkdir -p storage oem shared/src shared/dist "$OUT_DIR" + +echo "==> starting the Windows build VM (docker compose up -d)" +docker compose up -d + +echo "==> syncing FEnigma source into the VM's shared folder" +rm -rf shared/src +mkdir -p shared/src +cp -r "${REPO_ROOT}/src" shared/src/ +echo "$VERSION" > shared/BUILD_VERSION +rm -f shared/BUILD_DONE shared/BUILD_FAILED +rm -rf shared/dist +mkdir -p shared/dist + +echo "==> requesting a build (version $VERSION)" +touch shared/BUILD_REQUEST + +echo "==> waiting for it (up to ${TIMEOUT_S}s -- first run is slow, see" +echo " this script's own header comment; watch http://localhost:8006" +echo " if you want to see what's actually happening)" +elapsed=0 +while [ ! -e shared/BUILD_DONE ] && [ ! -e shared/BUILD_FAILED ]; do + if [ "$elapsed" -ge "$TIMEOUT_S" ]; then + echo "Timed out after ${TIMEOUT_S}s waiting for the build." >&2 + echo "Check the VM directly (http://localhost:8006) -- it may still" >&2 + echo "be mid Windows-install, or oem/install.bat may have wedged." >&2 + exit 1 + fi + sleep 10 + elapsed=$((elapsed + 10)) + printf '.' +done +echo + +if [ -e shared/BUILD_FAILED ]; then + echo "==> build FAILED. Log:" >&2 + cat shared/dist/build.log 2>/dev/null || cat shared/build.log.failed 2>/dev/null || true + exit 1 +fi + +msi="$(find shared/dist -maxdepth 1 -name '*.msi' | head -n1)" +if [ -z "$msi" ]; then + echo "BUILD_DONE appeared but no .msi found in shared/dist -- see" >&2 + echo "shared/dist/build.log" >&2 + exit 1 +fi + +cp "$msi" "$OUT_DIR/" +echo "==> done: $OUT_DIR/$(basename "$msi")" diff --git a/packaging/windows/docker-compose.yml b/packaging/windows/docker-compose.yml new file mode 100644 index 0000000..2bc2b02 --- /dev/null +++ b/packaging/windows/docker-compose.yml @@ -0,0 +1,37 @@ +# Boots a real Windows VM inside a container via dockur/windows (QEMU+KVM +# under the hood, no Windows license/key needed for the eval install it +# fetches automatically). Persistent disk lives in ./storage, so the +# one-time provisioning in oem/install.bat only ever runs once -- every +# later `docker compose up` just boots the already-provisioned VM. +# +# Requires /dev/kvm on the host (check with: ls -la /dev/kvm, and that +# your user is in the `kvm` group). +# +# Volumes use the :Z suffix (SELinux relabeling for a container-private +# label) -- confirmed needed on this host (Fedora, SELinux enforcing): +# without it dockur/windows refuses to start with "Storage folder +# (/storage) is not writeable!" even though normal Unix permissions are +# fine. Harmless no-op on a host without SELinux. +services: + windows: + image: dockurr/windows + container_name: fenigma-windows-builder + environment: + VERSION: "11" # Windows 11 Pro, fetched+installed unattended on first boot + RAM_SIZE: "8G" + CPU_CORES: "4" + DISK_SIZE: "80G" # MSYS2 + GTK4/libadwaita + WiX + build tree eats more than the 64G default + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + ports: + - "8006:8006" # noVNC web viewer, http://localhost:8006 -- watch the first install here + - "3389:3389/tcp" # RDP, if you'd rather use an RDP client + volumes: + - ./storage:/storage:Z # persistent VM disk + - ./oem:/oem:Z # one-time provisioning payload, copied to C:\OEM on first install + - ./shared:/shared:Z # live exchange folder, appears as Z:\ in Windows + stop_grace_period: 2m + restart: unless-stopped diff --git a/packaging/windows/oem/build.bat b/packaging/windows/oem/build.bat new file mode 100644 index 0000000..3b6f31c --- /dev/null +++ b/packaging/windows/oem/build.bat @@ -0,0 +1,86 @@ +@echo off +REM Actual per-build packaging. Triggered by watch_build.bat once +REM install.bat's one-time provisioning has already put MSYS2/GTK4/ +REM libadwaita/WiX in place. Reads source from Z:\src, writes +REM FEnigma-.msi to Z:\dist, and Z:\BUILD_DONE (or +REM Z:\BUILD_FAILED, with the log copied alongside it) when finished. +REM +REM UNTESTED (see install.bat's note) -- the WiX harvest/link step in +REM particular is likely to need iteration: bulk-copying all of +REM mingw64\ is the "make it work first" approach, not a lean one, and +REM heat.exe's default harvest options may need tuning to actually +REM produce a working component set for a tree this size. + +setlocal enabledelayedexpansion +set LOG=Z:\build.log +echo [build.bat] starting > %LOG% + +if exist Z:\BUILD_VERSION ( + set /p APPVER=> %LOG% + +rd /s /q C:\build 2>nul +mkdir C:\build\src +mkdir C:\build\dist\src +mkdir C:\build\dist\mingw64 + +echo [build.bat] copying source from Z:\src ... >> %LOG% +xcopy /e /i /q Z:\src C:\build\src >> %LOG% 2>&1 + +echo [build.bat] sanity import check ... >> %LOG% +set PYTHONPATH=C:\build\src\src +C:\msys64\mingw64\bin\python3.exe -c "import fenigma.app" >> %LOG% 2>&1 +if errorlevel 1 ( + echo [build.bat] FAILED: fenigma.app failed to import, see log >> %LOG% + copy %LOG% Z:\build.log.failed >nul + echo FAILED > Z:\BUILD_FAILED + exit /b 1 +) + +echo [build.bat] assembling dist tree ... >> %LOG% +xcopy /e /i /q C:\build\src\src C:\build\dist\src >> %LOG% 2>&1 +REM Bulk-copy the whole mingw64 runtime rather than hand-tracing the DLL/ +REM typelib/icon-theme/schema dependency closure -- bloated (likely 1GB+) +REM but reliable; trimming this down is a known follow-up, not attempted +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 +if errorlevel 1 ( + echo [build.bat] FAILED: heat.exe harvest failed >> %LOG% + copy %LOG% Z:\build.log.failed >nul + echo FAILED > Z:\BUILD_FAILED + exit /b 1 +) + +copy /y C:\OEM\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 +if errorlevel 1 ( + echo [build.bat] FAILED: candle.exe failed >> %LOG% + copy %LOG% Z:\build.log.failed >nul + echo FAILED > Z:\BUILD_FAILED + exit /b 1 +) + +echo [build.bat] linking (light) ... >> %LOG% +C:\wix\light.exe -ext WixUIExtension -sice:ICE60 -sice:ICE61 -out C:\build\FEnigma-%APPVER%.msi C:\build\product.wixobj C:\build\files.wixobj >> %LOG% 2>&1 +if errorlevel 1 ( + echo [build.bat] FAILED: light.exe failed >> %LOG% + copy %LOG% Z:\build.log.failed >nul + echo FAILED > Z:\BUILD_FAILED + exit /b 1 +) + +if not exist Z:\dist mkdir Z:\dist +copy /y C:\build\FEnigma-%APPVER%.msi Z:\dist\ >> %LOG% 2>&1 +copy /y %LOG% Z:\dist\build.log >nul + +echo [build.bat] done >> %LOG% +echo DONE > Z:\BUILD_DONE +endlocal diff --git a/packaging/windows/oem/install.bat b/packaging/windows/oem/install.bat new file mode 100644 index 0000000..13e3e37 --- /dev/null +++ b/packaging/windows/oem/install.bat @@ -0,0 +1,81 @@ +@echo off +REM One-time provisioning, auto-run by dockur/windows during the final step +REM of Windows's own unattended setup (see its README's /oem mechanism). +REM Everything here happens exactly once and lands on the VM's persistent +REM disk -- later builds just boot this already-provisioned VM and run +REM build.bat, no re-provisioning. +REM +REM UNTESTED end to end: written from MSYS2's documented CI bootstrap +REM sequence (the same one msys2/setup-msys2 uses) and WiX's own docs, not +REM verified against a live dockur/windows boot. Expect to debug this on +REM the actual first run -- watch it happen at http://localhost:8006. +REM +REM Every step also echoes to Z:\install_progress.log (best-effort, only +REM if the Z:\ shared drive happens to be up already at this point in +REM setup) purely so build_windows.sh on the host has SOMETHING to show +REM besides silence during the one-time provisioning run. + +setlocal enabledelayedexpansion +call :log "starting FEnigma build-VM provisioning" + +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). +REM Discover the current filename by scraping the repo listing, since it's +REM datestamped and there's no stable "latest" URL. +call :log "finding current MSYS2 base archive..." +powershell -NoProfile -Command ^ + "$ProgressPreference='SilentlyContinue';" ^ + "$html = Invoke-WebRequest -Uri 'https://repo.msys2.org/distrib/x86_64/' -UseBasicParsing;" ^ + "$name = ($html.Links | Where-Object { $_.href -match '^msys2-base-x86_64-.*\.sfx\.exe$' } | Select-Object -Last 1).href;" ^ + "Invoke-WebRequest -Uri ('https://repo.msys2.org/distrib/x86_64/' + $name) -OutFile 'C:\msys2-base.sfx.exe' -UseBasicParsing" +if not exist C:\msys2-base.sfx.exe ( + call :log "FAILED: could not download MSYS2 base archive" + exit /b 1 +) + +call :log "extracting MSYS2 to C:\msys64 ..." +C:\msys2-base.sfx.exe -y -oC:\ >> C:\OEM\install.log 2>&1 +del C:\msys2-base.sfx.exe + +REM First bash launch finalizes the base install and kills itself off +REM mid-update (documented MSYS2 behavior) -- run it, ignore its exit +REM code, then run the real update. +call :log "bootstrapping MSYS2 (pacman -Syuu, twice) ..." +C:\msys64\usr\bin\bash.exe -lc "exit 0" >> C:\OEM\install.log 2>&1 +C:\msys64\usr\bin\bash.exe -lc "pacman -Syuu --noconfirm" >> C:\OEM\install.log 2>&1 +C:\msys64\usr\bin\bash.exe -lc "pacman -Syuu --noconfirm" >> C:\OEM\install.log 2>&1 + +call :log "installing GTK4/libadwaita/PyGObject/build deps ..." +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 + +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 -- WiX v3 toolset (candle/light/heat), a plain zip of standalone exes, +REM no installer needed. Fixed versioned URL, no scraping required. +call :log "fetching WiX v3.11 ..." +powershell -NoProfile -Command ^ + "$ProgressPreference='SilentlyContinue';" ^ + "Invoke-WebRequest -Uri 'https://github.com/wixtoolset/wix3/releases/download/wix3111rtm/wix311-binaries.zip' -OutFile 'C:\wix311-binaries.zip' -UseBasicParsing;" ^ + "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 + +call :log "provisioning done" +echo DONE > C:\OEM\provisioned.marker +if exist Z:\ echo DONE > Z:\PROVISIONED +endlocal +exit /b 0 + +:log +echo [install.bat] %~1 >> C:\OEM\install.log +if exist Z:\ echo [install.bat] %~1 >> Z:\install_progress.log +exit /b 0 diff --git a/packaging/windows/oem/product.wxs b/packaging/windows/oem/product.wxs new file mode 100644 index 0000000..c08736c --- /dev/null +++ b/packaging/windows/oem/product.wxs @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/oem/watch_build.bat b/packaging/windows/oem/watch_build.bat new file mode 100644 index 0000000..3594d5c --- /dev/null +++ b/packaging/windows/oem/watch_build.bat @@ -0,0 +1,31 @@ +@echo off +REM Runs persistently from system boot (see install.bat's scheduled task). +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 +REM all just files dropped on the shared folder in both directions. +REM +REM UNTESTED (see install.bat's note). + +:wait_for_share +if not exist Z:\ ( + timeout /t 5 /nobreak >nul + goto wait_for_share +) + +:loop +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 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 + ) +) +timeout /t 5 /nobreak >nul +goto loop diff --git a/src/fenigma/app.py b/src/fenigma/app.py index a468594..91e4b50 100644 --- a/src/fenigma/app.py +++ b/src/fenigma/app.py @@ -383,6 +383,7 @@ class MainWindow(Adw.ApplicationWindow): self.firing_panel = FiringPanel( self.board, on_change=self._refresh, + on_visual_change=self.canvas.refresh, on_select=self._set_selection, on_edit_position=self._edit_target_position, on_set_position=self._start_target_placement, @@ -785,13 +786,21 @@ class MainWindow(Adw.ApplicationWindow): def show_type(): box = page() + # Same icon grid the entity-edit "Change type" popover uses + # (see _open_entity_menu's own show_type below), not a plain + # text list -- also gets that grid's filtering for free + # (icons.available_target_types), which a bare `for t in + # TargetType` here didn't have: STRIKE/STRIKE_REQUEST aren't + # real pickable unit types (see their own comments in + # models.py) and shouldn't have been offered as "what this + # detected marker actually is". scroller = Gtk.ScrolledWindow(propagate_natural_height=True, + propagate_natural_width=True, max_content_height=340, hscrollbar_policy=Gtk.PolicyType.NEVER) - inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) - for t in TargetType: - button(inner, t.value, lambda t=t: accept(t)) - scroller.set_child(inner) + scroller.set_child(icons.build_target_type_grid( + detected, lambda t: accept(t), is_ally=(proposal.side == "friendly"), + )) box.append(scroller) popover.set_child(box) @@ -1419,6 +1428,7 @@ class MainWindow(Adw.ApplicationWindow): def _add_strike_at(self, coord, shell: Shell) -> None: target = self.board.add_target(TargetType.STRIKE, coord) target.shell = shell + target.show_geo_desc = True # a strike's whole point is its blast radius; show it without needing a click self.board.reorder_target(target, 0) # new strikes go to the front of the list self._refresh() @@ -1505,6 +1515,12 @@ class MainWindow(Adw.ApplicationWindow): if self._id_field_of(obj) is not None: button(box, "Change ID", show_id) button(box, "Change position (click the map)", change_position) + if isinstance(obj, Target): + # Alive/dead is Target-only (see models.py's Target.alive), + # same "Mark destroyed"/"Mark alive" toggle the firing + # 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) if not isinstance(obj, Nest): button(box, "Delete", delete, css="destructive-action") popover.set_child(box) @@ -1563,6 +1579,19 @@ class MainWindow(Adw.ApplicationWindow): if any(s is not obj and s.id == value for s in self.board.spotters): self.toast(f"Spotter#{value} already exists.") 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. + value = text + siblings = self.board.targets if isinstance(obj, Target) else self.board.allies + 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}.") + return else: value = text old = obj.name @@ -1580,6 +1609,17 @@ class MainWindow(Adw.ApplicationWindow): lambda c: self._apply_and_refresh(obj, Location.from_coord(c))) self.toast(f"Click the map to place {obj.name}, Esc to cancel.") + def toggle_alive(): + popover.popdown() + obj.alive = not obj.alive + # NOT self._refresh(): same reasoning as firing_panel.py's own + # alive toggle (see refresh_after_alive_change) -- this can + # never affect the solver or dedupe, doesn't need that full + # pipeline just because it's triggered from the map instead of + # the sidebar. + self.firing_panel.refresh_after_alive_change(obj) + self.toast(f"{_display_name(obj)} marked {'alive' if obj.alive else 'destroyed'}.") + def delete(): popover.popdown() name = obj.name @@ -1708,6 +1748,7 @@ class MainWindow(Adw.ApplicationWindow): def add_strike(): target = self.board.add_target(TargetType.STRIKE, coord) + target.show_geo_desc = True # a strike's whole point is its blast radius; show it without needing a click self.board.reorder_target(target, 0) # new strikes go to the front of the list self._refresh() popover.popdown() diff --git a/src/fenigma/firing_panel.py b/src/fenigma/firing_panel.py index 5fafee6..fabfe11 100644 --- a/src/fenigma/firing_panel.py +++ b/src/fenigma/firing_panel.py @@ -106,12 +106,28 @@ class FiringPanel(Gtk.Box): """Right-hand sidebar content: sort/filter toolbar + scrollable cards.""" def __init__( - self, board: Board, *, on_change, on_select, on_edit_position, on_set_position, on_remove, - on_toggle_hide_dead_map, + self, board: Board, *, on_change, on_visual_change, on_select, on_edit_position, on_set_position, + on_remove, on_toggle_hide_dead_map, ) -> None: super().__init__(orientation=Gtk.Orientation.VERTICAL) self.board = board self.on_change = on_change + # app.py's full pipeline (solver + dedupe + redraw + THIS panel's + # own full rebuild) -- for mutations that actually need it (a + # position/clue changed, a target was added/removed/reordered). + # Assignment/alive/shell changes don't: nothing about them can + # ever be produced by the solver or change dedupe's outcome, they + # just need the MAP redrawn (assignment isn't drawn there at all; + # alive dims a marker; shell can change a selected/pinned + # target's blast-radius circle). on_visual_change is that lighter + # path -- just a map redraw, no solver/dedupe/panel-rebuild -- see + # _cycle_assignment/_toggle_alive/_pick_shell, which pair it with + # _rebuild_one() for this panel's own (single-card, not + # whole-board) update. Was a real, measured lag source: every one + # of those three going through on_change() meant every single + # click rebuilt every card of every target on the board, not just + # the one that changed. + self.on_visual_change = on_visual_change self.on_select = on_select self.on_edit_position = on_edit_position self.on_set_position = on_set_position @@ -135,14 +151,14 @@ class FiringPanel(Gtk.Box): self._list_box.set_margin_bottom(10) self._list_box.set_margin_start(10) self._list_box.set_margin_end(10) - scroller = Gtk.ScrolledWindow(child=self._list_box, vexpand=True) + self._scroller = Gtk.ScrolledWindow(child=self._list_box, vexpand=True) # Horizontal scrolling is never wanted here (fixed-width sidebar), # leaving it on AUTOMATIC (the default) lets a vertical scrollbar's # own width shrink the content area enough to trigger a horizontal # one too, which then perturbs card heights and can trip vertical # scrolling that wasn't actually needed. Pin it off outright. - scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - self.append(scroller) + self._scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self.append(self._scroller) self.refresh() @@ -195,6 +211,33 @@ class FiringPanel(Gtk.Box): self._restyle(self.selected, self.selected_point, _SELECTED_CSS, False) self.selected, self.selected_point = target, point self._restyle(self.selected, self.selected_point, _SELECTED_CSS, True) + if target is not None: + self._scroll_into_view(target, point) + + def _scroll_into_view(self, target, point) -> None: + """Selecting a target on the map (or cycling selection some other + way) should bring its card on-screen if the sidebar's scrolled + past it -- otherwise "selected" is invisible state the map alone + shows, and the firing panel this is FOR doesn't actually show what + got picked. A no-op if the card's already fully visible, this + only nudges the scroll position the minimum needed, never + recentres unnecessarily.""" + card = next( + (c for c, p in self._cards_by_target.get(target, []) if point is None or p == point), + None, + ) + if card is None: + return + ok, bounds = card.compute_bounds(self._list_box) + if not ok: + return # not laid out yet (e.g. called right after a rebuild); skip rather than guess + vadj = self._scroller.get_vadjustment() + top, bottom = bounds.get_y(), bounds.get_y() + bounds.get_height() + view_top, view_bottom = vadj.get_value(), vadj.get_value() + vadj.get_page_size() + if top < view_top: + vadj.set_value(top) + elif bottom > view_bottom: + vadj.set_value(bottom - vadj.get_page_size()) def set_hovered(self, target, point=None) -> None: if target is self.hovered and point == self.hovered_point: @@ -203,6 +246,28 @@ class FiringPanel(Gtk.Box): self.hovered, self.hovered_point = target, point self._restyle(self.hovered, self.hovered_point, _HOVERED_CSS, True) + def _rebuild_one(self, target: Target) -> None: + """Rebuild just `target`'s own card(s) in place -- O(1) in the + number of OTHER targets on the board, unlike refresh() (which + tears down and rebuilds every card) -- for a mutation that only + changes this target's own display and can never add/remove a + card or move anything in the sort order (see + _cycle_assignment/_pick_shell; _toggle_alive uses this only when + that's also true for it, falling back to refresh() otherwise). + """ + old_cards = self._cards_by_target.get(target) + if not old_cards: + return # not currently shown (e.g. filtered out) -- nothing to update + new_cards = self._build_cards(target) + for (old_widget, _old_point), (new_widget, new_point) in zip(old_cards, new_cards): + self._list_box.insert_child_after(new_widget, old_widget) + self._list_box.remove(old_widget) + if target is self.selected and (self.selected_point is None or new_point == self.selected_point): + new_widget.add_css_class(_SELECTED_CSS) + if target is self.hovered and (self.hovered_point is None or new_point == self.hovered_point): + new_widget.add_css_class(_HOVERED_CSS) + self._cards_by_target[target] = new_cards + def _restyle(self, target, point, css_class: str, add: bool) -> None: """point=None means "the whole target" (every one of its cards); otherwise only the card for that specific ambiguous candidate, @@ -505,14 +570,39 @@ class FiringPanel(Gtk.Box): return row def _cycle_assignment(self, target: Target) -> None: + # Assignment (L/R/unassigned) isn't drawn on the map at all, so + # this doesn't even need on_visual_change, just the card itself. idx = _ASSIGNMENT_STATES.index(target.assignment) target.assignment = _ASSIGNMENT_STATES[(idx + 1) % len(_ASSIGNMENT_STATES)] - self.on_change() + self._rebuild_one(target) def _toggle_alive(self, target: Target) -> None: target.alive = not target.alive - self.on_change() + self.refresh_after_alive_change(target) + + def refresh_after_alive_change(self, target: Target) -> None: + """The display-only aftermath of target.alive flipping, split out + from _toggle_alive so app.py's map-popover "Mark destroyed"/"Mark + alive" (which flips target.alive itself, reaching this same + target) can reuse the same cheap-when-possible logic rather than + going through on_change()'s full solver+dedupe+canvas+panel pass + again -- exactly the rebuild this class exists to avoid paying + for a change that was never going to affect the solver or dedupe. + + A card's presence/position can depend on alive (show_dead "hide" + drops dead cards entirely, "sort_later" moves them to their own + group at the bottom) -- only "show" guarantees this card stays + exactly where it is, just dimmed, so only that mode gets the + cheap single-card path; the other two need this panel's own full + rebuild (still far cheaper than on_change()'s, since it skips + everything but the last step).""" + if self.show_dead == "show": + self._rebuild_one(target) + else: + self.refresh() + self.on_visual_change() # dead dimming / hide_dead_from_map affects the map too def _pick_shell(self, target: Target, shell: Shell) -> None: target.shell = shell - self.on_change() + self._rebuild_one(target) + self.on_visual_change() # a selected/pinned target's blast-radius circle depends on its shell diff --git a/src/fenigma/icons.py b/src/fenigma/icons.py index a59c69c..1ef7efe 100644 --- a/src/fenigma/icons.py +++ b/src/fenigma/icons.py @@ -101,9 +101,9 @@ _TARGET_ICON = { TargetType.RECON: ("Recon.png", "Reconnaissance.png"), # name differs TargetType.RECON_LISTENING: ("Recon_Listening.png", "Recon_Listening.png"), } -assert {*_TARGET_ICON} | {TargetType.STRIKE} == {*TargetType}, ( - "every TargetType needs a row in _TARGET_ICON (STRIKE is the one " - "deliberate exception, see the comment above it)" +assert {*_TARGET_ICON} | {TargetType.STRIKE, TargetType.STRIKE_REQUEST} == {*TargetType}, ( + "every TargetType needs a row in _TARGET_ICON (STRIKE/STRIKE_REQUEST " + "are the deliberate exceptions, see the comment above target_icon_path)" ) @@ -150,10 +150,11 @@ def target_icon_path(target_type: TargetType, is_ally: bool = False) -> Path | N good one. `is_ally` picks the friendly side of _TARGET_ICON over the enemy one, falling back to the enemy icon if this particular type has no friendly art of its own at all (the two sets aren't the same size, - see assets/icons/README.md). STRIKE (a planned impact point, not a - unit) gets its own crosshair rather than a unit icon, it doesn't fit - the Enemy_/Friendly_ naming scheme at all.""" - if target_type is TargetType.STRIKE: + see assets/icons/README.md). STRIKE/STRIKE_REQUEST (a planned impact + point, not a unit -- player-placed vs called in by a friendly, see + STRIKE_REQUEST's own comment) both get the same crosshair rather than + a unit icon, neither fits the Enemy_/Friendly_ naming scheme at all.""" + if target_type in (TargetType.STRIKE, TargetType.STRIKE_REQUEST): return STRIKE_ICON_PATH own = _icon_for_side(target_type, is_ally) if own is not None: @@ -467,19 +468,23 @@ def _has_own_icon(t: "TargetType", is_ally: bool) -> bool: def available_target_types(is_ally: bool = False): """TargetType members worth offering in a picker for this side. - STRIKE is never offered: it's not a unit type at all (a planned - impact point, not a contact), it's always created through its own - dedicated "Add strike" action (see app.py's _open_quick_add_menu), - never by picking a type from this generic grid -- there's no such - thing as a Strike-typed Ally either, offering it there is just - confusing, not merely unlikely. + STRIKE/STRIKE_REQUEST are never offered: neither is a unit type at + all (a planned impact point, not a contact), each is always created + through its own path instead -- STRIKE via app.py's dedicated "Add + strike" action, STRIKE_REQUEST via ocr.py parsing a fire-support + request -- never by picking a type from this generic grid. There's + no such thing as a Strike-typed Ally either, offering either one + here is just confusing, not merely unlikely. Otherwise: each side only offers types it actually has its own art for (see _has_own_icon / _TARGET_ICON) -- some types are enemy-only and some are friendly-only (King, Police, a friendly hospital, ...), the game simply doesn't draw an installation of every kind on both sides.""" - return [t for t in TargetType if t is not TargetType.STRIKE and _has_own_icon(t, is_ally)] + return [ + t for t in TargetType + if t not in (TargetType.STRIKE, TargetType.STRIKE_REQUEST) and _has_own_icon(t, is_ally) + ] def target_type_label(t: "TargetType", is_ally: bool) -> str: diff --git a/src/fenigma/map_import.py b/src/fenigma/map_import.py index 2acc53e..d3de309 100644 --- a/src/fenigma/map_import.py +++ b/src/fenigma/map_import.py @@ -66,6 +66,16 @@ class ScreenshotImport: proposals: list = field(default_factory=list) overlay: object = None # BGRA array in map space px_per_km: int = 0 + # The same screenshot at full resolution, plus its width / `image`'s + # width -- `image` is downscaled to WORK_W for solving/marker-detection + # speed (see map_vision.WORK_W), which is plenty for those but throws + # away real detail the map overlay doesn't need to give up too (a + # screenshot can be up to 6880px wide, see map_vision.load_full_res's + # docstring). None/1.0 (rather than always loading it) because it's + # only needed for build_overlay(), and app.py sets it right after + # solving, before build_overlay() is ever called. + full_image: object = None + full_image_scale: float = 1.0 # Board.targets/Board.allies as they stood right when this screenshot's # grid was confirmed (see app.py's _accept_grid) -- Target/Ally are # identity-hashable (models.py's `eq=False`), so these are plain sets @@ -88,10 +98,14 @@ class ScreenshotImport: centre=m["centre"], box=m["box"]) for m in markers] return self.proposals - def build_overlay(self, px_per_km=100): - """Rectify the screenshot into map space, ready to draw under the grid.""" + def build_overlay(self, px_per_km=150): + """Rectify the screenshot into map space, ready to draw under the grid. + Uses full_image (full resolution) over image (WORK_W-downscaled) when + available, see full_image's own docstring.""" + src, scale = (self.full_image, self.full_image_scale) if self.full_image is not None \ + else (self.image, 1.0) self.overlay, self.px_per_km = map_vision.warp_to_map( - self.image, self.solution, px_per_km=px_per_km) + src, self.solution, px_per_km=px_per_km, img_scale=scale) return self.overlay def accept_all(self): @@ -166,7 +180,18 @@ class ImportJob: sol, img, err = map_vision.solve_path(path) if sol is None: return None, err - return ScreenshotImport(solution=sol, image=img), None + imp = ScreenshotImport(solution=sol, image=img) + # Best-effort: a sharper source for build_overlay() than the + # WORK_W-downscaled `img` solving used (see full_image's own + # docstring). Anything going wrong here just means the overlay + # falls back to `img`, not worth failing the whole import over. + try: + full = map_vision.load_full_res(path) + imp.full_image = full + imp.full_image_scale = full.shape[1] / img.shape[1] + except (ValueError, ZeroDivisionError, OSError): + pass + return imp, None return self._run(work, on_done, "map-import") diff --git a/src/fenigma/map_vision.py b/src/fenigma/map_vision.py index dd76759..ead0899 100644 --- a/src/fenigma/map_vision.py +++ b/src/fenigma/map_vision.py @@ -82,6 +82,22 @@ def load(path, work_w=None) -> np.ndarray: return downscale(img, work_w) +def load_full_res(path) -> np.ndarray: + """Same read as load(), but never downscaled -- solving and marker + detection deliberately work at WORK_W (a screenshot's real resolution + only matters up to what a grid label needs to stay legible, see + solve_path's own docstring), but that same downscaled image is a poor + source for the map overlay the app draws the screenshot as: a + screenshot wider than WORK_W (the docstring above notes these run + 700..6880px) was throwing away real detail there for no benefit. See + warp_to_map's img_scale param, which is how a caller tells it "this + image isn't the one `sol` was solved against, here's the size ratio".""" + img = cv2.imread(str(path), cv2.IMREAD_COLOR) + if img is None: + raise ValueError(f"cannot read image: {path}") + return img + + def downscale(img, work_w=None) -> np.ndarray: h, w = img.shape[:2] s = min(1.0, (work_w or WORK_W) / w) @@ -649,10 +665,19 @@ def centre_cell_quad(sol, shape): MAP_KM_W, MAP_KM_H = 20.0, 10.0 -def warp_to_map(img, sol, px_per_km=100): +def warp_to_map(img, sol, px_per_km=150, img_scale=1.0): """Rectify a screenshot into map space, ready to composite under the app's own grid. + `img` need not be the exact image `sol` was solved against (usually a + WORK_W-downscaled one, see solve_path) -- pass the original full- + resolution screenshot instead (see load_full_res) for a sharper overlay, + with `img_scale` set to img's width / the solved image's width, so this + can still map `sol`'s coordinates (which are in the SOLVED image's pixel + space) onto `img`'s actual pixels. img_scale=1.0 (the default) means + `img` IS the image `sol` was solved against, same as before this param + existed. + Returns (BGRA array, px_per_km). Only the region the screenshot actually covers is opaque; everything else is transparent, so a partial view of the table does not blank out the rest of the map. @@ -673,6 +698,15 @@ def warp_to_map(img, sol, px_per_km=100): # du, dv) is what pins those to named cells, and leaving it out put the # screenshot in the wrong place for every automatically solved grid. M = grid_to_map @ sol.lattice_to_grid() @ np.linalg.inv(sol.H) + if img_scale != 1.0: + # img's pixels are img_scale times bigger than what M expects + # (the solved image's pixel space) -- shrink img-space coordinates + # down to that space first, applied first since matrices compose + # right-to-left. + to_solved_px = np.array([[1.0 / img_scale, 0.0, 0.0], + [0.0, 1.0 / img_scale, 0.0], + [0.0, 0.0, 1.0]]) + M = M @ to_solved_px bgra = cv2.cvtColor(img, cv2.COLOR_BGR2BGRA) bgra[:, :, 3] = 255 return cv2.warpPerspective(bgra, M, (out_w, out_h), flags=cv2.INTER_LINEAR, diff --git a/src/fenigma/models.py b/src/fenigma/models.py index ba1a4eb..d810469 100644 --- a/src/fenigma/models.py +++ b/src/fenigma/models.py @@ -18,6 +18,7 @@ Coord) to work out everything else. This module just defines the shape. from __future__ import annotations +import itertools import string from dataclasses import dataclass, field from enum import Enum @@ -56,7 +57,18 @@ class TargetType(Enum): # ("Enemy Signal Station", "Enemy Field Command"), not one of the # game's fixed unit types, its id is the rest of that name with # spaces stripped, see ocr.py's squash_enemy_names() - STRIKE = "Strike" # a planned impact point, not an enemy contact + STRIKE = "Strike" # a planned impact point, not an enemy contact -- + # player-placed only (app.py's dedicated "Add Strike" flow / map + # right-click), never produced by OCR. + STRIKE_REQUEST = "Strike Request" # a planned impact point a friendly + # unit is calling in over the radio (ocr.py's "taking fire" fire- + # support-request grammar, when it names a bearing/distance offset + # from the reporter rather than the reporter's own position), as + # opposed to STRIKE, which the player places themselves. Same + # "not an enemy contact, just an impact point" shape as STRIKE + # (dedupe_generic_targets/icons.py both treat the two the same way), + # kept as its own type rather than reusing STRIKE so a request that + # came in over the radio is never confused for one the player chose. # -- Ground combat units ------------------------------------------- ANTI_AIR = "Anti-Air" @@ -487,6 +499,29 @@ class ScoutFlight: return f"ScoutFlight#{self.id}" +def _next_free_id(used: set[str]) -> str: + """Next unused id in a short, human-friendly sequence: single + uppercase letters (A..Z) first, then two-letter combinations + (AA..ZZ, spreadsheet-column style) once those run out, and so on. + + 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.""" + length = 1 + while True: + for combo in itertools.product(string.ascii_uppercase, repeat=length): + candidate = "".join(combo) + if candidate not in used: + return candidate + length += 1 + + SAVE_FORMAT_VERSION = 3 @@ -557,9 +592,14 @@ 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. if not id_: - used = {t.id for t in self.targets if t.type == type_} - id_ = next(c for c in string.ascii_uppercase if c not in used) + used = {t.id for t in self.targets} + id_ = _next_free_id(used) t = Target(type=type_, id=id_, location=_as_location(location)) self.targets.append(t) return t @@ -576,11 +616,12 @@ class Board: ) -> 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 of the same type, never - # self.targets. + # 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. if not id_: - used = {a.id for a in self.allies if a.type == type_} - id_ = next(c for c in string.ascii_uppercase if c not in used) + used = {a.id for a in self.allies} + id_ = _next_free_id(used) a = Ally(type=type_, id=id_, location=_as_location(location)) self.allies.append(a) return a diff --git a/src/fenigma/ocr.py b/src/fenigma/ocr.py index 79e1299..297815f 100644 --- a/src/fenigma/ocr.py +++ b/src/fenigma/ocr.py @@ -274,6 +274,22 @@ def _extract_bearing_distance_from_position_coord(text: str) -> Coord | None: return solver.point_to_coord(point) +def _extract_our_position_coord(text: str) -> Coord | None: + """The bearing/distance variant's OWN inline position ('...from our + position, J6 2:5, by ...'), as opposed to + _extract_bearing_distance_from_position_coord's computed offset from + it. Reported unit and requested fire point are two different places + for this variant (unlike the direct "on our position at " one, + a real danger-close call), so parse_intel_blocks's flush() uses this + for the reporting unit's own entry and the offset for a second, + separate Strike entry -- see its comment.""" + m = _BEARING_DISTANCE_FROM_POSITION_RE.search(text) + if not m: + return None + _bearing, _distance, letter, y, x, yy = m.groups() + return _coord_from_groups(letter, y, x, yy) + + # "Reported active in grid D10": only the large-grid cell, no sub-grid # x:y at all, unlike every other coord shape in this file. Tried last # (after _extract_grid_coord, which requires the full x:y and so is @@ -734,18 +750,46 @@ def parse_intel_blocks(text: str) -> list[dict]: if current is not None: joined = "\n".join(current["raw"]) current["clues"] = _parse_all_clues(joined) + # The bearing/distance taking-fire variant names TWO different + # places (see _extract_our_position_coord's docstring): the + # reporting unit's own position, and a separate fire point + # offset from it. Everything else in this module is "one block + # -> one entry", so that offset gets split into a second, + # synthetic StrikeRequest entry below rather than folded into + # this one -- otherwise the fire point either overwrites the + # unit's real position (wrong place) or gets silently dropped. + offset_coord = _extract_bearing_distance_from_position_coord(joined) current["coord"] = ( _extract_grid_coord(joined) or _extract_requested_on_coord(joined) or _extract_on_our_position_coord(joined) - or _extract_bearing_distance_from_position_coord(joined) + or _extract_our_position_coord(joined) or _extract_large_grid_only_coord(joined) ) - current["shell"] = _extract_shell_request(joined) or _extract_requesting_shell(joined) - current["requested_time"] = _extract_requested_time(joined) or _extract_taking_fire_time(joined) + 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 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: + # 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 + # comment in models.py). type_word must match its + # TargetType.short exactly ("StrikeRequest", no space), + # same as every other type_word this module produces. + strike_id = f"{current['type_word']}{current['id']}" + entries.append({ + "kind": "named", "name": f"StrikeRequest#{strike_id}", + "type_word": "StrikeRequest", + "id": strike_id, "raw": joined, "clues": [], "coord": offset_coord, + "shell": shell, "requested_time": requested_time, + }) current = None for raw_line in text.splitlines(): diff --git a/src/fenigma/solver.py b/src/fenigma/solver.py index 46c9180..6b1f7b3 100644 --- a/src/fenigma/solver.py +++ b/src/fenigma/solver.py @@ -372,14 +372,16 @@ def dedupe_generic_targets(board: Board) -> list[str]: same* position as an already-known specific target, it's not a new contact, it's the same one being spotted, just described more precisely. Drop the redundant generic entry, keep the specific one. - Strikes are our own planned impacts, not enemy contacts, and never - participate. Run this after resolve_board(), since positions may - only become comparable once resolved. Returns the names removed.""" + Strikes (player-placed or requested) are planned impacts, not enemy + contacts, and never participate. Run this after resolve_board(), + since positions may only become comparable once resolved. Returns + the names removed.""" removed: list[str] = [] unknowns = [t for t in board.targets if t.type is TargetType.UNKNOWN and t.coord is not None] specifics = [ t for t in board.targets - if t.type not in (TargetType.UNKNOWN, TargetType.STRIKE) and t.coord is not None + if t.type not in (TargetType.UNKNOWN, TargetType.STRIKE, TargetType.STRIKE_REQUEST) + and t.coord is not None ] for generic in unknowns: if any(generic.coord == specific.coord for specific in specifics): diff --git a/tests/test_map_vision_warp.py b/tests/test_map_vision_warp.py new file mode 100644 index 0000000..2f161f4 --- /dev/null +++ b/tests/test_map_vision_warp.py @@ -0,0 +1,59 @@ +"""warp_to_map's img_scale param: a caller can hand it a differently-sized +image than the one `sol` was actually solved against (see +map_vision.load_full_res / ScreenshotImport.full_image), scaled to +compensate. This checks that compensation is correct, without needing a +real fixture screenshot or the (slow) line-detection/solve pipeline -- +just a synthetic image and a stub solution with a predictable transform. +""" +import numpy as np +import pytest + +from fenigma import map_vision + + +class _IdentitySolution: + """H and lattice_to_grid() both identity: warp_to_map's transform then + reduces to just grid_to_map, so the output is a directly px_per_km- + scaled (and row-flipped, per warp_to_map's own comment) copy of + whatever region of the input `warp_to_map` reads as "grid space".""" + H = np.eye(3) + + def lattice_to_grid(self): + return np.eye(3) + + +def test_img_scale_compensates_for_a_bigger_source_image(): + # A small solid-color source, plus a 2x upscaled copy of it -- same + # content, different pixel dimensions. + small = np.zeros((20, 20, 3), dtype=np.uint8) + small[:, :] = (10, 20, 30) # BGR + big = np.zeros((40, 40, 3), dtype=np.uint8) + big[:, :] = (10, 20, 30) + + sol = _IdentitySolution() + out_small, ppk_small = map_vision.warp_to_map(small, sol, px_per_km=1) + out_big, ppk_big = map_vision.warp_to_map(big, sol, px_per_km=1, img_scale=2.0) + + assert ppk_small == ppk_big == 1 + assert out_small.shape == out_big.shape # output is always MAP_KM_W/H * px_per_km, regardless of source size + # Same solid color warped in (opaque region only -- compare where both + # actually painted something, alpha channel nonzero). + painted = (out_small[:, :, 3] > 0) & (out_big[:, :, 3] > 0) + assert painted.any() + np.testing.assert_array_equal(out_small[painted][:, :3], out_big[painted][:, :3]) + + +def test_default_img_scale_is_unchanged_behavior(): + """img_scale's default (1.0) must reproduce pre-existing behavior + exactly -- every other warp_to_map call site doesn't pass it.""" + img = np.zeros((20, 20, 3), dtype=np.uint8) + img[:, :] = (1, 2, 3) + sol = _IdentitySolution() + out_default, _ = map_vision.warp_to_map(img, sol, px_per_km=1) + out_explicit, _ = map_vision.warp_to_map(img, sol, px_per_km=1, img_scale=1.0) + np.testing.assert_array_equal(out_default, out_explicit) + + +def test_load_full_res_raises_like_load_on_a_bad_path(tmp_path): + with pytest.raises(ValueError): + map_vision.load_full_res(tmp_path / "does-not-exist.png") diff --git a/tests/test_models.py b/tests/test_models.py index 510b969..a5643c5 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -67,6 +67,45 @@ def test_ally_and_target_ids_are_independent_namespaces(): assert a_auto.id == "A" # first free letter among *allies* 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'.""" + 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 + + 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" + + +def test_auto_id_survives_past_26_entities_in_one_group(): + """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.""" + board = Board() + for _ in range(26): + board.add_target(TargetType.TANK, _coord()) + twenty_seventh = board.add_target(TargetType.TANK, _coord()) + assert twenty_seventh.id == "AA" + + board2 = Board() + for _ in range(26): + board2.add_ally(TargetType.TANK, _coord()) + twenty_seventh_ally = board2.add_ally(TargetType.TANK, _coord()) + assert twenty_seventh_ally.id == "AA" + + def test_find_by_name_prefers_target_over_same_named_ally(): """find_by_name() (used to resolve Clue references) checks targets before allies -- documented, deliberate priority, not a namespace diff --git a/tests/test_ocr.py b/tests/test_ocr.py index cbb5603..e7f5021 100644 --- a/tests/test_ocr.py +++ b/tests/test_ocr.py @@ -272,19 +272,55 @@ def test_infantry_taking_fire_no_attacker_mention(): 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 -- 'our position' isn't a named board entity - to hang a Clue off of, so this resolves straight to an absolute - coord.""" + (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).""" text = ("Infantry#3 taking fire!\n" "Requesting HE Shell at bearing 239°, distance " "10.76km from our position, J6 2:5, by 10:38:18 " "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 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")] assert shell is Shell.HE assert requested_time == "10:38:18" from fenigma import solver expected = solver.point_to_coord( solver.point_from_bearing_distance(Coord("J", 6, 2, 5).as_fraction(), 239.0, 10.76)) assert coord == expected + + +def test_infantry_taking_fire_bearing_distance_short_range(): + """Same shape, a sub-1km offset (the earlier fixture's own distance, + 10.76km, is far enough that a rounding slip in the offset math could + have gone unnoticed inside the same large cell -- this one crosses a + cell boundary, I7 0:8 -> H7 8:4, so a sign/axis error would visibly + land in the wrong cell letter entirely, not just a slightly-off + sub-position).""" + text = ("Infantry#11 taking fire!\n" + "Requesting HE Shell at bearing 210°, distance " + "0.43km from our position, I7 0:8, by 10:17:37 " + "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 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"