fix/id-space-strike-request-perf-2026-08-11 #2
5
.gitignore
vendored
5
.gitignore
vendored
@ -5,3 +5,8 @@ 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/
|
||||||
|
|||||||
@ -48,11 +48,6 @@ 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
61
TODO.md
@ -3,17 +3,26 @@
|
|||||||
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.
|
||||||
Investigated: `Board.add_target`/`add_ally` already use fully separate
|
First pass on this was wrong: I only checked that targets and allies
|
||||||
id namespaces by design (see `models.py`'s `Ally`/`Target` docstrings),
|
are separate id namespaces (they are, always were) and stopped there.
|
||||||
confirmed with a new regression test
|
The actual bug was one level down: `Board.add_target`/`add_ally`'s
|
||||||
(`test_ally_and_target_ids_are_independent_namespaces`). What was
|
auto-id assignment (`used = {t.id for t in self.targets if t.type ==
|
||||||
probably actually seen: an ally and a hostile target of the same type
|
type_}`) was scoped **per type**, not per group — a Tank and an
|
||||||
display with the *same name* ("Tank#1") on the map with no visual
|
Infantry auto-added back to back both got id "A", each type getting
|
||||||
"ally" cue beyond icon/side color — related to the next item, which
|
its own independent A/B/C... sequence instead of sharing one across
|
||||||
fixes one concrete instance of that (TargetType.ENEMY's "Enemy" label
|
the whole group. Fixed: the id namespace split is targets-vs-allies
|
||||||
on an Ally). If the symptom persists after that, it's a display/
|
ONLY, type never subdivides it further. New regression test
|
||||||
legibility issue, not an id collision — happy to take a screenshot of
|
(`test_auto_id_is_shared_across_types_within_targets_and_within_allies`).
|
||||||
what's confusing.
|
- [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.
|
- [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
|
||||||
@ -73,6 +82,16 @@ 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.
|
||||||
@ -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
|
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
|
||||||
|
|||||||
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.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,
|
||||||
@ -785,13 +786,21 @@ 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)
|
||||||
inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
scroller.set_child(icons.build_target_type_grid(
|
||||||
for t in TargetType:
|
detected, lambda t: accept(t), is_ally=(proposal.side == "friendly"),
|
||||||
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)
|
||||||
|
|
||||||
@ -1419,6 +1428,7 @@ 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()
|
||||||
|
|
||||||
@ -1505,6 +1515,12 @@ 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)
|
||||||
@ -1563,6 +1579,19 @@ 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
|
||||||
@ -1580,6 +1609,17 @@ 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
|
||||||
@ -1708,6 +1748,7 @@ 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()
|
||||||
|
|||||||
@ -106,12 +106,28 @@ 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_select, on_edit_position, on_set_position, on_remove,
|
self, board: Board, *, on_change, on_visual_change, on_select, on_edit_position, on_set_position,
|
||||||
on_toggle_hide_dead_map,
|
on_remove, 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
|
||||||
@ -135,14 +151,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)
|
||||||
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),
|
# 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.
|
||||||
scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
self._scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||||||
self.append(scroller)
|
self.append(self._scroller)
|
||||||
|
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
@ -195,6 +211,33 @@ 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:
|
||||||
@ -203,6 +246,28 @@ 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,
|
||||||
@ -505,14 +570,39 @@ 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.on_change()
|
self._rebuild_one(target)
|
||||||
|
|
||||||
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.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:
|
def _pick_shell(self, target: Target, shell: Shell) -> None:
|
||||||
target.shell = shell
|
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: ("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}, (
|
assert {*_TARGET_ICON} | {TargetType.STRIKE, TargetType.STRIKE_REQUEST} == {*TargetType}, (
|
||||||
"every TargetType needs a row in _TARGET_ICON (STRIKE is the one "
|
"every TargetType needs a row in _TARGET_ICON (STRIKE/STRIKE_REQUEST "
|
||||||
"deliberate exception, see the comment above it)"
|
"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
|
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 (a planned impact point, not a
|
see assets/icons/README.md). STRIKE/STRIKE_REQUEST (a planned impact
|
||||||
unit) gets its own crosshair rather than a unit icon, it doesn't fit
|
point, not a unit -- player-placed vs called in by a friendly, see
|
||||||
the Enemy_/Friendly_ naming scheme at all."""
|
STRIKE_REQUEST's own comment) both get the same crosshair rather than
|
||||||
if target_type is TargetType.STRIKE:
|
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
|
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:
|
||||||
@ -467,19 +468,23 @@ 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 is never offered: it's not a unit type at all (a planned
|
STRIKE/STRIKE_REQUEST are never offered: neither is a unit type at
|
||||||
impact point, not a contact), it's always created through its own
|
all (a planned impact point, not a contact), each is always created
|
||||||
dedicated "Add strike" action (see app.py's _open_quick_add_menu),
|
through its own path instead -- STRIKE via app.py's dedicated "Add
|
||||||
never by picking a type from this generic grid -- there's no such
|
strike" action, STRIKE_REQUEST via ocr.py parsing a fire-support
|
||||||
thing as a Strike-typed Ally either, offering it there is just
|
request -- never by picking a type from this generic grid. There's
|
||||||
confusing, not merely unlikely.
|
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
|
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 [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:
|
def target_type_label(t: "TargetType", is_ally: bool) -> str:
|
||||||
|
|||||||
@ -66,6 +66,16 @@ 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
|
||||||
@ -88,10 +98,14 @@ 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=100):
|
def build_overlay(self, px_per_km=150):
|
||||||
"""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(
|
||||||
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
|
return self.overlay
|
||||||
|
|
||||||
def accept_all(self):
|
def accept_all(self):
|
||||||
@ -166,7 +180,18 @@ 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
|
||||||
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")
|
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)
|
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)
|
||||||
@ -649,10 +665,19 @@ 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=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
|
"""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.
|
||||||
@ -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
|
# 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,
|
||||||
|
|||||||
@ -18,6 +18,7 @@ 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
|
||||||
@ -56,7 +57,18 @@ 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"
|
||||||
@ -487,6 +499,29 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@ -557,9 +592,14 @@ 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 if t.type == type_}
|
used = {t.id for t in self.targets}
|
||||||
id_ = next(c for c in string.ascii_uppercase if c not in used)
|
id_ = _next_free_id(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
|
||||||
@ -576,11 +616,12 @@ 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 of the same type, never
|
# here only looks at other allies, never self.targets. Same as
|
||||||
# self.targets.
|
# 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_:
|
if not id_:
|
||||||
used = {a.id for a in self.allies if a.type == type_}
|
used = {a.id for a in self.allies}
|
||||||
id_ = next(c for c in string.ascii_uppercase if c not in used)
|
id_ = _next_free_id(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
|
||||||
|
|||||||
@ -274,6 +274,22 @@ 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
|
||||||
@ -734,18 +750,46 @@ 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_bearing_distance_from_position_coord(joined)
|
or _extract_our_position_coord(joined)
|
||||||
or _extract_large_grid_only_coord(joined)
|
or _extract_large_grid_only_coord(joined)
|
||||||
)
|
)
|
||||||
current["shell"] = _extract_shell_request(joined) or _extract_requesting_shell(joined)
|
shell = _extract_shell_request(joined) or _extract_requesting_shell(joined)
|
||||||
current["requested_time"] = _extract_requested_time(joined) or _extract_taking_fire_time(joined)
|
requested_time = _extract_requested_time(joined) or _extract_taking_fire_time(joined)
|
||||||
|
# The offset variant's shell/deadline describe the FIRE POINT,
|
||||||
|
# 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():
|
||||||
|
|||||||
@ -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
|
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 are our own planned impacts, not enemy contacts, and never
|
Strikes (player-placed or requested) are planned impacts, not enemy
|
||||||
participate. Run this after resolve_board(), since positions may
|
contacts, and never participate. Run this after resolve_board(),
|
||||||
only become comparable once resolved. Returns the names removed."""
|
since positions may only become comparable once resolved. Returns
|
||||||
|
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) 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:
|
for generic in unknowns:
|
||||||
if any(generic.coord == specific.coord for specific in specifics):
|
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
|
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
|
||||||
|
|||||||
@ -272,19 +272,55 @@ 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 -- 'our position' isn't a named board entity
|
(inline-given) position -- two different places, so this becomes two
|
||||||
to hang a Clue off of, so this resolves straight to an absolute
|
entries: Infantry#3 stays at its own reported position (no shell/
|
||||||
coord."""
|
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"
|
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"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user