Compare commits
3 Commits
49863b6045
...
bd69b183ad
| Author | SHA1 | Date | |
|---|---|---|---|
| bd69b183ad | |||
| 4556da37ff | |||
| 23615a8c92 |
5
.gitignore
vendored
5
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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?
|
||||
|
||||
61
TODO.md
61
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#<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
|
||||
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
|
||||
|
||||
70
packaging/windows/README.md
Normal file
70
packaging/windows/README.md
Normal file
@ -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.
|
||||
80
packaging/windows/build_windows.sh
Executable file
80
packaging/windows/build_windows.sh
Executable file
@ -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")"
|
||||
37
packaging/windows/docker-compose.yml
Normal file
37
packaging/windows/docker-compose.yml
Normal file
@ -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
|
||||
86
packaging/windows/oem/build.bat
Normal file
86
packaging/windows/oem/build.bat
Normal file
@ -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-<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
|
||||
81
packaging/windows/oem/install.bat
Normal file
81
packaging/windows/oem/install.bat
Normal file
@ -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
|
||||
68
packaging/windows/oem/product.wxs
Normal file
68
packaging/windows/oem/product.wxs
Normal file
@ -0,0 +1,68 @@
|
||||
<?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>
|
||||
31
packaging/windows/oem/watch_build.bat
Normal file
31
packaging/windows/oem/watch_build.bat
Normal file
@ -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
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 <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
|
||||
# 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():
|
||||
|
||||
@ -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):
|
||||
|
||||
59
tests/test_map_vision_warp.py
Normal file
59
tests/test_map_vision_warp.py
Normal file
@ -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")
|
||||
@ -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
|
||||
|
||||
@ -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 <u><b>HE Shell</b></u> at bearing <b>239°</b>, distance "
|
||||
"<b>10.76km</b> from our position, <b>J6 2:5</b>, by <u>10:38:18</u> "
|
||||
"or we will be overrun!")
|
||||
info = ocr.parse_text(text)
|
||||
|
||||
assert (TargetType.INFANTRY, "3") in info.targets
|
||||
raw, clues, coord, shell, requested_time = info.targets[(TargetType.INFANTRY, "3")]
|
||||
assert 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 <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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user