Compare commits

..

No commits in common. "4556da37ffb11adf1566ecb017d2c9163aee8262" and "49863b604569c7ff412563592069771c263f57aa" have entirely different histories.

21 changed files with 74 additions and 962 deletions

5
.gitignore vendored
View File

@ -5,8 +5,3 @@ captures/*.png
GameAssets GameAssets
# tools/eval_map_vision.py renders its overlays here # tools/eval_map_vision.py renders its overlays here
build/ build/
# packaging/windows/build_windows.sh's VM disk/scratch and build output
packaging/windows/storage/
packaging/windows/shared/
dist-windows/

View File

@ -48,6 +48,11 @@ Regression coverage for every intel-text format the OCR pipeline understands and
## Stack ## 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. 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 ## FAQ
### Is this cheating? ### Is this cheating?

61
TODO.md
View File

@ -3,26 +3,17 @@
Status legend: [x] fixed+tested, [~] partially addressed, [ ] open/needs input Status legend: [x] fixed+tested, [~] partially addressed, [ ] open/needs input
- [x] Allies and enemies seem to share indices. - [x] Allies and enemies seem to share indices.
First pass on this was wrong: I only checked that targets and allies Investigated: `Board.add_target`/`add_ally` already use fully separate
are separate id namespaces (they are, always were) and stopped there. id namespaces by design (see `models.py`'s `Ally`/`Target` docstrings),
The actual bug was one level down: `Board.add_target`/`add_ally`'s confirmed with a new regression test
auto-id assignment (`used = {t.id for t in self.targets if t.type == (`test_ally_and_target_ids_are_independent_namespaces`). What was
type_}`) was scoped **per type**, not per group — a Tank and an probably actually seen: an ally and a hostile target of the same type
Infantry auto-added back to back both got id "A", each type getting display with the *same name* ("Tank#1") on the map with no visual
its own independent A/B/C... sequence instead of sharing one across "ally" cue beyond icon/side color — related to the next item, which
the whole group. Fixed: the id namespace split is targets-vs-allies fixes one concrete instance of that (TargetType.ENEMY's "Enemy" label
ONLY, type never subdivides it further. New regression test on an Ally). If the symptom persists after that, it's a display/
(`test_auto_id_is_shared_across_types_within_targets_and_within_allies`). legibility issue, not an id collision — happy to take a screenshot of
- [x] Regression FROM the fix above, caught via a real traceback: sharing what's confusing.
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. - [x] Ally type 'ally' is called Enemy on map title.
`icons._target_type_label` (now public `icons.target_type_label`) `icons._target_type_label` (now public `icons.target_type_label`)
already special-cased this for the type picker, but the map's already special-cased this for the type picker, but the map's
@ -82,16 +73,6 @@ Status legend: [x] fixed+tested, [~] partially addressed, [ ] open/needs input
`solver.point_from_bearing_distance` rather than through a Clue). `solver.point_from_bearing_distance` rather than through a Clue).
New extractors in `ocr.py`, wired into `parse_intel_blocks`'s New extractors in `ocr.py`, wired into `parse_intel_blocks`'s
`flush()`. 3 new regression tests, all passing (`tests/test_ocr.py`). `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#<TypeWord><id>` 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 - [x] When the user deletes/replaces the map screenshot, capture whatever
units they confirmed as ground truth for it. units they confirmed as ground truth for it.
@ -110,18 +91,18 @@ Status legend: [x] fixed+tested, [~] partially addressed, [ ] open/needs input
places a screenshot stops being "the active one": explicit drop, a places a screenshot stops being "the active one": explicit drop, a
new screenshot pasted straight over it, and window close. 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 ## 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 - [ ] Enemy type detection needs to be more robust; read the entity id
label so dedup is reliable; detect death from the log. label so dedup is reliable; detect death from the log.
All three are real computer-vision/OCR feature work (better marker All three are real computer-vision/OCR feature work (better marker

View File

@ -1,70 +0,0 @@
# 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.

View File

@ -1,80 +0,0 @@
#!/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")"

View File

@ -1,37 +0,0 @@
# 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

View File

@ -1,86 +0,0 @@
@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-<version>.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=<Z:\BUILD_VERSION
) else (
set APPVER=0.1.0
)
echo [build.bat] version %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

View File

@ -1,81 +0,0 @@
@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

View File

@ -1,68 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Hand-authored shell: directory layout, the Start Menu shortcut, and the
PYTHONPATH environment variable the shortcut relies on (see run.sh's
equivalent `PYTHONPATH=src python -m fenigma.app`). The actual app/
runtime files are a separate auto-harvested fragment (files.wxs, built
by heat.exe in build.bat) referenced here only by its ComponentGroup id.
UpgradeCode below is a fixed, generated-once GUID: DO NOT regenerate
it, that's what lets a newer .msi upgrade an older install in place
instead of installing side by side. ProductCode is left as "*" (auto-
generated per build), which is the normal WiX pattern.
UNTESTED (see build.bat's top-of-file note).
-->
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Product Id="*"
Name="FEnigma"
Language="1033"
Version="$(var.AppVersion)"
Manufacturer="FEnigma"
UpgradeCode="DAB672A3-9E27-4F3F-8251-0AACD6E57B94">
<Package InstallerVersion="500" Compressed="yes" InstallScope="perMachine" />
<MajorUpgrade DowngradeErrorMessage="A newer version of FEnigma is already installed." />
<MediaTemplate EmbedCab="yes" />
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="ProgramFiles64Folder">
<Directory Id="INSTALLFOLDER" Name="FEnigma" />
</Directory>
<Directory Id="ProgramMenuFolder">
<Directory Id="ApplicationProgramsFolder" Name="FEnigma" />
</Directory>
</Directory>
<!-- AppFiles (all of dist\mingw64 + dist\src, harvested by heat.exe
into files.wxs) is referenced by id only: its actual file list
lives in that generated fragment, not here. -->
<Feature Id="MainFeature" Title="FEnigma" Level="1">
<ComponentGroupRef Id="AppFiles" />
<ComponentRef Id="ApplicationShortcutComponent" />
</Feature>
<DirectoryRef Id="ApplicationProgramsFolder">
<Component Id="ApplicationShortcutComponent" Guid="*">
<Shortcut Id="ApplicationStartMenuShortcut"
Name="FEnigma"
Description="Screen-reading helper for IRON NEST: Heavy Turret Simulator"
Target="[INSTALLFOLDER]mingw64\bin\pythonw.exe"
Arguments="-m fenigma.app"
WorkingDirectory="INSTALLFOLDER" />
<RemoveFolder Id="CleanUpShortcut" On="uninstall" />
<!-- Machine-wide PYTHONPATH so the bundled mingw64\bin\pythonw.exe
(which knows nothing about this app on its own) can find the
fenigma package: same role run.sh's env var plays on Linux.
Permanent="no": removed again on uninstall. -->
<Environment Id="PythonPathEnv" Name="PYTHONPATH" Value="[INSTALLFOLDER]src"
Permanent="no" Action="set" System="yes" Part="last" />
<RegistryValue Root="HKCU" Key="Software\FEnigma" Name="installed" Type="integer" Value="1" KeyPath="yes" />
</Component>
</DirectoryRef>
<!-- WixUI_Minimal: no EULA screen, so no WixUILicenseRtf override needed. -->
<UIRef Id="WixUI_Minimal" />
</Product>
</Wix>

View File

@ -1,31 +0,0 @@
@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

View File

@ -383,7 +383,6 @@ class MainWindow(Adw.ApplicationWindow):
self.firing_panel = FiringPanel( self.firing_panel = FiringPanel(
self.board, self.board,
on_change=self._refresh, on_change=self._refresh,
on_visual_change=self.canvas.refresh,
on_select=self._set_selection, on_select=self._set_selection,
on_edit_position=self._edit_target_position, on_edit_position=self._edit_target_position,
on_set_position=self._start_target_placement, on_set_position=self._start_target_placement,
@ -786,21 +785,13 @@ class MainWindow(Adw.ApplicationWindow):
def show_type(): def show_type():
box = page() 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, scroller = Gtk.ScrolledWindow(propagate_natural_height=True,
propagate_natural_width=True,
max_content_height=340, max_content_height=340,
hscrollbar_policy=Gtk.PolicyType.NEVER) hscrollbar_policy=Gtk.PolicyType.NEVER)
scroller.set_child(icons.build_target_type_grid( inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
detected, lambda t: accept(t), is_ally=(proposal.side == "friendly"), for t in TargetType:
)) button(inner, t.value, lambda t=t: accept(t))
scroller.set_child(inner)
box.append(scroller) box.append(scroller)
popover.set_child(box) popover.set_child(box)
@ -1428,7 +1419,6 @@ class MainWindow(Adw.ApplicationWindow):
def _add_strike_at(self, coord, shell: Shell) -> None: def _add_strike_at(self, coord, shell: Shell) -> None:
target = self.board.add_target(TargetType.STRIKE, coord) target = self.board.add_target(TargetType.STRIKE, coord)
target.shell = shell 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.board.reorder_target(target, 0) # new strikes go to the front of the list
self._refresh() self._refresh()
@ -1515,12 +1505,6 @@ class MainWindow(Adw.ApplicationWindow):
if self._id_field_of(obj) is not None: if self._id_field_of(obj) is not None:
button(box, "Change ID", show_id) button(box, "Change ID", show_id)
button(box, "Change position (click the map)", change_position) 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): if not isinstance(obj, Nest):
button(box, "Delete", delete, css="destructive-action") button(box, "Delete", delete, css="destructive-action")
popover.set_child(box) popover.set_child(box)
@ -1579,19 +1563,6 @@ class MainWindow(Adw.ApplicationWindow):
if any(s is not obj and s.id == value for s in self.board.spotters): if any(s is not obj and s.id == value for s in self.board.spotters):
self.toast(f"Spotter#{value} already exists.") self.toast(f"Spotter#{value} already exists.")
return 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: else:
value = text value = text
old = obj.name old = obj.name
@ -1609,17 +1580,6 @@ class MainWindow(Adw.ApplicationWindow):
lambda c: self._apply_and_refresh(obj, Location.from_coord(c))) lambda c: self._apply_and_refresh(obj, Location.from_coord(c)))
self.toast(f"Click the map to place {obj.name}, Esc to cancel.") 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(): def delete():
popover.popdown() popover.popdown()
name = obj.name name = obj.name
@ -1748,7 +1708,6 @@ class MainWindow(Adw.ApplicationWindow):
def add_strike(): def add_strike():
target = self.board.add_target(TargetType.STRIKE, coord) 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.board.reorder_target(target, 0) # new strikes go to the front of the list
self._refresh() self._refresh()
popover.popdown() popover.popdown()

View File

@ -106,28 +106,12 @@ class FiringPanel(Gtk.Box):
"""Right-hand sidebar content: sort/filter toolbar + scrollable cards.""" """Right-hand sidebar content: sort/filter toolbar + scrollable cards."""
def __init__( def __init__(
self, board: Board, *, on_change, on_visual_change, on_select, on_edit_position, on_set_position, self, board: Board, *, on_change, on_select, on_edit_position, on_set_position, on_remove,
on_remove, on_toggle_hide_dead_map, on_toggle_hide_dead_map,
) -> None: ) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL) super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.board = board self.board = board
self.on_change = on_change 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_select = on_select
self.on_edit_position = on_edit_position self.on_edit_position = on_edit_position
self.on_set_position = on_set_position self.on_set_position = on_set_position
@ -151,14 +135,14 @@ class FiringPanel(Gtk.Box):
self._list_box.set_margin_bottom(10) self._list_box.set_margin_bottom(10)
self._list_box.set_margin_start(10) self._list_box.set_margin_start(10)
self._list_box.set_margin_end(10) self._list_box.set_margin_end(10)
self._scroller = Gtk.ScrolledWindow(child=self._list_box, vexpand=True) scroller = Gtk.ScrolledWindow(child=self._list_box, vexpand=True)
# Horizontal scrolling is never wanted here (fixed-width sidebar), # Horizontal scrolling is never wanted here (fixed-width sidebar),
# leaving it on AUTOMATIC (the default) lets a vertical scrollbar's # leaving it on AUTOMATIC (the default) lets a vertical scrollbar's
# own width shrink the content area enough to trigger a horizontal # own width shrink the content area enough to trigger a horizontal
# one too, which then perturbs card heights and can trip vertical # one too, which then perturbs card heights and can trip vertical
# scrolling that wasn't actually needed. Pin it off outright. # scrolling that wasn't actually needed. Pin it off outright.
self._scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
self.append(self._scroller) self.append(scroller)
self.refresh() self.refresh()
@ -211,33 +195,6 @@ class FiringPanel(Gtk.Box):
self._restyle(self.selected, self.selected_point, _SELECTED_CSS, False) self._restyle(self.selected, self.selected_point, _SELECTED_CSS, False)
self.selected, self.selected_point = target, point self.selected, self.selected_point = target, point
self._restyle(self.selected, self.selected_point, _SELECTED_CSS, True) 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: def set_hovered(self, target, point=None) -> None:
if target is self.hovered and point == self.hovered_point: if target is self.hovered and point == self.hovered_point:
@ -246,28 +203,6 @@ class FiringPanel(Gtk.Box):
self.hovered, self.hovered_point = target, point self.hovered, self.hovered_point = target, point
self._restyle(self.hovered, self.hovered_point, _HOVERED_CSS, True) 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: def _restyle(self, target, point, css_class: str, add: bool) -> None:
"""point=None means "the whole target" (every one of its cards); """point=None means "the whole target" (every one of its cards);
otherwise only the card for that specific ambiguous candidate, otherwise only the card for that specific ambiguous candidate,
@ -570,39 +505,14 @@ class FiringPanel(Gtk.Box):
return row return row
def _cycle_assignment(self, target: Target) -> None: 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) idx = _ASSIGNMENT_STATES.index(target.assignment)
target.assignment = _ASSIGNMENT_STATES[(idx + 1) % len(_ASSIGNMENT_STATES)] target.assignment = _ASSIGNMENT_STATES[(idx + 1) % len(_ASSIGNMENT_STATES)]
self._rebuild_one(target) self.on_change()
def _toggle_alive(self, target: Target) -> None: def _toggle_alive(self, target: Target) -> None:
target.alive = not target.alive target.alive = not target.alive
self.refresh_after_alive_change(target) self.on_change()
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: def _pick_shell(self, target: Target, shell: Shell) -> None:
target.shell = shell target.shell = shell
self._rebuild_one(target) self.on_change()
self.on_visual_change() # a selected/pinned target's blast-radius circle depends on its shell

View File

@ -101,9 +101,9 @@ _TARGET_ICON = {
TargetType.RECON: ("Recon.png", "Reconnaissance.png"), # name differs TargetType.RECON: ("Recon.png", "Reconnaissance.png"), # name differs
TargetType.RECON_LISTENING: ("Recon_Listening.png", "Recon_Listening.png"), TargetType.RECON_LISTENING: ("Recon_Listening.png", "Recon_Listening.png"),
} }
assert {*_TARGET_ICON} | {TargetType.STRIKE, TargetType.STRIKE_REQUEST} == {*TargetType}, ( assert {*_TARGET_ICON} | {TargetType.STRIKE} == {*TargetType}, (
"every TargetType needs a row in _TARGET_ICON (STRIKE/STRIKE_REQUEST " "every TargetType needs a row in _TARGET_ICON (STRIKE is the one "
"are the deliberate exceptions, see the comment above target_icon_path)" "deliberate exception, see the comment above it)"
) )
@ -150,11 +150,10 @@ 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 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 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, no friendly art of its own at all (the two sets aren't the same size,
see assets/icons/README.md). STRIKE/STRIKE_REQUEST (a planned impact see assets/icons/README.md). STRIKE (a planned impact point, not a
point, not a unit -- player-placed vs called in by a friendly, see unit) gets its own crosshair rather than a unit icon, it doesn't fit
STRIKE_REQUEST's own comment) both get the same crosshair rather than the Enemy_/Friendly_ naming scheme at all."""
a unit icon, neither fits the Enemy_/Friendly_ naming scheme at all.""" if target_type is TargetType.STRIKE:
if target_type in (TargetType.STRIKE, TargetType.STRIKE_REQUEST):
return STRIKE_ICON_PATH return STRIKE_ICON_PATH
own = _icon_for_side(target_type, is_ally) own = _icon_for_side(target_type, is_ally)
if own is not None: if own is not None:
@ -468,23 +467,19 @@ def _has_own_icon(t: "TargetType", is_ally: bool) -> bool:
def available_target_types(is_ally: bool = False): def available_target_types(is_ally: bool = False):
"""TargetType members worth offering in a picker for this side. """TargetType members worth offering in a picker for this side.
STRIKE/STRIKE_REQUEST are never offered: neither is a unit type at STRIKE is never offered: it's not a unit type at all (a planned
all (a planned impact point, not a contact), each is always created impact point, not a contact), it's always created through its own
through its own path instead -- STRIKE via app.py's dedicated "Add dedicated "Add strike" action (see app.py's _open_quick_add_menu),
strike" action, STRIKE_REQUEST via ocr.py parsing a fire-support never by picking a type from this generic grid -- there's no such
request -- never by picking a type from this generic grid. There's thing as a Strike-typed Ally either, offering it there is just
no such thing as a Strike-typed Ally either, offering either one confusing, not merely unlikely.
here is just confusing, not merely unlikely.
Otherwise: each side only offers types it actually has its own art Otherwise: each side only offers types it actually has its own art
for (see _has_own_icon / _TARGET_ICON) -- some types are enemy-only for (see _has_own_icon / _TARGET_ICON) -- some types are enemy-only
and some are friendly-only (King, Police, a friendly hospital, ...), and some are friendly-only (King, Police, a friendly hospital, ...),
the game simply doesn't draw an installation of every kind on both the game simply doesn't draw an installation of every kind on both
sides.""" sides."""
return [ return [t for t in TargetType if t is not TargetType.STRIKE and _has_own_icon(t, is_ally)]
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: def target_type_label(t: "TargetType", is_ally: bool) -> str:

View File

@ -66,16 +66,6 @@ class ScreenshotImport:
proposals: list = field(default_factory=list) proposals: list = field(default_factory=list)
overlay: object = None # BGRA array in map space overlay: object = None # BGRA array in map space
px_per_km: int = 0 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 # Board.targets/Board.allies as they stood right when this screenshot's
# grid was confirmed (see app.py's _accept_grid) -- Target/Ally are # grid was confirmed (see app.py's _accept_grid) -- Target/Ally are
# identity-hashable (models.py's `eq=False`), so these are plain sets # identity-hashable (models.py's `eq=False`), so these are plain sets
@ -98,14 +88,10 @@ class ScreenshotImport:
centre=m["centre"], box=m["box"]) for m in markers] centre=m["centre"], box=m["box"]) for m in markers]
return self.proposals return self.proposals
def build_overlay(self, px_per_km=150): def build_overlay(self, px_per_km=100):
"""Rectify the screenshot into map space, ready to draw under the grid. """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.overlay, self.px_per_km = map_vision.warp_to_map(
src, self.solution, px_per_km=px_per_km, img_scale=scale) self.image, self.solution, px_per_km=px_per_km)
return self.overlay return self.overlay
def accept_all(self): def accept_all(self):
@ -180,18 +166,7 @@ class ImportJob:
sol, img, err = map_vision.solve_path(path) sol, img, err = map_vision.solve_path(path)
if sol is None: if sol is None:
return None, err return None, err
imp = ScreenshotImport(solution=sol, image=img) return ScreenshotImport(solution=sol, image=img), None
# 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") return self._run(work, on_done, "map-import")

View File

@ -82,22 +82,6 @@ def load(path, work_w=None) -> np.ndarray:
return downscale(img, work_w) 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: def downscale(img, work_w=None) -> np.ndarray:
h, w = img.shape[:2] h, w = img.shape[:2]
s = min(1.0, (work_w or WORK_W) / w) s = min(1.0, (work_w or WORK_W) / w)
@ -665,19 +649,10 @@ def centre_cell_quad(sol, shape):
MAP_KM_W, MAP_KM_H = 20.0, 10.0 MAP_KM_W, MAP_KM_H = 20.0, 10.0
def warp_to_map(img, sol, px_per_km=150, img_scale=1.0): def warp_to_map(img, sol, px_per_km=100):
"""Rectify a screenshot into map space, ready to composite under the app's """Rectify a screenshot into map space, ready to composite under the app's
own grid. 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 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 covers is opaque; everything else is transparent, so a partial view of the
table does not blank out the rest of the map. table does not blank out the rest of the map.
@ -698,15 +673,6 @@ def warp_to_map(img, sol, px_per_km=150, img_scale=1.0):
# du, dv) is what pins those to named cells, and leaving it out put the # 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. # screenshot in the wrong place for every automatically solved grid.
M = grid_to_map @ sol.lattice_to_grid() @ np.linalg.inv(sol.H) 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 = cv2.cvtColor(img, cv2.COLOR_BGR2BGRA)
bgra[:, :, 3] = 255 bgra[:, :, 3] = 255
return cv2.warpPerspective(bgra, M, (out_w, out_h), flags=cv2.INTER_LINEAR, return cv2.warpPerspective(bgra, M, (out_w, out_h), flags=cv2.INTER_LINEAR,

View File

@ -18,7 +18,6 @@ Coord) to work out everything else. This module just defines the shape.
from __future__ import annotations from __future__ import annotations
import itertools
import string import string
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
@ -57,18 +56,7 @@ class TargetType(Enum):
# ("Enemy Signal Station", "Enemy Field Command"), not one of the # ("Enemy Signal Station", "Enemy Field Command"), not one of the
# game's fixed unit types, its id is the rest of that name with # game's fixed unit types, its id is the rest of that name with
# spaces stripped, see ocr.py's squash_enemy_names() # 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 ------------------------------------------- # -- Ground combat units -------------------------------------------
ANTI_AIR = "Anti-Air" ANTI_AIR = "Anti-Air"
@ -499,29 +487,6 @@ class ScoutFlight:
return f"ScoutFlight#{self.id}" 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 SAVE_FORMAT_VERSION = 3
@ -592,14 +557,9 @@ class Board:
location: Location | Coord | None = None, location: Location | Coord | None = None,
id_: str | None = None, id_: str | None = None,
) -> Target: ) -> 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_: 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) id_ = next(c for c in string.ascii_uppercase if c not in used)
t = Target(type=type_, id=id_, location=_as_location(location)) t = Target(type=type_, id=id_, location=_as_location(location))
self.targets.append(t) self.targets.append(t)
return t return t
@ -616,12 +576,11 @@ class Board:
) -> Ally: ) -> Ally:
# A separate id namespace from add_target()'s: an ally Tank#1 # A separate id namespace from add_target()'s: an ally Tank#1
# and a hostile Target Tank#1 are unrelated, so auto-assignment # and a hostile Target Tank#1 are unrelated, so auto-assignment
# here only looks at other allies, never self.targets. Same as # here only looks at other allies of the same type, never
# add_target though, that's the ONLY split: one shared A/B/C... # self.targets.
# sequence across every ally regardless of type, not one per type.
if not 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) id_ = next(c for c in string.ascii_uppercase if c not in used)
a = Ally(type=type_, id=id_, location=_as_location(location)) a = Ally(type=type_, id=id_, location=_as_location(location))
self.allies.append(a) self.allies.append(a)
return a return a

View File

@ -274,22 +274,6 @@ def _extract_bearing_distance_from_position_coord(text: str) -> Coord | None:
return solver.point_to_coord(point) 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 <coord>" 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 # "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 # 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 # (after _extract_grid_coord, which requires the full x:y and so is
@ -750,46 +734,18 @@ def parse_intel_blocks(text: str) -> list[dict]:
if current is not None: if current is not None:
joined = "\n".join(current["raw"]) joined = "\n".join(current["raw"])
current["clues"] = _parse_all_clues(joined) 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"] = ( current["coord"] = (
_extract_grid_coord(joined) or _extract_requested_on_coord(joined) _extract_grid_coord(joined) or _extract_requested_on_coord(joined)
or _extract_on_our_position_coord(joined) or _extract_on_our_position_coord(joined)
or _extract_our_position_coord(joined) or _extract_bearing_distance_from_position_coord(joined)
or _extract_large_grid_only_coord(joined) or _extract_large_grid_only_coord(joined)
) )
shell = _extract_shell_request(joined) or _extract_requesting_shell(joined) current["shell"] = _extract_shell_request(joined) or _extract_requesting_shell(joined)
requested_time = _extract_requested_time(joined) or _extract_taking_fire_time(joined) current["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 if (current["clues"] or current["coord"] is not None
or current["shell"] is not None or current["requested_time"] is not None): or current["shell"] is not None or current["requested_time"] is not None):
current["raw"] = joined current["raw"] = joined
entries.append(current) entries.append(current)
if offset_coord is not None:
# 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 current = None
for raw_line in text.splitlines(): for raw_line in text.splitlines():

View File

@ -372,16 +372,14 @@ def dedupe_generic_targets(board: Board) -> list[str]:
same* position as an already-known specific target, it's not a new same* position as an already-known specific target, it's not a new
contact, it's the same one being spotted, just described more contact, it's the same one being spotted, just described more
precisely. Drop the redundant generic entry, keep the specific one. precisely. Drop the redundant generic entry, keep the specific one.
Strikes (player-placed or requested) are planned impacts, not enemy Strikes are our own planned impacts, not enemy contacts, and never
contacts, and never participate. Run this after resolve_board(), participate. Run this after resolve_board(), since positions may
since positions may only become comparable once resolved. Returns only become comparable once resolved. Returns the names removed."""
the names removed."""
removed: list[str] = [] removed: list[str] = []
unknowns = [t for t in board.targets if t.type is TargetType.UNKNOWN and t.coord is not None] unknowns = [t for t in board.targets if t.type is TargetType.UNKNOWN and t.coord is not None]
specifics = [ specifics = [
t for t in board.targets t for t in board.targets
if t.type not in (TargetType.UNKNOWN, TargetType.STRIKE, TargetType.STRIKE_REQUEST) if t.type not in (TargetType.UNKNOWN, TargetType.STRIKE) and t.coord is not None
and t.coord is not None
] ]
for generic in unknowns: for generic in unknowns:
if any(generic.coord == specific.coord for specific in specifics): if any(generic.coord == specific.coord for specific in specifics):

View File

@ -1,59 +0,0 @@
"""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")

View File

@ -67,45 +67,6 @@ 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 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(): def test_find_by_name_prefers_target_over_same_named_ally():
"""find_by_name() (used to resolve Clue references) checks targets """find_by_name() (used to resolve Clue references) checks targets
before allies -- documented, deliberate priority, not a namespace before allies -- documented, deliberate priority, not a namespace

View File

@ -272,55 +272,19 @@ def test_infantry_taking_fire_no_attacker_mention():
def test_infantry_taking_fire_bearing_distance_from_position(): def test_infantry_taking_fire_bearing_distance_from_position():
"""The other request shape: the shell isn't wanted right on top of the """The other request shape: the shell isn't wanted right on top of the
reporting unit, but at a bearing/distance offset from its own reporting unit, but at a bearing/distance offset from its own
(inline-given) position -- two different places, so this becomes two (inline-given) position -- 'our position' isn't a named board entity
entries: Infantry#3 stays at its own reported position (no shell/ to hang a Clue off of, so this resolves straight to an absolute
deadline, it's not the fire point), and a separate synthetic Strike coord."""
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" text = ("Infantry#3 taking fire!\n"
"Requesting <u><b>HE Shell</b></u> at bearing <b>239°</b>, distance " "Requesting <u><b>HE Shell</b></u> at bearing <b>239°</b>, distance "
"<b>10.76km</b> from our position, <b>J6 2:5</b>, by <u>10:38:18</u> " "<b>10.76km</b> from our position, <b>J6 2:5</b>, by <u>10:38:18</u> "
"or we will be overrun!") "or we will be overrun!")
info = ocr.parse_text(text) info = ocr.parse_text(text)
assert (TargetType.INFANTRY, "3") in info.targets assert (TargetType.INFANTRY, "3") in info.targets
raw, clues, coord, shell, requested_time = info.targets[(TargetType.INFANTRY, "3")] 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 shell is Shell.HE
assert requested_time == "10:38:18" assert requested_time == "10:38:18"
from fenigma import solver from fenigma import solver
expected = solver.point_to_coord( expected = solver.point_to_coord(
solver.point_from_bearing_distance(Coord("J", 6, 2, 5).as_fraction(), 239.0, 10.76)) solver.point_from_bearing_distance(Coord("J", 6, 2, 5).as_fraction(), 239.0, 10.76))
assert coord == expected 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 <u><b>HE Shell</b></u> at bearing <b>210°</b>, distance "
"<b>0.43km</b> from our position, <b>I7 0:8</b>, by <u>10:17:37</u> "
"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"