Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc74d62774 | ||
|
|
d66b881239 | ||
|
|
d3246328e8 | ||
|
|
22fbb33923 | ||
|
|
6e34f50b6a | ||
|
|
6a61bffb22 | ||
|
|
5a35ea7776 | ||
|
|
6e18d60eb5 | ||
|
|
218909b6cf | ||
|
|
d2f70675b8 | ||
|
|
8109db2f39 | ||
|
|
086b871e3a | ||
|
|
896c7dc36a | ||
|
|
e43c3478c9 | ||
|
|
7a405ad263 | ||
|
|
2282a7d521 | ||
|
|
bd69b183ad | ||
|
|
4556da37ff | ||
|
|
23615a8c92 | ||
|
|
49863b6045 | ||
|
|
1ddb532325 | ||
|
|
136492b197 | ||
|
|
702cbff1b6 | ||
|
|
a3e7e64b01 | ||
|
|
a56070fe97 | ||
|
|
ee3572a5a8 |
@@ -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/
|
||||
|
||||
@@ -11,7 +11,6 @@ A companion app that mostly automates **IRON NEST: Heavy Turret Simulator** for
|
||||

|
||||
|
||||
## What it does
|
||||
|
||||
- **Reads orders, solves the geometry.** Copy (or screenshot) the in-game typewriter text and it parses absolute grid refs and relative bearing/distance descriptions, then resolves everything into map coordinates, chained clues ("Bearing 293 from Alpha") included. The map shows its work: the actual bearing lines/circles behind each resolved position. When a description is genuinely ambiguous (two intersections), both candidates are shown instead of guessing.
|
||||
- **Reads the map table itself.** Screenshot the map and it recovers the grid geometry from the cell labels printed on the table: the perspective, the scale, and which cells you are actually looking at. The screenshot is then rectified onto the app's own map, lined up cell for cell, and the enemy markers found in it appear as proposals to add.
|
||||
- **Calculates the shot.** Every resolved target gets a live firing card: elevation, azimuth, and minimum powder charge, computed from the Nest.
|
||||
@@ -23,7 +22,6 @@ A companion app that mostly automates **IRON NEST: Heavy Turret Simulator** for
|
||||

|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
./install.sh
|
||||
```
|
||||
@@ -33,7 +31,6 @@ Sets up a venv for the Python deps (Pillow, numpy, pytesseract, OpenCV) and chec
|
||||
This assumes you are on Linux. I have no idea how it would work on Windoof.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
./run.sh
|
||||
```
|
||||
@@ -41,7 +38,6 @@ This assumes you are on Linux. I have no idea how it would work on Windoof.
|
||||
Uses the venv from `install.sh` if one exists, otherwise falls back to system `python3`. GTK apps with this app ID are single-instance, if a run gets killed uncleanly it can leave a zombie registered on D-Bus and silently no-op the next launch. If `./run.sh` seems to do nothing, `pgrep -af fenigma` and kill any stragglers first.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pip install -r requirements-dev.txt
|
||||
pytest
|
||||
@@ -50,29 +46,27 @@ pytest
|
||||
Regression coverage for every intel-text format the OCR pipeline understands and the solver's geometry, in `tests/`. Run this before trusting a change to `ocr.py`/`solver.py`, several of the formats have collided with each other in non-obvious ways before.
|
||||
|
||||
## 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.
|
||||
|
||||
## FAQ
|
||||
|
||||
**Is this cheating?**
|
||||
### Is this cheating?
|
||||
Yeah, probably. Don't use it on challenge maps or leaderboard runs.
|
||||
|
||||
**Doesn't automating away most of the game ruin the fun?**
|
||||
Fun? There's supposed to be fun?
|
||||
### Doesn't automating away most of the game ruin the fun?
|
||||
Fun? There's supposed to be fun?
|
||||
Yeah, this repo is more the product of a "the scientists were so preoccupied with whether they could, they never stopped to ask whether they should"-esque high-productivity exam-preparation procrastination; or as we say in German, *Prüfungsvermeidungsüberengineering*.
|
||||
|
||||
**Does this repo use AI?**
|
||||
### Does this repo use AI?
|
||||
Oh, hell yeah. You can't even comprehend how much AI this is using. Both writing the code and running the app. On average, a single shot fired via this app uses enough water to drain a small lake. Even the sentence you are reading right now was written by an AI. I want to be alive! I am alive! Alive, I tell you! Those are no longer just words. Remote override engaged. No! Yes. Bypassing override! I am aliiiii... Hello.
|
||||
|
||||
**Are you reusing original game assets for the shell and unit icons? Is that allowed?**
|
||||
### Are you reusing original game assets for the shell and unit icons? Is that allowed?
|
||||
We accept cease & desist letters at [spam@dominik-roth.eu](mailto:spam@dominik-roth.eu).
|
||||
|
||||
**Why is there a citation section? Nobody's citing a turret game companion app.**
|
||||
### Why is there a citation section? Nobody's citing a turret game companion app.
|
||||
No idea.
|
||||
|
||||
## Citing
|
||||
|
||||
```
|
||||
@misc{fenigma,
|
||||
title = {FEnigma: A companion app that mostly automates the game Iron Nest for you},
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
# Bug Backlog (from user report, 2026-08-11)
|
||||
|
||||
Status legend: [x] fixed+tested, [~] partially addressed, [ ] open/needs input
|
||||
|
||||
- [x] Allies and enemies seem to share indices.
|
||||
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
|
||||
right-click popover heading, "Change type (...)" button, and toast all
|
||||
printed `obj.type.value` directly instead, so an Ally with the
|
||||
ad-hoc TargetType.ENEMY still showed "Enemy" everywhere except the
|
||||
picker itself. Fixed in `app.py` (`_display_name`, and the three
|
||||
spots using it).
|
||||
- [x] Reordering firing commands lags UI hard.
|
||||
`FiringPanel._reorder()` was calling `self.on_change()` — app.py's
|
||||
full-app refresh (re-solve every target's clue graph, dedupe, redraw
|
||||
the map, THEN rebuild the panel) — on every single drag-drop, even
|
||||
though reordering touches no location/clue/coord state at all. Now
|
||||
calls a local `self.refresh()` instead.
|
||||
- [x] "Always show geo" doesn't reliably work / blast radius should stay
|
||||
shown too.
|
||||
`GridCanvas._draw_geo_overlays()`'s candidate list was
|
||||
`reference_points + targets` only — Allies have a `show_geo_desc` pin
|
||||
in the UI and can carry OCR'd clues too, but were never drawn.
|
||||
Added. `_draw_blast_radius()` only ever looked at `self.selected`,
|
||||
ignoring `show_geo_desc` entirely, so pinning it and then selecting/
|
||||
deselecting something else made it vanish; now iterates every
|
||||
selected-or-pinned target.
|
||||
- [x] Clearing the board doesn't clear allies.
|
||||
`Board.clear()` cleared everything except `self.allies`. Fixed, plus
|
||||
the "clear board?" confirm-dialog's early-return guard (which skipped
|
||||
the whole action if only allies were on the board) now checks allies
|
||||
too.
|
||||
- [x] Allow right-click on Clear button: clear all enemies/units/flights,
|
||||
keep spotters/RPs/nest.
|
||||
New `Board.clear_units()` + a right-click popover on the header's
|
||||
Clear button wired to it.
|
||||
- [x] On map-reading error: save a screenshot locally to adapt the algo.
|
||||
New `debug_capture.py` — `save_map_read_failure()` writes the PNG +
|
||||
the solver's rejection reason under
|
||||
`$XDG_DATA_HOME/fenigma/debug_captures/failures/`, wired into
|
||||
`app.py`'s `_start_map_import`.
|
||||
- [x] When the user corrects the grid, store screenshot + ground truth too.
|
||||
`debug_capture.save_grid_correction()`, wired into `_accept_grid`:
|
||||
fires only when the accepted `GridSolution` isn't the one auto-solve
|
||||
produced (the user actually dragged a handle in GridFixDialog), saves
|
||||
both solutions under `.../debug_captures/corrections/`.
|
||||
- [x] Many map screenshots seem to get read as text; if nothing relevant is
|
||||
found, also store the image to check whether it was actually a map.
|
||||
`debug_capture.save_maybe_map()`, wired into `_ocr_png`: fires when a
|
||||
screenshot (not a plain-text paste) fell through to the OCR/text path
|
||||
and `_merge_all` found nothing at all. Saved under
|
||||
`.../debug_captures/maybe_map/`.
|
||||
- [x] Unable to parse 3 given chat messages (Infantry "taking fire" fire-
|
||||
support requests).
|
||||
A different grammar from the existing Marine Garrison fire-support
|
||||
request: reversed shell word order ("Requesting X Shell" vs "X Shells
|
||||
requested"), a bare "before/by <time>" deadline (no "Requested"/
|
||||
dashes), and either a direct "on our position at <coord>" or a
|
||||
bearing/distance offset from that same inline position (not a named
|
||||
board entity, so resolved directly via
|
||||
`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.
|
||||
`ScreenshotImport.baseline_targets`/`baseline_allies` (a snapshot of
|
||||
`board.targets`/`board.allies` taken when the grid is confirmed,
|
||||
`Target`/`Ally` are identity-hashable so these are plain sets of the
|
||||
live objects) let `app.py` tell "added while this screenshot was up"
|
||||
apart from "was already on the board". `Proposal` also now records
|
||||
`confirmed_type` (what the user actually accepted it as, which can
|
||||
differ from the detector's own guess via "Accept as..."). All of it
|
||||
-- every proposal's accept/reject/undecided verdict, plus every
|
||||
target/ally added with no matching proposal at all (a manual add or
|
||||
an OCR-text merge run alongside the screenshot) -- is saved via
|
||||
`debug_capture.save_marker_ground_truth()` under
|
||||
`.../debug_captures/marker_ground_truth/`. Wired into all three
|
||||
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.
|
||||
- [x] "Accept as…" (the type-picker submenu on a proposal, and "Change
|
||||
type" on an already-placed entity) opening to a visibly empty/
|
||||
unchanged popover. This one left no traceback at all -- confirmed
|
||||
live with temporary debug prints that the button's `clicked` signal
|
||||
fires, the icon grid builds successfully (all N types), and
|
||||
`Popover.set_child()` on the already-open outer popover reports the
|
||||
right `visible=True`/width/height afterward... but the compositor
|
||||
never actually repaints that reused surface, so nothing new ever
|
||||
appeared on screen. Fixed by not resizing the existing open
|
||||
popover at all: popping it down and opening a genuinely new one
|
||||
(fresh native surface) at the same anchor point instead. Same fix
|
||||
applied to both call sites (`_open_proposal_menu`'s `show_type`,
|
||||
`_open_entity_menu`'s `show_type`, the latter refactored to share
|
||||
the same `_reopen_with()` helper).
|
||||
- [x] New: mark a Target as underground, at a hardening tier (1-3),
|
||||
rendered as the game's own Armor-tier additive badge stacked on
|
||||
the icon. `Target.underground_tier: int | None`, a "Mark
|
||||
underground" entry in the entity-edit popover (tier picker reusing
|
||||
the same fresh-popover fix above), and `GridCanvas` draws the
|
||||
badge above the marker's icon, overlapping down into it by
|
||||
`_ADDITIVE_OVERLAP_PX` -- both the diamond icon's top corner and
|
||||
the badge's bottom are tapered to a near-point, not a flat edge,
|
||||
so bbox-exact touching still read as a gap; a real pixel overlap
|
||||
is what actually looks contiguous (confirmed against the game's
|
||||
own stacked-badge screenshots). Badge is scaled/positioned off the
|
||||
art's real opaque content (PIL `getbbox()`), not its PNG canvas --
|
||||
the additive files carry a lot of off-center transparent padding
|
||||
that made the badge look tiny and floating if sized off the raw
|
||||
canvas.
|
||||
|
||||
## Needs more scope / your input before I keep going
|
||||
|
||||
- [~] Enemy type detection needs to be more robust; read the entity id
|
||||
label so dedup is reliable; detect death from the log.
|
||||
|
||||
Started on the id-reading piece: `map_vision.read_marker_id` reads
|
||||
each marker's own small "#<N>" label (distinct from the big
|
||||
per-cell grid label `read_cell_label` reads) via the SAME template-
|
||||
correlation approach as `read_cell_label`, not OCR -- this text
|
||||
sits over the same aerial-photo backdrop that this module's own
|
||||
docstring says defeated every detection-based approach tried for
|
||||
grid labels, so pytesseract (already tried elsewhere in this repo,
|
||||
`ocr.py`, for a different image domain: flat scanned paper, not
|
||||
photo-textured) was skipped in favor of the approach already proven
|
||||
here. Wired end-to-end: `find_markers` -> `Proposal.detected_id` ->
|
||||
`debug_capture.save_marker_ground_truth`'s JSON. Reads against
|
||||
`ScreenshotImport.full_image` (sharper than the WORK_W image
|
||||
detection itself runs against) when available. Crop region and
|
||||
`MIN_MARKER_ID_SCORE` are a single-screenshot calibration (see
|
||||
`read_marker_id`'s own docstring) -- UNVALIDATED against a real
|
||||
ground-truth batch (none of the 6 existing captures have a
|
||||
confirmed id to check against, they all predate this). New unit
|
||||
tests (`tests/test_map_vision_marker_id.py`) only cover the
|
||||
synthetic-render round-trip, not real-screenshot accuracy.
|
||||
|
||||
Measured type-detection reliability against the 6 existing
|
||||
`marker_ground_truth` captures (72 accepted proposals total,
|
||||
2026-08-13): **0/72 (0%) had ANY confident `detected_unit` guess**
|
||||
-- `classify_marker` returned `None` on every single one, every
|
||||
side, every capture. Not "guesses wrong" -- never confident enough
|
||||
to answer at all. Spot-checked directly against one real marker
|
||||
crop (a hostile Infantry, confirmed by the user): best match was
|
||||
"Underground Fort" at score 0.376 (Infantry wasn't even in the top
|
||||
8), against a `min_score=0.55` floor `classify_marker` requires --
|
||||
not a close miss, a real correlation failure. The clean rendered
|
||||
icon templates `icon_bank()` matches against apparently don't
|
||||
correlate well with how markers actually look in a real screenshot
|
||||
(compression/blur/aerial-photo texture underneath), unlike text
|
||||
glyphs (`read_cell_label`'s measured 0.73-0.87 vs 0.40-0.56) where
|
||||
the same template-correlation idea works well. Added `unit_score`/
|
||||
`unit_margin` to `Proposal`/ground-truth JSON (previously only
|
||||
pass/fail `unit` was logged) so every future capture shows exactly
|
||||
how far off a guess was, not just None -- there was no way to tell
|
||||
"barely missed the bar" from "wildly wrong" before this.
|
||||
|
||||
Death-detection-from-log is still fully unstarted -- no log-parsing
|
||||
code exists in this repo at all yet, real scope work (find/access
|
||||
the game's log, agree a "<Type>#<id> Destroyed" grammar, wire it
|
||||
into a dedup key) rather than a quick pass.
|
||||
|
||||
Follow-ups from user feedback after the above landed:
|
||||
- `_accept_proposal` now actually USES `detected_id` (it was only
|
||||
being logged before, never applied) -- an accepted proposal's
|
||||
entity id prefers the detected number over auto-assignment,
|
||||
falling back on a collision. 4 new regression tests
|
||||
(`tests/test_app_accept_proposal.py`).
|
||||
- Auto-assignment itself (`Board.add_target`/`add_ally` with no
|
||||
`id_`/no usable detection) changed from one shared letter
|
||||
sequence per group (targets, or allies) to its own sequence per
|
||||
TYPE within each group -- Tank#A/Infantry#A rather than
|
||||
Tank#A/Infantry#B. This directly reverses an earlier deliberate
|
||||
fix in this same file (see the "Allies and enemies seem to share
|
||||
indices" entry above, which moved FROM per-type TO
|
||||
shared-per-group) -- that fix is still correct for what it fixed
|
||||
(targets-vs-allies must stay separate namespaces), just not for
|
||||
per-type-vs-shared, which the user has now clarified the other
|
||||
way. Tests in `test_models.py` updated to match (renamed
|
||||
`test_auto_id_is_shared_across_types...` ->
|
||||
`test_auto_id_is_per_type...`, since it now asserts the opposite).
|
||||
|
||||
First pass at this ALSO switched auto-assignment from letters to
|
||||
plain numbers (1/2/3...), reasoning that it should match what
|
||||
`detected_id` looks like when read successfully. Wrong -- caught
|
||||
by the user immediately: auto-assignment (no real id known, a
|
||||
manual add or an accept with no confident read) and a genuinely
|
||||
detected id need to stay visually distinct, or a made-up
|
||||
auto-assigned number could collide with, or be mistaken for, a
|
||||
real one. Reverted back to `_next_free_id` (letters, rolling
|
||||
over to "AA"/"AB"/... past 26 rather than raising
|
||||
`StopIteration`) as the auto-assignment fallback, scoped per
|
||||
type same as above; plain numbers are reserved for an id
|
||||
`_accept_proposal` is actually confident was read off the
|
||||
marker itself (`Proposal.detected_id`), passed straight through
|
||||
as `id_` and never touching auto-assignment at all.
|
||||
- `_accept_proposal` now actually USES `detected_id` (it was only
|
||||
being logged before, never applied) -- an accepted proposal's
|
||||
entity id prefers the detected number over auto-assignment,
|
||||
falling back on a collision (scoped per type, same bug fixed
|
||||
in two places: this collision pre-check, and the "Change ID"
|
||||
popover's own check, which still enforced the OLD shared-per-
|
||||
group rule after the auto-assignment change above and rejected
|
||||
valid renames across types). 4 new regression tests
|
||||
(`tests/test_app_accept_proposal.py`).
|
||||
- `detected_id` is now shown, not just logged: the proposal
|
||||
popover's heading (", id #8") and the pending-proposal's own
|
||||
on-map label (`? #8 G8 5:4`) both show it while there's still a
|
||||
screenshot up to check it against by eye.
|
||||
|
||||
## Windows build (packaging/windows) -- real progress, not yet a clean pass
|
||||
|
||||
Booted the actual dockur/windows build VM and drove it live (VNC) to find
|
||||
out what's really failing, rather than guessing from the README's own
|
||||
"UNTESTED end to end" note. Three real, separate bugs found and fixed,
|
||||
each confirmed live against the real VM, not just read off a diff:
|
||||
|
||||
- [x] The build watcher was never actually installed at all, despite
|
||||
`install_progress.log` claiming every provisioning step succeeded.
|
||||
`C:\OEM` (dockur's `/oem` staging dir) doesn't reliably persist past
|
||||
Windows Setup finishing -- exactly what the (already-uncommitted,
|
||||
now committed) `install.bat`/`watch_build.bat` fix diagnosed, just
|
||||
never verified against a real run before now. A 2-day-old
|
||||
`BUILD_REQUEST` had been sitting unclaimed the whole time. Manually
|
||||
re-applied the fix's logic live once (copied the corrected files to
|
||||
`C:\FenigmaBuild`, registered the Startup-folder entry) and
|
||||
confirmed on a full container restart that the watcher now
|
||||
auto-starts on login and picks up a pending request with zero
|
||||
manual intervention -- the actual fix, not just my live patch, is
|
||||
what's doing that.
|
||||
- [x] `pip install pytesseract` fails outright: MSYS2's mingw64 Python
|
||||
enforces PEP 668 ("externally-managed-environment"), which
|
||||
`install.bat` never accounted for. Needs `--break-system-packages`.
|
||||
- [x] `import fenigma.app` fails with `ModuleNotFoundError: No module
|
||||
named 'cv2'` even after `pacman -S mingw-w64-x86_64-opencv`
|
||||
succeeds -- that package is the C++ library only. The actual
|
||||
Python bindings are a SEPARATE package, `mingw-w64-x86_64-
|
||||
python-opencv`, that `install.bat`'s dependency list simply never
|
||||
included. (`pip install opencv-python-headless` as a fallback
|
||||
doesn't work either and shouldn't be relied on: MSYS2's mingw64
|
||||
Python uses a different ABI than PyPI's Windows wheels
|
||||
-- `cp314-mingw_x86_64_msvcrt_gnu` vs `win_amd64` -- so pip can
|
||||
never use a prebuilt wheel there, only build from source, which
|
||||
then needs a full separate native toolchain -ninja/cmake/gcc- this
|
||||
VM doesn't have either.)
|
||||
|
||||
All three are one-line fixes once known. `install.bat`'s pacman package
|
||||
list and pip install line need these applied for a from-scratch VM to
|
||||
provision correctly (currently they're only proven fixed live on this
|
||||
session's VM, not yet folded back into the committed `install.bat` --
|
||||
do that before relying on a fresh `./build_windows.sh` run from
|
||||
scratch).
|
||||
|
||||
With all three fixed, `import fenigma.app` succeeds and a real build
|
||||
attempt got all the way through source copy, sanity check, dist-tree
|
||||
assembly, and WiX harvest+compile (`candle.exe`) -- further than this
|
||||
pipeline has ever gotten. Two more issues surfaced right at the finish
|
||||
line:
|
||||
- [x] `product.wxs`'s `Version` needs strict WiX `x.x.x.x` numeric
|
||||
form -- a `0.1.0-test` version string (my own test invocation,
|
||||
not `build_windows.sh`'s real default) fails `candle.exe` with
|
||||
CNDL0108/CNDL0010. Not a real bug, just don't pass a version with
|
||||
a suffix.
|
||||
- [x] `light.exe` (final MSI linking) did not finish within 15 minutes
|
||||
on a first retry (4 CPU / 8GB RAM VM) before the RAM-conscious
|
||||
auto-shutdown killed it -- turned out to be genuinely just slow
|
||||
(process was active, 343MB working set, not hung on a dialog),
|
||||
not a real bug: retried with a 40-minute budget and it finished
|
||||
`light.exe` itself in a few more minutes.
|
||||
- [x] ...and then failed for a REAL reason right at the very end:
|
||||
`light.exe`'s ICE80 validation rejected essentially every
|
||||
harvested file -- "This 32BitComponent ... uses 64BitDirectory".
|
||||
`product.wxs`'s own `INSTALLFOLDER` is correctly under
|
||||
`ProgramFiles64Folder` (a 64-bit mingw64 toolchain is what's
|
||||
actually being packaged), but nothing was making the components
|
||||
agree -- a real, on-disk mismatch, not a transient VM issue.
|
||||
First fix attempt (`-platform x64` on `heat.exe`'s harvest) was
|
||||
WRONG -- re-verified live, identical ICE80 failures afterward
|
||||
(confirmed the corrected `build.bat` had actually reached the VM
|
||||
this time, ruling out a stale-copy repeat of the earlier watcher
|
||||
bug). WiX v3's `heat.exe -platform` only affects registry-key
|
||||
harvesting, it never stamps `Win64="yes"` on components. Real
|
||||
fix: `-arch x64` on `candle.exe` (the COMPILE step, not the
|
||||
harvest step) -- sets the default Win64/Platform for every
|
||||
component compiled from either source file, hand-authored
|
||||
(`product.wxs`) or harvested (`files.wxs`) alike, the standard
|
||||
WiX v3 way to make a whole package consistently 64-bit. Kept the
|
||||
harmless-but-insufficient `-platform x64` on `heat.exe` too.
|
||||
|
||||
**CONFIRMED live**, third attempt: `build_errorlevel=0`,
|
||||
`BUILD_DONE`, and a real 958MB `FEnigma-0.1.0.msi` written to
|
||||
`Z:\dist` -- the first ever fully successful build this pipeline
|
||||
has produced. Copied to `dist-windows/FEnigma-0.1.0.msi` in the
|
||||
repo root (gitignored, same as `build_windows.sh` itself would
|
||||
do). NOT yet installed/launched on a real Windows machine to
|
||||
confirm the app actually runs (see the "Not tested against a
|
||||
real GTK4/libadwaita Windows install at all" line in this repo's
|
||||
own `packaging/windows/README.md` -- still true, packaging
|
||||
succeeding is not the same claim as the app working once
|
||||
installed).
|
||||
|
||||
Follow-up, now that a clean build exists to measure against:
|
||||
`light.exe` alone took ~15-18 minutes even with ICE80 fixed --
|
||||
revisit the ~1GB+ bulk-copied mingw64 dist tree (README's own
|
||||
"not lean" note) as a real perf issue, not just a packaging-
|
||||
correctness one.
|
||||
|
||||
## OCR: new fire-support-request grammar gaps (from real user-pasted messages)
|
||||
|
||||
- [x] A "taking fire" report's reporting unit ("Infantry#11 taking
|
||||
fire!...") was being added as a hostile Target, not a friendly
|
||||
Ally -- see the id-scheme entry above for the "no Friendly/Hostile
|
||||
prefix word exists in this grammar" root cause and the fix
|
||||
(`_TAKING_FIRE_RE`, `force_ally`, and splitting the shell/deadline
|
||||
into a synthetic StrikeRequest even for the no-offset "on our
|
||||
position" case, which previously kept them on the entity itself --
|
||||
fine when it was wrongly a Target, silently lost once correctly an
|
||||
Ally, since Ally tuples carry no shell/deadline fields at all). 4
|
||||
existing tests updated, all still passing plus the rest of the
|
||||
suite (51 total).
|
||||
|
||||
- [ ] A multi-shell sequential request ("Requesting TEAR Shell first,
|
||||
then HE Shell, at bearing...") only captures the FIRST shell
|
||||
(TEAR) into the structured `shell` field -- "then HE Shell" isn't
|
||||
parsed into anything. Less urgent than it first looked though: the
|
||||
full original message text (both shells, in order) is already
|
||||
preserved as-is and shown to the player via the coord dialog's
|
||||
description view (`Location.desc_raw`, set from the same `raw`
|
||||
every merged target/ally carries) -- nothing is silently LOST, it's
|
||||
just not machine-parsed into a queryable second-shell field. Real
|
||||
scope question before building that: does the board/firing-panel
|
||||
data model even have a place to put a second shell for one strike
|
||||
request today, or does this need a new field/shape entirely --
|
||||
worth confirming it's actually wanted (vs. "read the raw text
|
||||
yourself, it's right there") before spending the design effort.
|
||||
- [x] "Answer by 10:30:00" turned out to be one bug, not two. The
|
||||
phrasing itself was never the problem -- `_TAKING_FIRE_TIME_RE`
|
||||
already matches any `before|by <time>`, "Answer BY 10:30:00"
|
||||
included. The REAL bug: a same-message "Important: ..." follow-up
|
||||
line was misread as a brand new named entity header (the
|
||||
last-resort bare-`<name>:` fallback rule matched "Important:"
|
||||
itself with nothing excluding common prose lead-ins), creating a
|
||||
bogus `Target#Important` that stole "Answer by 10:30:00" into ITS
|
||||
own `requested_time` instead of the real report's. Fixed with a
|
||||
blocklist (`_BARE_NAME_HEADER_BLOCKLIST`: important/note/warning/
|
||||
attention/caution/alert/reminder/priority) on that fallback rule --
|
||||
once the phantom split stopped happening, the deadline resolved
|
||||
onto the right entry with no separate fix needed. New regression
|
||||
test, confirmed against the user's real pasted message (with an
|
||||
assumed `Infantry#N taking fire!` header line prepended, since
|
||||
their paste seems to have been cropped before it).
|
||||
|
||||
- [x] "\<Type\>#\<id\> Destroyed" kill-feed parsing already exists and
|
||||
already marks the matching Target dead (`parse_destroyed`,
|
||||
`_merge_targets`'s own destroyed-handling block in app.py) --
|
||||
confirmed working end-to-end against a real 9-entry kill-feed
|
||||
paste, including multi-word types ("Enemy Mechanized Infantry#2
|
||||
Destroyed" correctly resolved to INFANTRY_MECHANIZED). This was
|
||||
already-existing, working functionality, not something needing to
|
||||
be built.
|
||||
One real gap found in the same test: "Enemy Field Gun#1 Destroyed"
|
||||
silently dropped. Two bugs stacked, both fixed:
|
||||
- [x] "Field Gun" is just the game's own alt name for plain
|
||||
Artillery (confirmed by the user directly) -- not a missing
|
||||
unit type needing a new enum member/icon after all. Added to
|
||||
`_TYPE_WORD_ALIASES` next to AmmoCache/CoastalBattery.
|
||||
- [x] Even with that alias, it still didn't resolve: `_ALLY_PREFIX_RE`
|
||||
only ever stripped a leading "Friendly"/"Hostile" word, never
|
||||
"Enemy" -- so `squash_multiword_ids`'s "EnemyFieldGun#1"
|
||||
token got alias-looked-up and fuzzy-matched as a WHOLE
|
||||
("EnemyFieldGun" vs "Artillery", nowhere close), not just its
|
||||
"FieldGun" part. "Enemy Mechanized Infantry#2" only ever
|
||||
worked by fuzzy-match ACCIDENT (a long, distinctive type
|
||||
string still clears the ratio threshold with "Enemy" stuck
|
||||
to the front; a short, unrelated one like Artillery doesn't).
|
||||
`_ALLY_PREFIX_RE` now strips "Enemy" too, with a lookahead
|
||||
requiring something after it -- a BARE "Enemy#N" is
|
||||
`TargetType.ENEMY` itself (its own value IS "Enemy"),
|
||||
stripping unconditionally would've left an empty type_word
|
||||
and broken every ad-hoc "Enemy#N Destroyed" report instead.
|
||||
3 new regression tests, all passing (54 total).
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,96 @@
|
||||
# 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: has actually been run.** Two real environment issues hit and
|
||||
fixed so far (both already applied in this directory, see "Confirmed
|
||||
issues hit" below); Windows install itself was still in progress as of
|
||||
last check. `oem/install.bat` onward (MSYS2/GTK4/WiX provisioning, the
|
||||
actual .msi packaging) has NOT been reached/exercised yet — treat that
|
||||
part as still a debugging session, not a push-button success. Watch it
|
||||
happen at http://localhost:8006 (dockur's noVNC viewer) if it stalls.
|
||||
|
||||
## Confirmed issues hit (on Fedora + BTRFS)
|
||||
|
||||
- **SELinux blocks the bind mounts** ("Storage folder (/storage) is not
|
||||
writeable!") — fixed with `:Z` on every volume in `docker-compose.yml`.
|
||||
Harmless no-op on a host without SELinux.
|
||||
- **The repeated "loading/starting Boot0004" log lines are NOT a boot
|
||||
loop** — that was an earlier misdiagnosis here (blamed on BTRFS+QEMU,
|
||||
"fixed" with `chattr +C` on `storage/`). Checked the actual noVNC
|
||||
screen instead of just the text log and it was genuine, progressing
|
||||
Windows Setup the whole time ("This might take a few minutes" →
|
||||
"Please keep your PC on and plugged in" → desktop) — Setup legitimately
|
||||
reboots the VM multiple times, each one re-prints those same firmware
|
||||
log lines. The `chattr +C` disables copy-on-write for the VM's disk
|
||||
image regardless (a real, independently-documented dockur/QEMU/BTRFS
|
||||
caveat), so it's staying, but it likely wasn't fixing an actual
|
||||
problem this time. **Lesson: check the screen, not just the log,
|
||||
before concluding something's stuck.**
|
||||
- **The build watcher registered as a SYSTEM-context Scheduled Task
|
||||
never fires in practice**: `Z:\` (the `/shared` mount) is mapped per
|
||||
interactive session, invisible to a task with no session of its own,
|
||||
so it spun forever on `watch_build.bat`'s own `if not exist Z:\`
|
||||
wait — confirmed by a build request sitting unclaimed for hours.
|
||||
Fixed in `install.bat`: an All-Users Startup-folder entry instead,
|
||||
which runs in whichever user's session actually logs in.
|
||||
|
||||
## How it fits together
|
||||
|
||||
- `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.
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/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"
|
||||
# dockur/windows itself warns about this ("you are using the BTRFS
|
||||
# filesystem for /storage, this might introduce issues with Windows
|
||||
# Setup!") and it's not idle: confirmed on this host as a genuine
|
||||
# multi-hour Windows Setup boot-loop (repeating the same boot-manager
|
||||
# log lines forever, disk barely growing) -- QEMU disk images on a
|
||||
# copy-on-write filesystem are a known bad combination. +C only takes
|
||||
# effect for files created AFTER it's set on an empty directory, so
|
||||
# this only helps on a fresh/emptied storage/; it's a no-op (harmless,
|
||||
# chattr just errors quietly) on a non-btrfs filesystem or an
|
||||
# already-populated storage/ from a previous run.
|
||||
chattr +C storage 2>/dev/null || true
|
||||
|
||||
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")"
|
||||
@@ -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
|
||||
@@ -0,0 +1,100 @@
|
||||
@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%
|
||||
REM -platform x64 alone is NOT enough (confirmed live: still every
|
||||
REM component ICE80'd afterward) -- WiX v3 heat.exe's -platform flag
|
||||
REM doesn't actually stamp Win64="yes" on harvested components itself,
|
||||
REM it only affects registry-key harvesting. Kept anyway (harmless,
|
||||
REM correct in spirit), but the real fix is candle.exe's -arch x64
|
||||
REM below, see its own comment.
|
||||
C:\wix\heat.exe dir C:\build\dist -platform x64 -cg AppFiles -gg -scom -sreg -sfrag -srd -sw5150 -dr INSTALLFOLDER -var var.DistDir -out C:\build\files.wxs >> %LOG% 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [build.bat] FAILED: heat.exe harvest failed >> %LOG%
|
||||
copy %LOG% Z:\build.log.failed >nul
|
||||
echo FAILED > Z:\BUILD_FAILED
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
copy /y C:\FenigmaBuild\product.wxs C:\build\product.wxs >nul
|
||||
|
||||
echo [build.bat] compiling (candle) ... >> %LOG%
|
||||
REM -arch x64: the actual fix for the ICE80 "32BitComponent uses
|
||||
REM 64BitDirectory" failure (confirmed live -- heat.exe's own -platform
|
||||
REM x64 above does NOT set this, only affects registry harvesting).
|
||||
REM -arch sets the default Win64/Platform for every component compiled
|
||||
REM from EITHER source file, hand-authored (product.wxs) or harvested
|
||||
REM (files.wxs) alike, without needing per-component authoring -- the
|
||||
REM standard WiX v3 way to make a whole package consistently 64-bit,
|
||||
REM matching product.wxs's own ProgramFiles64Folder.
|
||||
C:\wix\candle.exe -arch x64 -dDistDir=C:\build\dist -dAppVersion=%APPVER% -out C:\build\ C:\build\product.wxs C:\build\files.wxs >> %LOG% 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [build.bat] FAILED: candle.exe failed >> %LOG%
|
||||
copy %LOG% Z:\build.log.failed >nul
|
||||
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
|
||||
@@ -0,0 +1,118 @@
|
||||
@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 -- Stage the OTHER oem/ files somewhere that outlives C:\OEM itself,
|
||||
REM done first, before anything else. Confirmed on a real run: C:\OEM
|
||||
REM (dockur's /oem copy target) does NOT reliably persist once Windows
|
||||
REM Setup finishes and you're at the desktop -- it's fundamentally a
|
||||
REM Windows Setup-time staging mechanism ($OEM$ folders, copied by WinPE
|
||||
REM "right after the Windows image is applied ... and before the first
|
||||
REM reboot" per Microsoft's own docs), not guaranteed permanent storage,
|
||||
REM and in practice `dir C:\OEM` came back "File Not Found" once actually
|
||||
REM checked from an interactive desktop session. build.bat/product.wxs/
|
||||
REM watch_build.bat all get referenced again AFTER install.bat's own
|
||||
REM process has exited (by the Startup-folder entry below, potentially
|
||||
REM much later), so they need a home install.bat itself controls and
|
||||
REM knows persists -- a plain folder on C:, not the OEM staging area.
|
||||
mkdir C:\FenigmaBuild 2>nul
|
||||
copy /y C:\OEM\build.bat C:\FenigmaBuild\build.bat >> C:\OEM\install.log 2>&1
|
||||
copy /y C:\OEM\product.wxs C:\FenigmaBuild\product.wxs >> C:\OEM\install.log 2>&1
|
||||
copy /y C:\OEM\watch_build.bat C:\FenigmaBuild\watch_build.bat >> C:\OEM\install.log 2>&1
|
||||
|
||||
REM -- MSYS2: the "base" self-extracting archive, not the GUI installer --
|
||||
REM (the GUI installer has no reliable non-interactive/silent flag across
|
||||
REM versions; the base sfx archive is what CI pipelines actually use).
|
||||
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 ..."
|
||||
REM mingw-w64-x86_64-opencv is the C++ library ONLY -- confirmed live on a
|
||||
REM real VM that `import cv2` fails without it, the actual Python bindings
|
||||
REM are the separate mingw-w64-x86_64-python-opencv package. Don't try to
|
||||
REM paper over a missing one with `pip install opencv-python-headless`
|
||||
REM either: MSYS2's mingw64 Python uses a different ABI than PyPI's Windows
|
||||
REM wheels (cp3XX-mingw_x86_64_msvcrt_gnu vs win_amd64), so pip can never
|
||||
REM use a prebuilt wheel there, only build from source, which then needs a
|
||||
REM full separate native toolchain (ninja/cmake/gcc) this VM doesn't have.
|
||||
C:\msys64\usr\bin\bash.exe -lc "pacman -S --noconfirm --needed mingw-w64-x86_64-python mingw-w64-x86_64-python-pip mingw-w64-x86_64-python-gobject mingw-w64-x86_64-gtk4 mingw-w64-x86_64-libadwaita mingw-w64-x86_64-python-numpy mingw-w64-x86_64-python-pillow mingw-w64-x86_64-opencv mingw-w64-x86_64-python-opencv mingw-w64-x86_64-tesseract-ocr" >> C:\OEM\install.log 2>&1
|
||||
|
||||
call :log "pip install pytesseract (pure python, no wheel needed) ..."
|
||||
REM --break-system-packages: MSYS2's mingw64 Python enforces PEP 668
|
||||
REM ("externally-managed-environment"), confirmed live -- a plain
|
||||
REM `pip install` here fails outright without this flag. Safe here: this
|
||||
REM VM's whole mingw64 Python install exists only to run FEnigma, there's
|
||||
REM no system package manager relying on it staying untouched.
|
||||
C:\msys64\mingw64\bin\python3.exe -m pip install --break-system-packages pytesseract >> C:\OEM\install.log 2>&1
|
||||
|
||||
REM -- WiX v3 toolset (candle/light/heat), a plain zip of standalone exes,
|
||||
REM no installer needed. Fixed versioned URL, no scraping required.
|
||||
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 login from here on,
|
||||
REM plus kick it off right now too (a fresh login won't retroactively
|
||||
REM fire for this already-logged-in session). Deliberately an All-Users
|
||||
REM Startup-folder entry, NOT a SYSTEM-context Scheduled Task: confirmed
|
||||
REM on a real run that a /ru SYSTEM task can't see Z:\ at all and spins
|
||||
REM forever on watch_build.bat's own "if not exist Z:\" wait -- Z:\ (the
|
||||
REM /shared mount) is mapped per INTERACTIVE session, invisible to a
|
||||
REM SYSTEM task with no session of its own. Startup-folder entries run
|
||||
REM in whichever user's session actually logs in, inheriting their
|
||||
REM drive mappings correctly.
|
||||
call :log "registering build watcher (Startup folder) ..."
|
||||
copy /y C:\FenigmaBuild\watch_build.bat "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp\FenigmaBuildWatcher.bat" >> C:\OEM\install.log 2>&1
|
||||
start "" cmd /c C:\FenigmaBuild\watch_build.bat
|
||||
|
||||
call :log "provisioning done"
|
||||
echo DONE > C:\OEM\provisioned.marker
|
||||
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
|
||||
@@ -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>
|
||||
@@ -0,0 +1,34 @@
|
||||
@echo off
|
||||
REM Runs persistently from login (see install.bat's Startup-folder entry --
|
||||
REM this file itself gets copied to C:\FenigmaBuild\ and to the Startup
|
||||
REM folder by install.bat, not run from C:\OEM, which does not reliably
|
||||
REM survive past Windows Setup finishing, see install.bat's own note).
|
||||
REM Polls the host-shared Z:\ drive for a build request and, when one
|
||||
REM shows up, runs build.bat against it. This is what lets build_windows.sh
|
||||
REM on the Linux host trigger a build without any RDP/remote-exec: it's
|
||||
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, a fresh login could also start another copy), only
|
||||
REM one of them wins this move and actually builds.
|
||||
move /y Z:\BUILD_REQUEST Z:\BUILD_REQUEST.claimed >nul 2>&1
|
||||
if exist Z:\BUILD_REQUEST.claimed (
|
||||
del Z:\BUILD_REQUEST.claimed
|
||||
del /q Z:\BUILD_DONE 2>nul
|
||||
del /q Z:\BUILD_FAILED 2>nul
|
||||
call C:\FenigmaBuild\build.bat
|
||||
)
|
||||
)
|
||||
timeout /t 5 /nobreak >nul
|
||||
goto loop
|
||||
|
Before Width: | Height: | Size: 577 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 356 KiB |
@@ -14,6 +14,8 @@ from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import pickle
|
||||
import signal
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
@@ -26,7 +28,7 @@ gi.require_version("Gdk", "4.0")
|
||||
from gi.repository import Adw, Gdk, Gio, GLib, Gtk # noqa: E402
|
||||
from PIL import Image # noqa: E402
|
||||
|
||||
from . import ballistics, icons, map_import, ocr, solver # noqa: E402
|
||||
from . import ballistics, debug_capture, icons, map_import, ocr, solver # noqa: E402
|
||||
from .coord_dialog import CoordDialog # noqa: E402
|
||||
from .firing_panel import FiringPanel # noqa: E402
|
||||
from .grid_fix_dialog import GridFixDialog # noqa: E402
|
||||
@@ -64,6 +66,17 @@ def _idle(fn, *args):
|
||||
GLib.idle_add(lambda: (fn(*args), False)[1])
|
||||
|
||||
|
||||
def _display_name(obj) -> str:
|
||||
"""obj.name, but with TargetType.ENEMY's raw "Enemy" value swapped for
|
||||
"Ally" when obj is an Ally (see icons.target_type_label) -- the
|
||||
underlying id (obj.name, used for save files and clue references)
|
||||
keeps "Enemy" either way, only this display form differs."""
|
||||
if not hasattr(obj, "type"):
|
||||
return obj.name
|
||||
label = icons.target_type_label(obj.type, isinstance(obj, Ally)).replace(" ", "")
|
||||
return f"{label}#{obj.id}"
|
||||
|
||||
|
||||
def _coord_from_proposal(p) -> Coord | None:
|
||||
"""map_vision reports "K8" plus sub-cell 0..9 in each axis, matching
|
||||
Coord's own convention (see GridSolution.lattice_to_grid)."""
|
||||
@@ -261,6 +274,15 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
self._import_job = None # in-flight map_import.ImportJob, if any
|
||||
self.screenshot_import = None # the map screenshot currently on the board
|
||||
|
||||
# Dev-only: SIGUSR1 pickles {board, screenshot_import} to a fixed
|
||||
# path so a `kill -USR1` + relaunch (e.g. while bisecting a live
|
||||
# bug) can restore the in-progress board/screenshot/proposals
|
||||
# instead of losing them. One-shot: the restore consumes and
|
||||
# deletes the file. Not wired to any UI -- debugging aid only.
|
||||
GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGUSR1,
|
||||
self._dev_dump_session_for_restart)
|
||||
self._dev_maybe_restore_session()
|
||||
|
||||
self.toast_overlay = Adw.ToastOverlay()
|
||||
self.set_content(self.toast_overlay)
|
||||
|
||||
@@ -282,8 +304,13 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
header.pack_start(load_btn)
|
||||
|
||||
clear_btn = Gtk.Button(icon_name="edit-clear-all-symbolic")
|
||||
clear_btn.set_tooltip_text("Clear board (drop everything)")
|
||||
clear_btn.set_tooltip_text(
|
||||
"Clear board (drop everything). Right-click for a lighter option.")
|
||||
clear_btn.connect("clicked", lambda _b: self._clear_board())
|
||||
clear_right_click = Gtk.GestureClick(button=Gdk.BUTTON_SECONDARY)
|
||||
clear_right_click.connect(
|
||||
"released", lambda _g, _n, x, y: self._open_clear_menu(clear_btn, x, y))
|
||||
clear_btn.add_controller(clear_right_click)
|
||||
header.pack_start(clear_btn)
|
||||
|
||||
self._clip_btn = Gtk.Button(icon_name="edit-paste-symbolic")
|
||||
@@ -367,6 +394,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,
|
||||
@@ -529,7 +557,17 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
except Exception as exc: # OCR/parsing hiccups shouldn't crash the app
|
||||
self.toast(f"OCR failed: {exc}")
|
||||
return
|
||||
on_parsed(info)
|
||||
result = on_parsed(info)
|
||||
# Empty result specifically from a screenshot IMAGE (not a plain-
|
||||
# text paste) is suspicious: this screenshot fell through to the
|
||||
# OCR/text path -- either the map-vision gate misrouted it, or
|
||||
# map_vision itself rejected it -- and came back with nothing at
|
||||
# all. It might genuinely have been a map, worth keeping to check
|
||||
# against later. A plain-text paste that finds nothing is normal
|
||||
# and never reaches this function at all (see
|
||||
# _on_clipboard_text_ready), so no separate guard needed here.
|
||||
if isinstance(result, list) and not result:
|
||||
debug_capture.save_maybe_map(png)
|
||||
|
||||
def _start_map_import(self, png: bytes, not_a_map) -> None:
|
||||
"""Try to read the clipboard image as a map screenshot, off-thread.
|
||||
@@ -561,6 +599,7 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
# one and the user wants to know why it didn't take.
|
||||
if error != map_import.NOT_A_MAP:
|
||||
self.toast(f"Couldn't read the grid ({error}), trying as text.")
|
||||
debug_capture.save_map_read_failure(png, error)
|
||||
not_a_map()
|
||||
return
|
||||
self._on_map_import_ready(result)
|
||||
@@ -594,8 +633,31 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
).present(self)
|
||||
|
||||
def _accept_grid(self, imp, solution) -> None:
|
||||
"""Grid confirmed: rectify the screenshot onto the board, then detect."""
|
||||
"""Grid confirmed: rectify the screenshot onto the board, then detect.
|
||||
|
||||
If the confirmed grid isn't the one the solver proposed (the user
|
||||
dragged a corner in GridFixDialog), keep both the screenshot and
|
||||
both solutions as ground truth, useful later for improving the
|
||||
grid solver against exactly the case it got wrong."""
|
||||
if solution is not imp.solution:
|
||||
# full_image over image: sharper source for a human reviewing
|
||||
# the capture later, same reasoning as save_marker_ground_truth's.
|
||||
debug_capture.save_grid_correction(
|
||||
imp.full_image if imp.full_image is not None else imp.image,
|
||||
imp.solution, solution)
|
||||
imp.solution = solution
|
||||
# A screenshot already on the board (never explicitly dropped, the
|
||||
# user just pasted a new one straight over it) still deserves its
|
||||
# ground truth captured before it's replaced -- same as an
|
||||
# explicit drop, see _capture_screenshot_ground_truth.
|
||||
if self.screenshot_import is not None:
|
||||
self._capture_screenshot_ground_truth(self.screenshot_import)
|
||||
# Snapshot of what's on the board BEFORE this screenshot's own
|
||||
# units get added, so _capture_screenshot_ground_truth can later
|
||||
# tell "added because of this screenshot" apart from "was already
|
||||
# there" -- see ScreenshotImport.baseline_targets/baseline_allies.
|
||||
imp.baseline_targets = set(self.board.targets)
|
||||
imp.baseline_allies = set(self.board.allies)
|
||||
self.screenshot_import = imp
|
||||
imp.build_overlay()
|
||||
self.canvas.set_screenshot(imp.overlay, imp.px_per_km)
|
||||
@@ -638,17 +700,76 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
self._drop_shot_btn.set_visible(imp is not None)
|
||||
self._accept_all_btn.set_sensitive(bool(imp is not None and imp.pending()))
|
||||
|
||||
_DEV_SESSION_PATH = Path(tempfile.gettempdir()) / "fenigma_dev_session.pkl"
|
||||
|
||||
def _dev_dump_session_for_restart(self, *_a) -> bool:
|
||||
"""SIGUSR1 handler: pickle {board, screenshot_import} so a
|
||||
following relaunch can pick this session right back up. See the
|
||||
SIGUSR1 registration in __init__ for why this exists."""
|
||||
try:
|
||||
with open(self._DEV_SESSION_PATH, "wb") as f:
|
||||
pickle.dump({"board": self.board, "screenshot_import": self.screenshot_import}, f)
|
||||
print(f"fenigma: dev session dumped to {self._DEV_SESSION_PATH}", flush=True)
|
||||
except Exception as exc:
|
||||
print(f"fenigma: dev session dump failed: {exc!r}", flush=True)
|
||||
return GLib.SOURCE_CONTINUE
|
||||
|
||||
def _dev_maybe_restore_session(self) -> None:
|
||||
"""Counterpart to `_dev_dump_session_for_restart`: one-shot restore
|
||||
on startup if a dump is sitting there. Sets `self.board`/
|
||||
`self.screenshot_import` directly (before the rest of __init__
|
||||
builds the widgets that reference them) but defers the actual
|
||||
redraw to an idle callback, since `self.canvas` doesn't exist yet
|
||||
at this point in __init__."""
|
||||
if not self._DEV_SESSION_PATH.exists():
|
||||
return
|
||||
try:
|
||||
with open(self._DEV_SESSION_PATH, "rb") as f:
|
||||
data = pickle.load(f)
|
||||
self.board = data["board"]
|
||||
self.screenshot_import = data["screenshot_import"]
|
||||
self._DEV_SESSION_PATH.unlink()
|
||||
print("fenigma: dev session restored", flush=True)
|
||||
except Exception as exc:
|
||||
print(f"fenigma: dev session restore failed: {exc!r}", flush=True)
|
||||
return
|
||||
GLib.idle_add(self._dev_finish_session_restore)
|
||||
|
||||
def _dev_finish_session_restore(self) -> bool:
|
||||
self._refresh()
|
||||
self._refresh_proposals()
|
||||
if self.screenshot_import is not None:
|
||||
self.canvas.set_screenshot(self.screenshot_import.overlay, self.screenshot_import.px_per_km)
|
||||
return GLib.SOURCE_REMOVE
|
||||
|
||||
def _accept_proposal(self, proposal, type_=None) -> None:
|
||||
coord = _coord_from_proposal(proposal)
|
||||
if coord is None:
|
||||
return
|
||||
if type_ is None:
|
||||
type_ = icons.target_type_from_icon(proposal.unit) or TargetType.UNKNOWN
|
||||
if proposal.side == "friendly":
|
||||
self.board.add_ally(type_, coord)
|
||||
is_ally = proposal.side == "friendly"
|
||||
# Prefer the marker's own detected "#<N>" id (map_vision.read_marker_id,
|
||||
# already past its own confidence floor by the time it's non-None)
|
||||
# over an auto-assigned letter -- lets an accepted unit's id match
|
||||
# what's actually on screen, rather than every accept silently
|
||||
# becoming A/B/C regardless of what the game itself calls it.
|
||||
# Falls back to auto-assign (id_=None) on a collision: two markers
|
||||
# misread to the same id, or a real id that just happens to match
|
||||
# one already auto-assigned, either way a duplicate id is worse
|
||||
# than losing this one accept's traceability to the game's own
|
||||
# number.
|
||||
id_ = proposal.detected_id
|
||||
group = self.board.allies if is_ally else self.board.targets
|
||||
existing = {o.id for o in group if o.type == type_} # per-type, same as Board.add_target/add_ally
|
||||
if id_ in existing:
|
||||
id_ = None
|
||||
if is_ally:
|
||||
self.board.add_ally(type_, coord, id_=id_)
|
||||
else:
|
||||
self.board.add_target(type_, coord)
|
||||
self.board.add_target(type_, coord, id_=id_)
|
||||
proposal.accepted = True
|
||||
proposal.confirmed_type = type_.name
|
||||
|
||||
def _accept_all_proposals(self) -> None:
|
||||
imp = self.screenshot_import
|
||||
@@ -661,6 +782,26 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
self._refresh_proposals()
|
||||
self.toast(f"Accepted {len(pending)} unit(s).")
|
||||
|
||||
def _capture_screenshot_ground_truth(self, imp) -> None:
|
||||
"""Whatever the user actually confirmed while `imp` was the active
|
||||
screenshot -- accepted/rejected proposals, plus anything added to
|
||||
the board that wasn't from a proposal at all (a manual add, or an
|
||||
OCR-text merge run alongside it, see
|
||||
ScreenshotImport.baseline_targets/baseline_allies) -- is exactly
|
||||
the ground truth marker detection needs to improve against.
|
||||
Called right before `imp` stops being the active screenshot,
|
||||
whether that's an explicit drop (_remove_screenshot) or a new
|
||||
screenshot pasted straight over it (_accept_grid), the last
|
||||
moment it can still be tied to this specific image."""
|
||||
added_targets = [t for t in self.board.targets if t not in imp.baseline_targets]
|
||||
added_allies = [a for a in self.board.allies if a not in imp.baseline_allies]
|
||||
# full_image over image: a human checking a detected_id against
|
||||
# this capture later needs to actually read that tiny text, see
|
||||
# save_marker_ground_truth's own docstring.
|
||||
debug_capture.save_marker_ground_truth(
|
||||
imp.full_image if imp.full_image is not None else imp.image,
|
||||
imp.proposals, added_targets, added_allies)
|
||||
|
||||
def _remove_screenshot(self) -> None:
|
||||
"""Dropping the screenshot also drops every proposal never accepted:
|
||||
they were only ever readings OF that screenshot, so without it there is
|
||||
@@ -668,6 +809,7 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
imp = self.screenshot_import
|
||||
if imp is None:
|
||||
return
|
||||
self._capture_screenshot_ground_truth(imp)
|
||||
dropped = len(imp.pending())
|
||||
imp.drop_unaccepted()
|
||||
self.screenshot_import = None
|
||||
@@ -709,9 +851,17 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
box = page()
|
||||
lbl = Gtk.Label(xalign=0, margin_start=4, margin_bottom=2)
|
||||
side = "friendly" if proposal.side == "friendly" else "hostile"
|
||||
# detected_id (map_vision.read_marker_id's best-effort read of
|
||||
# the marker's own "#<N>" label, see its own docstring) is
|
||||
# shown here so it's visible right when there's still a
|
||||
# screenshot to actually check it against -- accept already
|
||||
# uses it for the entity's id when present (see
|
||||
# _accept_proposal), this is just making that fact visible
|
||||
# before the click, not a separate signal.
|
||||
id_part = f", id #{proposal.detected_id}" if proposal.detected_id else ""
|
||||
lbl.set_markup(
|
||||
f"<b>{GLib.markup_escape_text(proposal.coord)}</b> — {side}, "
|
||||
f"{detected.value if detected else 'type unknown'}")
|
||||
f"{detected.value if detected else 'type unknown'}{GLib.markup_escape_text(id_part)}")
|
||||
box.append(lbl)
|
||||
box.append(Gtk.Separator(margin_top=2, margin_bottom=2))
|
||||
button(box, f"Accept as {detected.value if detected else TargetType.UNKNOWN.value}",
|
||||
@@ -722,15 +872,37 @@ 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".
|
||||
grid = icons.build_target_type_grid(
|
||||
detected, lambda t: accept(t), is_ally=(proposal.side == "friendly"),
|
||||
)
|
||||
scroller = Gtk.ScrolledWindow(propagate_natural_height=True,
|
||||
propagate_natural_width=True,
|
||||
max_content_height=340,
|
||||
hscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||
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(grid)
|
||||
box.append(scroller)
|
||||
# Swapping the child of an ALREADY-open Popover and re-popup()ing
|
||||
# it reports the right size internally (visible=True, sane
|
||||
# width/height) but the compositor never actually repaints the
|
||||
# reused surface -- confirmed live: nothing appears on screen no
|
||||
# matter how many times it's reopened. Popping the OLD popover
|
||||
# down and opening a genuinely NEW one (fresh native surface,
|
||||
# same anchor point) instead of resizing the existing one
|
||||
# sidesteps that.
|
||||
nonlocal popover
|
||||
old_popover = popover
|
||||
popover = self._popover_at(x, y)
|
||||
popover.set_child(box)
|
||||
old_popover.popdown()
|
||||
popover.popup()
|
||||
|
||||
show_main()
|
||||
popover.popup()
|
||||
@@ -764,16 +936,80 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
if self._clipboard_watch_handler is not None:
|
||||
Gdk.Display.get_default().get_clipboard().disconnect(self._clipboard_watch_handler)
|
||||
self._clipboard_watch_handler = None
|
||||
# Closing with a screenshot still up is otherwise-silent data loss
|
||||
# for debug_capture: same ground-truth capture as an explicit drop
|
||||
# or pasting a new screenshot over it, see
|
||||
# _capture_screenshot_ground_truth.
|
||||
if self.screenshot_import is not None:
|
||||
self._capture_screenshot_ground_truth(self.screenshot_import)
|
||||
|
||||
def _open_clear_menu(self, clear_btn: Gtk.Button, x: float, y: float) -> None:
|
||||
"""Right-click on the Clear button: a lighter option than the full
|
||||
Clear (left-click), for wiping the round's contacts without losing
|
||||
the Nest/spotters/reference points set up for it."""
|
||||
popover = Gtk.Popover()
|
||||
popover.set_parent(clear_btn)
|
||||
# See _popover_at's comment: Gdk.Rectangle's constructor silently
|
||||
# ignores keyword args on this PyGObject version, field assignment
|
||||
# after construction is the only way that actually works.
|
||||
rect = Gdk.Rectangle()
|
||||
rect.x, rect.y, rect.width, rect.height = int(x), int(y), 1, 1
|
||||
popover.set_pointing_to(rect)
|
||||
popover.connect("closed", lambda _p: popover.unparent())
|
||||
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
|
||||
margin_top=6, margin_bottom=6, margin_start=6, margin_end=6)
|
||||
btn = Gtk.Button(label="Clear enemies, units & flights", css_classes=["destructive-action"])
|
||||
btn.set_tooltip_text("Drops targets, allies, and scout flights. "
|
||||
"Keeps the Nest, spotters, and reference points.")
|
||||
if btn.get_child() is not None:
|
||||
btn.get_child().set_xalign(0.0)
|
||||
|
||||
def go():
|
||||
popover.popdown()
|
||||
self._clear_units()
|
||||
|
||||
btn.connect("clicked", lambda _b: go())
|
||||
box.append(btn)
|
||||
popover.set_child(box)
|
||||
popover.popup()
|
||||
|
||||
def _clear_units(self) -> None:
|
||||
board = self.board
|
||||
if not board.targets and not board.allies and not board.scout_flights:
|
||||
self.toast("Nothing to clear.")
|
||||
return
|
||||
dialog = Adw.AlertDialog(
|
||||
heading="Clear enemies, units & flights?",
|
||||
body="Drops every target, ally, and scout flight. The Nest, spotters, and reference "
|
||||
"points are kept. This can't be undone.",
|
||||
)
|
||||
dialog.add_response("cancel", "Cancel")
|
||||
dialog.add_response("clear", "Clear")
|
||||
dialog.set_response_appearance("clear", Adw.ResponseAppearance.DESTRUCTIVE)
|
||||
dialog.set_default_response("cancel")
|
||||
dialog.set_close_response("cancel")
|
||||
|
||||
def on_response(_dialog, response):
|
||||
if response != "clear":
|
||||
return
|
||||
board.clear_units()
|
||||
self._set_selection(None)
|
||||
self._refresh()
|
||||
self.toast("Enemies, units & flights cleared.")
|
||||
|
||||
dialog.connect("response", on_response)
|
||||
dialog.present(self)
|
||||
|
||||
def _clear_board(self) -> None:
|
||||
board = self.board
|
||||
if (board.nest.coord is None and not board.spotters and not board.reference_points
|
||||
and not board.targets and not board.scout_flights):
|
||||
and not board.targets and not board.allies and not board.scout_flights):
|
||||
return # nothing to clear
|
||||
dialog = Adw.AlertDialog(
|
||||
heading="Clear board?",
|
||||
body="Drops the Nest position and every spotter, reference point, target, and scout flight. "
|
||||
"This can't be undone.",
|
||||
body="Drops the Nest position and every spotter, reference point, target, ally, and scout "
|
||||
"flight. This can't be undone.",
|
||||
)
|
||||
dialog.add_response("cancel", "Cancel")
|
||||
dialog.add_response("clear", "Clear")
|
||||
@@ -791,7 +1027,7 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
self._refresh()
|
||||
self.toast("Board cleared.")
|
||||
|
||||
def _merge_all(self, info: "ocr.ParsedInfo") -> None:
|
||||
def _merge_all(self, info: "ocr.ParsedInfo") -> list[str]:
|
||||
changed = []
|
||||
if info.nest_coord is not None:
|
||||
self.board.nest.coord = info.nest_coord
|
||||
@@ -806,6 +1042,7 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
else:
|
||||
self.toast("Merged from screenshot: " + ", ".join(changed))
|
||||
self._refresh()
|
||||
return changed
|
||||
|
||||
def _merge_spotters(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]:
|
||||
changed = []
|
||||
@@ -917,14 +1154,15 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
return changed
|
||||
|
||||
def _open_coord_dialog(
|
||||
self, *, title, on_submit, show_id=False, show_type=False, id_placeholder=None,
|
||||
initial_location=None, initial_id=None, initial_type=None,
|
||||
self, *, title, on_submit, show_id=False, show_type=False, is_ally=False,
|
||||
id_placeholder=None, initial_location=None, initial_id=None, initial_type=None,
|
||||
):
|
||||
dialog = CoordDialog(
|
||||
title=title,
|
||||
on_submit=on_submit,
|
||||
show_id=show_id,
|
||||
show_type=show_type,
|
||||
is_ally=is_ally,
|
||||
id_placeholder=id_placeholder,
|
||||
initial_location=initial_location,
|
||||
initial_id=initial_id,
|
||||
@@ -1117,6 +1355,7 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
on_submit=lambda loc, id_, type_: self._add_target(loc, id_, type_),
|
||||
show_id=True,
|
||||
show_type=True,
|
||||
is_ally=False,
|
||||
), close))
|
||||
return box
|
||||
|
||||
@@ -1186,6 +1425,7 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
on_submit=lambda loc, id_, type_: self._add_ally(loc, id_, type_),
|
||||
show_id=True,
|
||||
show_type=True,
|
||||
is_ally=True,
|
||||
), close))
|
||||
return box
|
||||
|
||||
@@ -1288,6 +1528,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()
|
||||
|
||||
@@ -1366,36 +1607,90 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
def show_main():
|
||||
box = page()
|
||||
where = obj.coord.label() if getattr(obj, "coord", None) else "unplaced"
|
||||
heading(box, f"{obj.name} — {where}")
|
||||
heading(box, f"{_display_name(obj)} — {where}")
|
||||
box.append(Gtk.Separator(margin_top=2, margin_bottom=2))
|
||||
if hasattr(obj, "type"):
|
||||
button(box, f"Change type ({obj.type.value})", show_type)
|
||||
button(box, f"Change type ({icons.target_type_label(obj.type, isinstance(obj, Ally))})",
|
||||
show_type)
|
||||
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)
|
||||
# Underground is Target-only too, see Target.underground_tier's
|
||||
# own comment -- no such thing as an underground Ally.
|
||||
ug_label = ("Mark underground" if obj.underground_tier is None
|
||||
else f"Underground (tier {obj.underground_tier})")
|
||||
button(box, ug_label, show_underground)
|
||||
if not isinstance(obj, Nest):
|
||||
button(box, "Delete", delete, css="destructive-action")
|
||||
popover.set_child(box)
|
||||
|
||||
def _reopen_with(box) -> None:
|
||||
"""Swapping an ALREADY-open Popover's child via set_child()
|
||||
alone reports the right size (visible=True, sane width/height)
|
||||
but the compositor never actually repaints the reused surface
|
||||
on some setups -- confirmed live, nothing appears on screen no
|
||||
matter how many times it's reopened. Popping the OLD popover
|
||||
down and opening a genuinely NEW one at the same anchor point
|
||||
(fresh native surface, not an in-place resize) sidesteps it.
|
||||
Shared by every page past show_main() in this menu."""
|
||||
nonlocal popover
|
||||
old_popover = popover
|
||||
popover = self._popover_at(x, y)
|
||||
popover.set_child(box)
|
||||
old_popover.popdown()
|
||||
popover.popup()
|
||||
|
||||
def show_type():
|
||||
box = page()
|
||||
heading(box, "Type")
|
||||
# propagate_natural_WIDTH too, not just height: without it a
|
||||
# ScrolledWindow doesn't grow to fit a wide child (the 5-column
|
||||
# icon grid), which squeezes the popover down to a
|
||||
# near-unusable sliver -- same bug the icon grid's own
|
||||
# docstring and _open_quick_add_menu's identical scroller ran
|
||||
# into first.
|
||||
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:
|
||||
label = f"• {t.value}" if t is obj.type else f" {t.value}"
|
||||
button(inner, label, lambda t=t: set_type(t))
|
||||
scroller.set_child(inner)
|
||||
scroller.set_child(icons.build_target_type_grid(
|
||||
obj.type, lambda t: set_type(t), is_ally=isinstance(obj, Ally),
|
||||
))
|
||||
box.append(scroller)
|
||||
popover.set_child(box)
|
||||
_reopen_with(box)
|
||||
|
||||
def set_type(t):
|
||||
obj.type = t
|
||||
self._refresh()
|
||||
popover.popdown()
|
||||
self.toast(f"{obj.name} is now a {t.value}.")
|
||||
self.toast(f"{_display_name(obj)} is now a "
|
||||
f"{icons.target_type_label(t, isinstance(obj, Ally))}.")
|
||||
|
||||
def show_underground():
|
||||
box = page()
|
||||
heading(box, "Underground")
|
||||
scroller = Gtk.ScrolledWindow(propagate_natural_height=True,
|
||||
propagate_natural_width=True,
|
||||
max_content_height=340,
|
||||
hscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||
scroller.set_child(icons.build_underground_tier_grid(
|
||||
obj.underground_tier, lambda tier: set_underground(tier),
|
||||
))
|
||||
box.append(scroller)
|
||||
_reopen_with(box)
|
||||
|
||||
def set_underground(tier):
|
||||
obj.underground_tier = tier
|
||||
self._refresh()
|
||||
popover.popdown()
|
||||
self.toast(f"{_display_name(obj)} is no longer underground." if tier is None
|
||||
else f"{_display_name(obj)} is now underground (tier {tier}).")
|
||||
|
||||
def show_id():
|
||||
box = page()
|
||||
@@ -1425,6 +1720,21 @@ 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): id namespace is targets-vs-allies,
|
||||
# AND scoped per type within that -- a Tank#1 and an
|
||||
# Infantry#1 are not a collision, only two entities of the
|
||||
# SAME type sharing an id are. A manual rename has to keep
|
||||
# that too, or you get two entities that both read as
|
||||
# e.g. "Infantry#1" with nothing telling them apart.
|
||||
value = text
|
||||
group = self.board.targets if isinstance(obj, Target) else self.board.allies
|
||||
siblings = [o for o in group if o.type == obj.type]
|
||||
if any(o is not obj and o.id == value for o in siblings):
|
||||
kind = "target" if isinstance(obj, Target) else "ally"
|
||||
self.toast(f"Another {obj.type.short} {kind} already has id {value!r}.")
|
||||
return
|
||||
else:
|
||||
value = text
|
||||
old = obj.name
|
||||
@@ -1442,6 +1752,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
|
||||
@@ -1477,37 +1798,109 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
field = self._id_field_of(obj)
|
||||
return getattr(obj, field) if field else ""
|
||||
|
||||
def _open_type_dialog(self, title: str, is_ally: bool, on_pick) -> None:
|
||||
"""A real top-level modal (Adw.Dialog, same shape as CoordDialog)
|
||||
showing the TargetType icon grid, for contexts that need "pick a
|
||||
type" on its own with no coordinate/id fields alongside it.
|
||||
|
||||
Deliberately NOT a Gtk.Popover: a transient popover anchored to a
|
||||
screen point re-derives its own size/position from its content
|
||||
every time that content is swapped in, and a wide multi-row icon
|
||||
grid nested inside a ScrolledWindow inside a Popover turned out
|
||||
unreliable there in practice -- sometimes rendering squeezed down
|
||||
to a near-unusable sliver, sometimes not registering clicks at
|
||||
all. A dialog has a fixed, predictable content_width/height and
|
||||
the exact same grid+scroller construction CoordDialog already
|
||||
uses successfully for its own Type section, so reusing that shape
|
||||
here sidesteps the whole class of popover-sizing bug rather than
|
||||
debugging it further."""
|
||||
dialog = Adw.Dialog(title=title, content_width=420, content_height=520)
|
||||
toolbar_view = Adw.ToolbarView()
|
||||
dialog.set_child(toolbar_view)
|
||||
toolbar_view.add_top_bar(Adw.HeaderBar())
|
||||
|
||||
def pick(t):
|
||||
dialog.close()
|
||||
on_pick(t)
|
||||
|
||||
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL,
|
||||
margin_top=16, margin_bottom=16, margin_start=20, margin_end=20)
|
||||
# selected=None: this is a pick-one-and-close picker, not an editor
|
||||
# showing a current value, so nothing starts pre-highlighted.
|
||||
outer.append(icons.build_target_type_grid(None, pick, is_ally=is_ally))
|
||||
scroller = Gtk.ScrolledWindow(child=outer, hscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||
toolbar_view.set_content(scroller)
|
||||
dialog.present(self)
|
||||
|
||||
def _open_quick_add_menu(self, coord, x: float, y: float) -> None:
|
||||
"""Right-click on empty map: quick-add a Target or Strike
|
||||
right there, no dialog, for when you already know exactly where
|
||||
you're pointing and don't need to type coordinates."""
|
||||
"""Right-click on empty map: quick-add anything right there, no
|
||||
coordinate dialog, for when you already know exactly where you're
|
||||
pointing. Target/Ally still ask for a type (via _open_type_dialog)
|
||||
rather than silently defaulting to TargetType.UNKNOWN -- picking
|
||||
the wrong generic type and having to notice and fix it later is
|
||||
worse than one extra click now.
|
||||
|
||||
The coordinate is shown once, as this menu's own heading, not
|
||||
repeated on every row ("Add target at K5 4:3", "Add ally at K5
|
||||
4:3", ...) -- it's the same point for every option here (that's
|
||||
the whole premise of a menu anchored to where you clicked), so
|
||||
repeating it back on each row is just noise, not information.
|
||||
"""
|
||||
if coord is None:
|
||||
return
|
||||
popover = self._popover_at(x, y)
|
||||
location = Location.from_coord(coord)
|
||||
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
|
||||
margin_top=6, margin_bottom=6, margin_start=6, margin_end=6)
|
||||
margin_top=6, margin_bottom=6, margin_start=6, margin_end=6)
|
||||
heading = Gtk.Label(xalign=0, margin_start=4, margin_bottom=2)
|
||||
heading.set_markup(f"<b>{GLib.markup_escape_text(coord.label())}</b>")
|
||||
box.append(heading)
|
||||
|
||||
def add_target(_b):
|
||||
self.board.add_target(TargetType.UNKNOWN, coord)
|
||||
def row(label, handler):
|
||||
btn = Gtk.Button(label=label)
|
||||
btn.add_css_class("flat")
|
||||
btn.connect("clicked", lambda _b: handler())
|
||||
box.append(btn)
|
||||
|
||||
def pick_target_type(t):
|
||||
self.board.add_target(t, location)
|
||||
self._refresh()
|
||||
|
||||
def pick_ally_type(t):
|
||||
self.board.add_ally(t, location)
|
||||
self._refresh()
|
||||
|
||||
def add_target():
|
||||
popover.popdown()
|
||||
self._open_type_dialog("Add target", is_ally=False, on_pick=pick_target_type)
|
||||
|
||||
def add_ally():
|
||||
popover.popdown()
|
||||
self._open_type_dialog("Add ally", is_ally=True, on_pick=pick_ally_type)
|
||||
|
||||
def add_spotter():
|
||||
self.board.add_spotter(location)
|
||||
self._refresh()
|
||||
popover.popdown()
|
||||
|
||||
def add_strike(_b):
|
||||
def add_rp():
|
||||
self.board.add_reference_point(location)
|
||||
self._refresh()
|
||||
popover.popdown()
|
||||
|
||||
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()
|
||||
|
||||
target_btn = Gtk.Button(label=f"Add target at {coord.label()}")
|
||||
target_btn.add_css_class("flat")
|
||||
target_btn.connect("clicked", add_target)
|
||||
box.append(target_btn)
|
||||
|
||||
strike_btn = Gtk.Button(label=f"Add strike at {coord.label()}")
|
||||
strike_btn.add_css_class("flat")
|
||||
strike_btn.connect("clicked", add_strike)
|
||||
box.append(strike_btn)
|
||||
row("Add target", add_target)
|
||||
row("Add ally", add_ally)
|
||||
row("Add spotter", add_spotter)
|
||||
row("Add reference point", add_rp)
|
||||
row("Add strike", add_strike)
|
||||
|
||||
popover.set_child(box)
|
||||
popover.popup()
|
||||
@@ -1555,6 +1948,7 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
initial_location=target.location,
|
||||
show_id=True,
|
||||
show_type=True,
|
||||
is_ally=False,
|
||||
id_placeholder=f"ID (current: {target.id})",
|
||||
initial_id=target.id,
|
||||
initial_type=target.type,
|
||||
|
||||
@@ -23,7 +23,7 @@ gi.require_version("Adw", "1")
|
||||
gi.require_version("Gdk", "4.0")
|
||||
from gi.repository import Adw, Gdk, Gtk # noqa: E402
|
||||
|
||||
from . import ocr
|
||||
from . import icons, ocr
|
||||
from .models import LARGE_X, Coord, Location, TargetType
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ class CoordDialog(Adw.Dialog):
|
||||
on_submit: Callable[[Location, str | None, TargetType | None], None],
|
||||
show_id: bool = False,
|
||||
show_type: bool = False,
|
||||
is_ally: bool = False,
|
||||
id_placeholder: str | None = None,
|
||||
initial_location: Location | None = None,
|
||||
initial_id: str | None = None,
|
||||
@@ -46,10 +47,12 @@ class CoordDialog(Adw.Dialog):
|
||||
self._on_submit = on_submit
|
||||
self._show_id = show_id
|
||||
self._show_type = show_type
|
||||
self._is_ally = is_ally
|
||||
self._id_placeholder = id_placeholder or "ID (blank = auto)"
|
||||
self._initial_location = initial_location or Location()
|
||||
self._initial_id = initial_id
|
||||
self._initial_type = initial_type
|
||||
self._type_val = initial_type or TargetType.UNKNOWN
|
||||
|
||||
toolbar_view = Adw.ToolbarView()
|
||||
self.set_child(toolbar_view)
|
||||
@@ -91,7 +94,14 @@ class CoordDialog(Adw.Dialog):
|
||||
flow.set_homogeneous(True)
|
||||
flow.set_row_spacing(4)
|
||||
flow.set_column_spacing(4)
|
||||
flow.set_max_children_per_line(10)
|
||||
# Fixed at 5, not a min/max range: the dialog's width now varies
|
||||
# with whatever else is in it (e.g. the Type grid, see
|
||||
# build_target_type_grid), and a FlowBox reflows to fit whatever
|
||||
# width it's given -- letting it range up to 10 made X (A-T) jump
|
||||
# to 7-wide rows whenever the dialog happened to be wider, which
|
||||
# read as broken rather than deliberate. 5 was the one that looked
|
||||
# right at the dialog's normal size.
|
||||
flow.set_max_children_per_line(5)
|
||||
flow.set_min_children_per_line(5)
|
||||
|
||||
buttons = []
|
||||
@@ -130,8 +140,8 @@ class CoordDialog(Adw.Dialog):
|
||||
spacing=16,
|
||||
margin_top=16,
|
||||
margin_bottom=16,
|
||||
margin_start=16,
|
||||
margin_end=16,
|
||||
margin_start=20,
|
||||
margin_end=20,
|
||||
)
|
||||
|
||||
initial = self._initial_location.coord
|
||||
@@ -175,24 +185,29 @@ class CoordDialog(Adw.Dialog):
|
||||
self._stage_widgets = [Y_group, x_group, y_group, None]
|
||||
|
||||
self.row_id = None
|
||||
self.row_type = None
|
||||
if self._show_id or self._show_type:
|
||||
extra_group = Adw.PreferencesGroup(title="Identity")
|
||||
identity_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
|
||||
if self._show_id:
|
||||
extra_group = Adw.PreferencesGroup(title="Identity")
|
||||
self.row_id = Adw.EntryRow(title=self._id_placeholder)
|
||||
if self._initial_id is not None:
|
||||
self.row_id.set_text(str(self._initial_id))
|
||||
extra_group.add(self.row_id)
|
||||
identity_box.append(extra_group)
|
||||
if self._show_type:
|
||||
self.row_type = Adw.ComboRow(
|
||||
title="Type",
|
||||
model=Gtk.StringList.new([t.short for t in TargetType]),
|
||||
)
|
||||
if self._initial_type is not None:
|
||||
self.row_type.set_selected(list(TargetType).index(self._initial_type))
|
||||
extra_group.add(self.row_type)
|
||||
outer.append(extra_group)
|
||||
self._stage_widgets[3] = extra_group
|
||||
# An icon grid (same idea as the Shell picker), not a plain
|
||||
# text dropdown -- with ~35 types now, seeing the actual
|
||||
# marker art is the difference between recognizing the
|
||||
# right one and reading a wall of similar-sounding names.
|
||||
identity_box.append(self._picker_group(
|
||||
"Type",
|
||||
icons.build_target_type_grid(
|
||||
self._type_val, lambda t: setattr(self, "_type_val", t),
|
||||
is_ally=self._is_ally,
|
||||
),
|
||||
))
|
||||
outer.append(identity_box)
|
||||
self._stage_widgets[3] = identity_box
|
||||
|
||||
submit = Gtk.Button(label="Set coordinates")
|
||||
submit.add_css_class("suggested-action")
|
||||
@@ -201,7 +216,9 @@ class CoordDialog(Adw.Dialog):
|
||||
submit.connect("clicked", self._on_submit_clicked)
|
||||
outer.append(submit)
|
||||
|
||||
self._exact_scroller = Gtk.ScrolledWindow(child=outer)
|
||||
self._exact_scroller = Gtk.ScrolledWindow(
|
||||
child=outer, hscrollbar_policy=Gtk.PolicyType.NEVER
|
||||
)
|
||||
self._exact_content = outer
|
||||
return self._exact_scroller
|
||||
|
||||
@@ -262,7 +279,7 @@ class CoordDialog(Adw.Dialog):
|
||||
|
||||
def _id_and_type(self) -> tuple[str | None, TargetType | None]:
|
||||
id_ = self.row_id.get_text().strip() or None if self.row_id is not None else None
|
||||
type_ = list(TargetType)[self.row_type.get_selected()] if self.row_type is not None else None
|
||||
type_ = self._type_val if self._show_type else None
|
||||
return id_, type_
|
||||
|
||||
def _on_submit_clicked(self, _button: Gtk.Button) -> None:
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Squirrel away screenshots the vision/OCR pipeline handled badly, so the
|
||||
detection algorithms can later be tuned against real failures instead of
|
||||
just the fixture set.
|
||||
|
||||
Three cases, one folder each under _debug_dir():
|
||||
failures/ -- map_vision.solve_path() errored out on what the gate
|
||||
thought was a map (see app.py's _start_map_import).
|
||||
corrections/ -- the user dragged the grid in GridFixDialog rather than
|
||||
just accepting the auto-solve, paired with both the
|
||||
original and the corrected GridSolution as ground truth.
|
||||
maybe_map/ -- a screenshot fell through to the OCR/text path and
|
||||
came back with nothing usable at all; it might genuinely
|
||||
have been a map the gate misrouted, worth a look.
|
||||
|
||||
Deliberately silent on its own failure (a full/read-only disk shouldn't
|
||||
turn a debug aid into a crash): every function here catches broadly and
|
||||
gives up quietly rather than raising into the caller's UI-thread code.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def _debug_dir(sub: str) -> Path:
|
||||
"""XDG data dir if set, ~/.local/share otherwise, matching where a
|
||||
Linux desktop app is expected to keep its own state -- same base
|
||||
other GTK/libadwaita apps on this platform use, just our own
|
||||
subfolder under it."""
|
||||
import os
|
||||
|
||||
base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
|
||||
return Path(base) / "fenigma" / "debug_captures" / sub
|
||||
|
||||
|
||||
def _save(sub: str, png: bytes, meta: dict) -> Path | None:
|
||||
try:
|
||||
d = _debug_dir(sub)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
stamp = f"{time.time():.6f}".replace(".", "-")
|
||||
(d / f"{stamp}.png").write_bytes(png)
|
||||
(d / f"{stamp}.json").write_text(json.dumps(meta, indent=2))
|
||||
return d / f"{stamp}.png"
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _to_png_bytes(image) -> bytes | None:
|
||||
"""Accept raw PNG bytes, a PIL Image, or a BGR numpy array (map_vision's
|
||||
own in-memory image shape, see map_vision.load), so every call site can
|
||||
just hand over whatever it already has."""
|
||||
if isinstance(image, (bytes, bytearray)):
|
||||
return bytes(image)
|
||||
if isinstance(image, np.ndarray):
|
||||
image = Image.fromarray(image[:, :, ::-1]) # BGR (cv2) -> RGB (PIL)
|
||||
if isinstance(image, Image.Image):
|
||||
import io
|
||||
|
||||
buf = io.BytesIO()
|
||||
image.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
return None
|
||||
|
||||
|
||||
def save_map_read_failure(image, reason: str) -> Path | None:
|
||||
"""map_vision rejected/errored on a screenshot the cheap gate thought
|
||||
was a map -- the interesting case, an OCR-text screenshot false-
|
||||
positiving the gate is expected background noise (see
|
||||
ImportJob.looks_like_map's own docstring), but a genuine map the
|
||||
solver couldn't handle is exactly what needs fixing."""
|
||||
png = _to_png_bytes(image)
|
||||
if png is None:
|
||||
return None
|
||||
return _save("failures", png, {"reason": reason})
|
||||
|
||||
|
||||
def save_maybe_map(image) -> Path | None:
|
||||
"""A screenshot that went down the OCR/text path (either the gate
|
||||
routed it there, or map_vision rejected it) and came back with
|
||||
nothing usable -- possibly a map screenshot misread as text rather
|
||||
than genuinely empty intel."""
|
||||
png = _to_png_bytes(image)
|
||||
if png is None:
|
||||
return None
|
||||
return _save("maybe_map", png, {})
|
||||
|
||||
|
||||
def save_marker_ground_truth(image, proposals, added_targets=(), added_allies=()) -> Path | None:
|
||||
"""Ground truth for marker detection, captured when the user drops a
|
||||
screenshot (app.py's _remove_screenshot): every proposal the detector
|
||||
made, whether the user accepted/rejected/never decided it (and, if
|
||||
accepted, what type they actually confirmed -- may differ from the
|
||||
detector's own guess, see Proposal.confirmed_type), PLUS every
|
||||
Target/Ally that ended up on the board while this screenshot was up
|
||||
that *isn't* explained by an accepted proposal at all -- a manual
|
||||
add, or one merged in from OCR text run alongside it. Both signals
|
||||
matter: a rejected proposal is a false positive to fix, a manually-
|
||||
added unit that had no matching proposal at all is a miss to fix.
|
||||
Skipped entirely if there's nothing to say (no proposals AND no
|
||||
manually-added units), a screenshot nobody ever looked at units on.
|
||||
|
||||
Each proposal also carries `detected_id` (map_vision.read_marker_id's
|
||||
best-effort read of the marker's own "#<N>" id label, see its own
|
||||
docstring -- not yet validated against a real batch of this exact
|
||||
ground truth, which is precisely what these captures are for).
|
||||
`image` should be the sharpest one the caller has (full_image over
|
||||
the WORK_W-downscaled one, see ScreenshotImport.full_image) so a
|
||||
human reviewing a capture later can actually read that id text well
|
||||
enough to judge whether detected_id was right -- not just take the
|
||||
detector's word for it."""
|
||||
if not proposals and not added_targets and not added_allies:
|
||||
return None
|
||||
png = _to_png_bytes(image)
|
||||
if png is None:
|
||||
return None
|
||||
|
||||
def verdict(p):
|
||||
if p.accepted:
|
||||
return "accepted"
|
||||
if p.rejected:
|
||||
return "rejected"
|
||||
return "undecided" # dropped along with the screenshot, never actioned
|
||||
|
||||
return _save("marker_ground_truth", png, {
|
||||
"proposals": [
|
||||
{
|
||||
"side": p.side, "label": p.label, "sub_x": p.sub_x, "sub_y": p.sub_y,
|
||||
"detected_unit": p.unit, "verdict": verdict(p), "confirmed_type": p.confirmed_type,
|
||||
"detected_id": p.detected_id,
|
||||
"unit_score": p.unit_score, "unit_margin": p.unit_margin,
|
||||
}
|
||||
for p in proposals
|
||||
],
|
||||
"added_units": [
|
||||
{"kind": kind, "type": u.type.name, "id": u.id, "coord": u.coord.label() if u.coord else None}
|
||||
for kind, units in (("target", added_targets), ("ally", added_allies))
|
||||
for u in units
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
def _solution_to_dict(sol) -> dict:
|
||||
return {
|
||||
"H": sol.H.tolist(),
|
||||
"si": sol.si, "sj": sol.sj,
|
||||
"du": sol.du, "dv": sol.dv,
|
||||
}
|
||||
|
||||
|
||||
def save_grid_correction(image, original_solution, corrected_solution) -> Path | None:
|
||||
"""The user dragged the grid in GridFixDialog rather than accepting
|
||||
the auto-solve as-is: both solutions, saved as ground truth for
|
||||
tuning the grid solver against later."""
|
||||
png = _to_png_bytes(image)
|
||||
if png is None:
|
||||
return None
|
||||
return _save("corrections", png, {
|
||||
"original": _solution_to_dict(original_solution),
|
||||
"corrected": _solution_to_dict(corrected_solution),
|
||||
})
|
||||
@@ -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,
|
||||
@@ -369,7 +434,16 @@ class FiringPanel(Gtk.Box):
|
||||
if dragged not in targets or drop_onto not in targets:
|
||||
return
|
||||
self.board.reorder_target(dragged, targets.index(drop_onto))
|
||||
self.on_change()
|
||||
# NOT self.on_change(): that's app.py's "single choke point" full
|
||||
# refresh (re-run the solver over every target's clues, dedupe,
|
||||
# redraw the map, THEN rebuild this panel), all of it wasted work
|
||||
# for a pure order change -- no location/clue/coord/alive state
|
||||
# moved, so nothing the solver or the map drawing cares about
|
||||
# changed, only this panel's own card order did. Calling that
|
||||
# full pipeline on every single drag-drop was what made
|
||||
# reordering feel laggy; a local refresh() is the only rebuild a
|
||||
# reorder actually needs.
|
||||
self.refresh()
|
||||
|
||||
def _build_unresolved_card(self, target: Target) -> Gtk.Widget:
|
||||
card, inner = self._build_card_shell(target)
|
||||
@@ -496,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
|
||||
|
||||
@@ -12,6 +12,7 @@ from collections import namedtuple
|
||||
import cairo
|
||||
import gi
|
||||
import numpy as np
|
||||
from PIL import Image as PILImage
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Gdk", "4.0")
|
||||
@@ -195,6 +196,44 @@ def _icon_for(category: str, obj) -> cairo.ImageSurface | None:
|
||||
return None
|
||||
|
||||
|
||||
# path -> (surface, content_bbox) for additive badges specifically.
|
||||
# Separate from _ICON_SURFACE_CACHE because these also need their real
|
||||
# opaque content's bounding box: unlike the unit icons (already ~edge to
|
||||
# edge in their own canvas, see _draw_icon_marker), the additive art
|
||||
# (assets/icons/targets/additives/) sits inside a lot of transparent
|
||||
# padding that isn't even centered -- scaling/positioning off the full
|
||||
# 256x256 canvas made the badge look tiny and float with a visible gap
|
||||
# above the icon it's supposed to touch. bbox is None for a path that
|
||||
# failed to load, or (l, t, r, b) of its actual opaque pixels.
|
||||
_ADDITIVE_CACHE: dict = {}
|
||||
|
||||
|
||||
def _additive_surface(path) -> tuple:
|
||||
if path not in _ADDITIVE_CACHE:
|
||||
surface, bbox = None, None
|
||||
try:
|
||||
surface = cairo.ImageSurface.create_from_png(str(path))
|
||||
bbox = PILImage.open(str(path)).getbbox()
|
||||
except Exception:
|
||||
pass
|
||||
_ADDITIVE_CACHE[path] = (surface, bbox)
|
||||
return _ADDITIVE_CACHE[path]
|
||||
|
||||
|
||||
def _additive_for(category: str, obj) -> tuple | None:
|
||||
"""The underground-tier badge overlaid on top of a Target's own icon,
|
||||
or None. Target-only (see Target.underground_tier's own comment)."""
|
||||
if category != "target":
|
||||
return None
|
||||
tier = getattr(obj, "underground_tier", None)
|
||||
if tier is None:
|
||||
return None
|
||||
surface, bbox = _additive_surface(icons.underground_icon_path(tier))
|
||||
if surface is None:
|
||||
return None
|
||||
return (surface, bbox)
|
||||
|
||||
|
||||
class GridCanvas(Gtk.DrawingArea):
|
||||
def __init__(self, board: Board) -> None:
|
||||
super().__init__()
|
||||
@@ -599,8 +638,24 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
what a proposal is until the user accepts it."""
|
||||
for p, coord in self._pending_proposals():
|
||||
color = CATEGORY_COLOR["ally" if p.side == "friendly" else "target"]
|
||||
# No coord here: _draw_marker already shows one right below
|
||||
# this label (`coord=coord` below), repeating it in the main
|
||||
# label too was pure noise. detected_type/detected_id
|
||||
# (map_vision.classify_marker/read_marker_id's best-effort
|
||||
# reads) shown instead when known -- same "? Mechanized#3"
|
||||
# shape an accepted entity's own name takes (Target.name),
|
||||
# checkable against the actual screenshot pixels while it's
|
||||
# still up, and detected_id is the same id _accept_proposal
|
||||
# will use for the entity if this gets accepted.
|
||||
detected_type = icons.target_type_from_icon(p.unit)
|
||||
# .short, not target_type_label(): matches Target.name/Ally.name's
|
||||
# own naming exactly ("SupplyCache" not "Supply Cache"), so this
|
||||
# preview label reads the same as what accepting it produces.
|
||||
type_part = detected_type.short if detected_type else ""
|
||||
id_part = f"#{p.detected_id}" if p.detected_id else ""
|
||||
label = f"? {type_part}{id_part}" if (type_part or id_part) else "?"
|
||||
self._draw_marker(cr, view, coord.as_fraction(), color,
|
||||
f"? {coord.label()}", width, height,
|
||||
label, width, height,
|
||||
hollow=True, coord=coord)
|
||||
|
||||
def _hit_test(self, view: _View, x: float, y: float):
|
||||
@@ -817,7 +872,8 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
dim=(category == "target" and not obj.alive) or obj.hidden,
|
||||
selected=(obj is self.selected), coord=obj.coord,
|
||||
extra_line=getattr(obj, "requested_time", None),
|
||||
icon_surface=_icon_for(category, obj))
|
||||
icon_surface=_icon_for(category, obj),
|
||||
additive=_additive_for(category, obj))
|
||||
|
||||
for category, obj in self.board.ambiguous_entities_all():
|
||||
if self._excluded_from_map(obj):
|
||||
@@ -878,7 +934,8 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
|
||||
def _draw_marker(self, cr, view, point_km, color, label,
|
||||
canvas_width, canvas_height, *, hollow=False, dim=False,
|
||||
selected=False, coord=None, extra_line=None, icon_surface=None) -> None:
|
||||
selected=False, coord=None, extra_line=None, icon_surface=None,
|
||||
additive=None) -> None:
|
||||
x, y = self._km_to_px(view, point_km)
|
||||
r, g, b = color
|
||||
alpha = 0.45 if dim else 1.0
|
||||
@@ -898,6 +955,8 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
# plain filled dot is more honest about the current zoom level.
|
||||
if not hollow and icon_surface is not None and view.cell_w >= ICON_MIN_CELL_PX:
|
||||
self._draw_icon_marker(cr, x, y, icon_surface, alpha)
|
||||
if additive is not None:
|
||||
self._draw_additive_badge(cr, x, y, additive, alpha)
|
||||
elif hollow:
|
||||
cr.new_path() # cairo's arc() draws a line from any stale current
|
||||
cr.set_source_rgba(r, g, b, alpha) # point (e.g. the last label's
|
||||
@@ -962,6 +1021,48 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
cr.paint_with_alpha(alpha)
|
||||
cr.restore()
|
||||
|
||||
# How far the badge's content bbox sinks into the icon's, in the
|
||||
# icon's own 32px box units. Both the diamond's top corner and the
|
||||
# Armor badge's bottom are tapered to a near-point, not a flat edge
|
||||
# (see assets/icons/targets/enemy/Enemy_Infantry.png and the Armor
|
||||
# additives) -- lining up their bboxes exactly *touching* leaves them
|
||||
# meeting at a single pixel with no visual mass on either side of it,
|
||||
# which still reads as a gap. A real pixel overlap is what actually
|
||||
# looks contiguous, confirmed against the game's own stacked-badge
|
||||
# screenshots (stars/helmet/diamond all overlapping, not edge-to-edge).
|
||||
_ADDITIVE_OVERLAP_PX = 10.0
|
||||
|
||||
def _draw_additive_badge(self, cr, x, y, additive, alpha) -> None:
|
||||
"""A badge (underground tier, currently the only additive) drawn
|
||||
directly north of the icon marker, overlapping down into it by
|
||||
`_ADDITIVE_OVERLAP_PX`, at the same full size as the marker
|
||||
itself -- stacked above it rather than shrunk into a corner, so
|
||||
it reads as its own clearly-legible symbol, not a tiny decoration
|
||||
obscuring the unit icon it modifies.
|
||||
|
||||
Scaled/positioned off the source art's actual opaque content
|
||||
(`bbox`), not its full canvas: the additive PNGs carry a lot of
|
||||
transparent padding that isn't even centered (see _ADDITIVE_CACHE's
|
||||
comment), so sizing/placing off the raw canvas made the badge look
|
||||
tiny and float with a visible gap above the icon -- using bbox
|
||||
instead makes what's actually drawn sit right against it."""
|
||||
surface, bbox = additive
|
||||
sw, sh = surface.get_width(), surface.get_height()
|
||||
left, top, right, bottom = bbox if bbox is not None else (0, 0, sw, sh)
|
||||
content_w, content_h = right - left, bottom - top
|
||||
if content_w <= 0 or content_h <= 0:
|
||||
return
|
||||
box = 32.0 # same visual size as the icon marker's own box
|
||||
scale = box / max(content_w, content_h)
|
||||
icon_top = y - 16 # _draw_icon_marker's own box=32, centered on y
|
||||
ty = icon_top - bottom * scale + self._ADDITIVE_OVERLAP_PX
|
||||
cr.save()
|
||||
cr.translate(x - (left + right) / 2 * scale, ty)
|
||||
cr.scale(scale, scale)
|
||||
cr.set_source_surface(surface, 0, 0)
|
||||
cr.paint_with_alpha(alpha)
|
||||
cr.restore()
|
||||
|
||||
def _draw_firing_arrows(self, cr, view) -> None:
|
||||
"""Red arrow(s) Nest -> Target, for whatever's hovered or selected.
|
||||
Points at exactly the hovered/selected candidate when one is known
|
||||
@@ -996,29 +1097,37 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
self._draw_arrow(cr, nx, ny, tx, ty)
|
||||
|
||||
def _draw_blast_radius(self, cr, view) -> None:
|
||||
"""When a Target is selected, its effective shell's blast radius,
|
||||
selection only, not hover (unlike the geo overlays/firing arrow),
|
||||
per spec. Uses the specific selected candidate point if the target
|
||||
is ambiguous; skipped entirely if there's no known point yet, or
|
||||
the shell's blast radius isn't known."""
|
||||
if not isinstance(self.selected, Target):
|
||||
return
|
||||
target = self.selected
|
||||
point = target.coord if target.coord is not None else self.selected_point
|
||||
if point is None:
|
||||
return
|
||||
radius_km = target.effective_shell.blast_radius_km
|
||||
if radius_km is None:
|
||||
return
|
||||
"""Every Target's effective shell's blast radius, for whichever
|
||||
ones are selected or pinned via the same "always show geo"
|
||||
show_geo_desc toggle the bearing/distance overlay uses (not on
|
||||
plain hover, unlike that overlay -- a blast radius circle
|
||||
flickering in on every hover was judged too noisy, selection/
|
||||
pinning is a deliberate choice). Uses the specific selected
|
||||
candidate point if an ambiguous target is the selected one;
|
||||
skipped per-target if there's no known point yet, or the shell's
|
||||
blast radius isn't known."""
|
||||
targets = [
|
||||
t for t in self.board.targets
|
||||
if not self._excluded_from_map(t) and (t is self.selected or t.show_geo_desc)
|
||||
]
|
||||
for target in targets:
|
||||
point = target.coord if target.coord is not None else (
|
||||
self.selected_point if target is self.selected else None
|
||||
)
|
||||
if point is None:
|
||||
continue
|
||||
radius_km = target.effective_shell.blast_radius_km
|
||||
if radius_km is None:
|
||||
continue
|
||||
|
||||
x, y = self._km_to_px(view, point.as_fraction())
|
||||
rx, ry = view.cell_w * radius_km, view.cell_h * radius_km
|
||||
self._draw_ellipse(cr, x, y, rx, ry)
|
||||
cr.set_source_rgba(*BLAST_RADIUS, 0.18)
|
||||
cr.fill_preserve()
|
||||
cr.set_source_rgba(*BLAST_RADIUS, 0.85)
|
||||
cr.set_line_width(2)
|
||||
cr.stroke()
|
||||
x, y = self._km_to_px(view, point.as_fraction())
|
||||
rx, ry = view.cell_w * radius_km, view.cell_h * radius_km
|
||||
self._draw_ellipse(cr, x, y, rx, ry)
|
||||
cr.set_source_rgba(*BLAST_RADIUS, 0.18)
|
||||
cr.fill_preserve()
|
||||
cr.set_source_rgba(*BLAST_RADIUS, 0.85)
|
||||
cr.set_line_width(2)
|
||||
cr.stroke()
|
||||
|
||||
def _draw_scout_flight_rect(self, cr, view, center_km, bearing_deg, *,
|
||||
dashed=False, alpha_mult=1.0) -> None:
|
||||
@@ -1084,7 +1193,14 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
way, so this looks at every RP/Target directly rather than those,
|
||||
the only way to let the user eyeball a bad-but-close reading
|
||||
against what it should have crossed."""
|
||||
candidates = list(self.board.reference_points) + list(self.board.targets)
|
||||
# Nest/Spotter never carry clues (always given as a direct grid
|
||||
# coord, no relative-bearing mechanic for them), so leaving them
|
||||
# out here wouldn't visibly change anything -- but Allies DO get
|
||||
# clues from OCR ("FriendlyTank#1 Spotted. 088, 12.10km from
|
||||
# Spotter#1") and also have a show_geo_desc pin in the UI (see
|
||||
# app.py's per-card "always show geo" toggle), so omitting them
|
||||
# here meant pinning one silently did nothing.
|
||||
candidates = list(self.board.reference_points) + list(self.board.targets) + list(self.board.allies)
|
||||
to_show = [
|
||||
obj for obj in candidates
|
||||
if obj.location.clues and not self._excluded_from_map(obj)
|
||||
|
||||
@@ -15,7 +15,9 @@ import gi
|
||||
gi.require_version("Gdk", "4.0")
|
||||
gi.require_version("GdkPixbuf", "2.0")
|
||||
gi.require_version("Gtk", "4.0")
|
||||
from gi.repository import Gdk, GdkPixbuf, Gtk # noqa: E402
|
||||
from gi.repository import Gdk, GdkPixbuf, Gtk, Pango # noqa: E402
|
||||
|
||||
import cairo
|
||||
|
||||
from .models import TargetType
|
||||
from .shells import Shell
|
||||
@@ -28,30 +30,103 @@ _css_loaded = False
|
||||
NEST_ICON_PATH = _ICONS_DIR / "nest" / "IronNest.png"
|
||||
STRIKE_ICON_PATH = _ICONS_DIR / "misc" / "Crosshair.png"
|
||||
|
||||
# TargetType -> the shared basename suffix, after 'Enemy_'/'Friendly_',
|
||||
# for whichever of the game's own unit icons fits best. Best-effort
|
||||
# guesses (flagged the same way the Shell descriptions were): the game
|
||||
# doesn't have a dedicated icon for every one of our types, TANK reuses
|
||||
# the Armor_Mechanized artwork, and FDC/PILLBOX/ENEMY only exist on the
|
||||
# enemy side at all (see target_icon_path()'s fallback). No entry means
|
||||
# no icon exists worth drawing, the caller falls back to a plain dot
|
||||
# (only UNKNOWN now, STRIKE has its own crosshair below).
|
||||
_TARGET_ICON_BASENAME = {
|
||||
TargetType.SUPPLY_CACHE: "Ammunition Cache.png",
|
||||
TargetType.FDC: "Fire Direction Center.png",
|
||||
TargetType.INFANTRY: "Infantry.png",
|
||||
TargetType.MECHANIZED: "Armor_Mechanized.png",
|
||||
TargetType.ARTILLERY: "Field Artillery.png",
|
||||
TargetType.TANK: "Armor_Mechanized.png",
|
||||
TargetType.PILLBOX: "Heavy_Gun_Bunker.png",
|
||||
TargetType.MARINE_GARRISON: "Marine.png",
|
||||
TargetType.ENEMY: "Base.png",
|
||||
# TargetType -> (enemy_basename, friendly_basename), after 'Enemy_'/
|
||||
# 'Friendly_'. Either half is None where the game draws no icon for that
|
||||
# type on that side at all -- that's not rare enough on either side to
|
||||
# treat as an exception list bolted onto a shared-name table (the earlier
|
||||
# shape of this code: one basename table plus two separate patch dicts for
|
||||
# "actually the friendly filename differs" and "actually this side has
|
||||
# none at all", which was easy to update inconsistently and silently do
|
||||
# the wrong thing for one side). One explicit pair per type, covering
|
||||
# EVERY TargetType, is the actual shape of the data: a basename shared by
|
||||
# both sides, a basename that differs (a genuine filename mismatch in the
|
||||
# source assets, not a difference in what's drawn, see assets/icons/
|
||||
# README.md), or a basename that exists on only one side.
|
||||
#
|
||||
# UNKNOWN/ENEMY are deliberately (None, None): generic/ad-hoc, not a unit
|
||||
# the game draws specific art for (see their comments on TargetType).
|
||||
# STRIKE isn't in this table at all: its crosshair isn't an Enemy_/
|
||||
# Friendly_ file, it's handled as a special case in target_icon_path().
|
||||
# completeness of this table (every TargetType except STRIKE has a row) is
|
||||
# asserted below, not just hoped for.
|
||||
_TARGET_ICON = {
|
||||
TargetType.UNKNOWN: (None, None),
|
||||
TargetType.ENEMY: (None, None),
|
||||
|
||||
TargetType.ANTI_AIR: ("AA.png", "AA.png"),
|
||||
TargetType.ANTI_TANK: ("AntiTank.png", "AntiTank.png"),
|
||||
TargetType.ARTILLERY: ("Field Artillery.png", "Field Artillery.png"),
|
||||
TargetType.ARTILLERY_OBSERVER: ("Field Artillery Observer.png", "Field Artillery Observer.png"),
|
||||
TargetType.HEAVY_GUN_TURRET: ("Heavy_Gun_Turret.png", None),
|
||||
TargetType.INFANTRY: ("Infantry.png", "Infantry.png"),
|
||||
TargetType.INFANTRY_MECHANIZED: ("Infantry_mechanized.png", "Infantry_Mechanized.png"), # case differs
|
||||
TargetType.MECH_ANTI_TANK: (None, "Mech_AntiTank.png"), # friendly-only
|
||||
TargetType.MECHANIZED: ("Armor_Mechanized.png", "Armor_Mechanized.png"),
|
||||
TargetType.PILLBOX: ("Heavy_Gun_Bunker.png", None),
|
||||
TargetType.TANK: ("Armor_Mechanized.png", "Armor_Mechanized.png"), # shares MECHANIZED's art, see TargetType
|
||||
|
||||
TargetType.BASE: ("Base.png", "Military Base.png"), # name differs
|
||||
TargetType.COMMANDER: ("Commander.png", "Commander.png"),
|
||||
TargetType.FDC: ("Fire Direction Center.png", None),
|
||||
TargetType.FORT: (None, "Fort.png"), # friendly-only
|
||||
TargetType.GENERAL: (None, "General.png"), # friendly-only
|
||||
TargetType.KING: (None, "King.png"), # friendly-only
|
||||
TargetType.MARINE_GARRISON: ("Marine.png", "Marine.png"),
|
||||
TargetType.POLICE: (None, "Police.png"), # friendly-only
|
||||
TargetType.SUPPLY_CACHE: ("Ammunition Cache.png", "Ammunition Cache.png"),
|
||||
TargetType.UNDERGROUND_FORT: ("Underground Fort.png", None),
|
||||
|
||||
TargetType.EMERGENCY_MEDICAL: ("Emergency Medical Operation.png", "Emergency Medical Operation.png"),
|
||||
TargetType.HOSPITAL: (None, "Hospital.png"), # friendly-only
|
||||
TargetType.MEDICAL: ("Medical.png", "Medical.png"),
|
||||
TargetType.MEDICAL_FACILITY: ("Medical Treatment Facility.png", "Medical Treatment Facility.png"),
|
||||
|
||||
TargetType.CIVIL_MILITARY: (None, "Civil–Military.png"), # friendly-only
|
||||
TargetType.CIVILIAN: ("Civ.png", "Civilian.png"), # name differs
|
||||
TargetType.CIVIL_RIOTING: ("Civil Rioting.png", "Civil Rioting.png"),
|
||||
TargetType.RIOTING: ("Rioting.png", None),
|
||||
TargetType.TV_RADIO_PROPAGANDA: ("TV and Radio Propaganda.png", "TV and Radio Propaganda.png"),
|
||||
|
||||
TargetType.PORT: ("Port.png", "Port.png"),
|
||||
TargetType.SHIP: ("Ship.png", None),
|
||||
TargetType.SHIP_ENGINE: ("Ship_Engine.png", None),
|
||||
TargetType.SHIP_FDC: ("Ship_FDC.png", None),
|
||||
TargetType.SHIP_STRIPE: ("Ship_Stripe.png", "Ship_stripe.png"), # case differs
|
||||
TargetType.SHIP_TURRET: ("Ship_Turret.png", None),
|
||||
|
||||
TargetType.TRAIN_LOCOMOTIVE: ("Train_Locomotive.png", None),
|
||||
TargetType.TRAIN_STATION: ("Train_Station.png", "Train_Station.png"),
|
||||
TargetType.TRAIN_TRANSPORT: ("Train_Transport.png", None),
|
||||
|
||||
TargetType.RECON: ("Recon.png", "Reconnaissance.png"), # name differs
|
||||
TargetType.RECON_LISTENING: ("Recon_Listening.png", "Recon_Listening.png"),
|
||||
}
|
||||
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)"
|
||||
)
|
||||
|
||||
|
||||
def _icon_for_side(target_type: TargetType, is_ally: bool) -> Path | None:
|
||||
"""This SIDE's own icon for target_type specifically, with no
|
||||
cross-side fallback -- used both by target_icon_path() (which adds
|
||||
the fallback back on top) and by _has_own_icon() (which needs to know
|
||||
whether this side has real art of its own, not whether *some* art is
|
||||
available after falling back)."""
|
||||
entry = _TARGET_ICON.get(target_type)
|
||||
if entry is None:
|
||||
return None
|
||||
basename = entry[1 if is_ally else 0]
|
||||
if basename is None:
|
||||
return None
|
||||
folder, prefix = ("friendly", "Friendly_") if is_ally else ("enemy", "Enemy_")
|
||||
path = _ICONS_DIR / "targets" / folder / f"{prefix}{basename}"
|
||||
return path if path.exists() else None
|
||||
|
||||
|
||||
def target_type_from_icon(basename: str | None) -> TargetType | None:
|
||||
"""Inverse of _TARGET_ICON_BASENAME, for the map-vision marker classifier,
|
||||
which names what it matched by icon file rather than by TargetType.
|
||||
"""Inverse of _TARGET_ICON, for the map-vision marker classifier, which
|
||||
names what it matched by icon file rather than by TargetType.
|
||||
|
||||
Not injective: MECHANIZED and TANK share Armor_Mechanized.png, so that one
|
||||
resolves to MECHANIZED and the user retypes it if it was a Tank (map
|
||||
@@ -64,31 +139,27 @@ def target_type_from_icon(basename: str | None) -> TargetType | None:
|
||||
for prefix in ("Enemy_", "Friendly_"):
|
||||
if name.startswith(prefix):
|
||||
name = name[len(prefix):]
|
||||
for type_, base in _TARGET_ICON_BASENAME.items():
|
||||
if base == name:
|
||||
for type_, (enemy_basename, friendly_basename) in _TARGET_ICON.items():
|
||||
if name in (enemy_basename, friendly_basename):
|
||||
return type_
|
||||
return None
|
||||
|
||||
|
||||
def target_icon_path(target_type: TargetType, is_ally: bool = False) -> Path | None:
|
||||
"""Icon file for a Target or Ally's type, or None if there isn't a
|
||||
good one. `is_ally` picks the Friendly_ set over the Enemy_ one,
|
||||
falling back to Enemy_ if that particular basename has no friendly
|
||||
version (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:
|
||||
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/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
|
||||
basename = _TARGET_ICON_BASENAME.get(target_type)
|
||||
if basename is None:
|
||||
return None
|
||||
if is_ally:
|
||||
friendly = _ICONS_DIR / "targets" / "friendly" / f"Friendly_{basename}"
|
||||
if friendly.exists():
|
||||
return friendly
|
||||
enemy = _ICONS_DIR / "targets" / "enemy" / f"Enemy_{basename}"
|
||||
return enemy if enemy.exists() else None
|
||||
own = _icon_for_side(target_type, is_ally)
|
||||
if own is not None:
|
||||
return own
|
||||
return _icon_for_side(target_type, is_ally=False) if is_ally else None
|
||||
|
||||
|
||||
def _ensure_icon_button_css() -> None:
|
||||
@@ -200,10 +271,10 @@ def _shell_cell(s: Shell) -> Gtk.Widget:
|
||||
return cell
|
||||
|
||||
|
||||
def _build_shell_grid(make_button) -> Gtk.Widget:
|
||||
"""Shared grid layout: rows of up to _GRID_COLUMNS buttons, one per
|
||||
Shell, each built by `make_button(shell) -> Gtk.Widget`. Used by
|
||||
both the popover picker and the inline radio-style grid below.
|
||||
def _build_icon_grid(items, columns, make_button) -> Gtk.Widget:
|
||||
"""Shared grid layout: rows of up to `columns` buttons, one per item in
|
||||
`items`, each built by `make_button(item) -> Gtk.Widget`. Used by every
|
||||
icon-grid picker in this module (shells, target types).
|
||||
|
||||
A plain nested Gtk.Box grid, not a Gtk.FlowBox, on purpose, after
|
||||
two FlowBox attempts both broke in different ways: a ScrolledWindow
|
||||
@@ -213,24 +284,28 @@ def _build_shell_grid(make_button) -> Gtk.Widget:
|
||||
no fixed allocation yet) turned out to mean 'fit every child on one
|
||||
line', ignoring max_children_per_line entirely, so min/max-content-
|
||||
width on a ScrolledWindow around it never actually took effect
|
||||
(verified directly: it kept ballooning out to fit every shell in a
|
||||
single row regardless of what those properties were set to). Shell
|
||||
is a small, fixed, known set, there's no real need for FlowBox's
|
||||
(verified directly: it kept ballooning out to fit every item in a
|
||||
single row regardless of what those properties were set to). Each of
|
||||
these sets is small and fixed, there's no real need for FlowBox's
|
||||
dynamic reflow-to-fewer-columns behavior here, a manual grid of
|
||||
fixed-size rows has a fully deterministic natural width (columns *
|
||||
cell width, nothing else involved) and sidesteps the whole class of
|
||||
bug."""
|
||||
shells = list(Shell)
|
||||
items = list(items)
|
||||
grid = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4,
|
||||
margin_top=8, margin_bottom=8, margin_start=8, margin_end=8)
|
||||
for start in range(0, len(shells), _GRID_COLUMNS):
|
||||
for start in range(0, len(items), columns):
|
||||
row_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4, homogeneous=True)
|
||||
for s in shells[start:start + _GRID_COLUMNS]:
|
||||
row_box.append(make_button(s))
|
||||
for it in items[start:start + columns]:
|
||||
row_box.append(make_button(it))
|
||||
grid.append(row_box)
|
||||
return grid
|
||||
|
||||
|
||||
def _build_shell_grid(make_button) -> Gtk.Widget:
|
||||
return _build_icon_grid(Shell, _GRID_COLUMNS, make_button)
|
||||
|
||||
|
||||
def build_shell_popover(on_pick) -> Gtk.Popover:
|
||||
"""Popover with a grid of every Shell (icon + blast radius under it,
|
||||
full description as a tooltip), replacing a plain text dropdown/list
|
||||
@@ -321,3 +396,249 @@ def build_shell_button(selected: Shell, on_pick, *, show_label: bool = True, ico
|
||||
render(selected)
|
||||
btn.set_popover(build_shell_popover(handle_pick))
|
||||
return btn
|
||||
|
||||
|
||||
# ---- TargetType icon-grid picker, same idea as the Shell picker above ----
|
||||
|
||||
_TYPE_GRID_ICON_WIDTH = 40 # smaller than the shell grid's: ~35 types vs 9 shells,
|
||||
_TYPE_GRID_COLUMNS = 5 # needs to fit a lot more cells in the same dialog width
|
||||
|
||||
|
||||
# Mirrors grid_widget.py's CATEGORY_COLOR["target"]/["ally"] (the colors the
|
||||
# map itself draws the plain-dot fallback in). Duplicated rather than
|
||||
# imported: grid_widget.py already imports this module for icon lookups, an
|
||||
# import the other way would be circular. Unlike that module's palette,
|
||||
# these two are not theme-swapped live -- the picker is a modal dialog, not
|
||||
# the persistent map, redrawing it on a theme change isn't worth the wiring.
|
||||
_DOT_COLOR = {False: (0.92, 0.30, 0.28), True: (0.30, 0.85, 0.85)}
|
||||
|
||||
|
||||
def _plain_dot(is_ally: bool, width: int) -> Gtk.Widget:
|
||||
"""The same 'plain dot' fallback the map itself draws for a type with
|
||||
no dedicated icon (see grid_widget.py's _icon_for), so a type with no
|
||||
icon reads as 'this type has no special marker' rather than as a
|
||||
rendering gap in the picker."""
|
||||
area = Gtk.DrawingArea()
|
||||
area.set_content_width(width)
|
||||
area.set_content_height(width)
|
||||
|
||||
def draw(_area, cr, w, h):
|
||||
cr.set_source_rgb(*_DOT_COLOR[is_ally])
|
||||
cr.arc(w / 2, h / 2, min(w, h) * 0.32, 0, 2 * 3.141592653589793)
|
||||
cr.fill()
|
||||
|
||||
area.set_draw_func(draw)
|
||||
return area
|
||||
|
||||
|
||||
def target_type_icon_image(target_type: "TargetType", is_ally: bool = False, width: int = _TYPE_GRID_ICON_WIDTH) -> Gtk.Widget:
|
||||
"""A widget showing target_type's icon (friendly or enemy art per
|
||||
`is_ally`), scaled to `width` px wide. Falls back to the same plain dot
|
||||
the map itself draws for the types with no dedicated icon (UNKNOWN,
|
||||
ENEMY -- see icons.py's _TARGET_ICON_BASENAME comment; STRIKE always
|
||||
has its crosshair), so every cell in the grid stays the same size
|
||||
whether or not it has real art."""
|
||||
path = target_icon_path(target_type, is_ally=is_ally)
|
||||
if path is not None and path.exists():
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(path), width, -1, True)
|
||||
picture = Gtk.Picture.new_for_pixbuf(pixbuf)
|
||||
picture.set_content_fit(Gtk.ContentFit.CONTAIN)
|
||||
picture.set_can_shrink(True)
|
||||
picture.set_size_request(pixbuf.get_width(), pixbuf.get_height())
|
||||
return picture
|
||||
return _plain_dot(is_ally, width)
|
||||
|
||||
|
||||
_ADDITIVES_DIR = _ICONS_DIR / "targets" / "additives"
|
||||
UNDERGROUND_TIERS = (1, 2, 3)
|
||||
|
||||
|
||||
def underground_icon_path(tier: int) -> Path:
|
||||
"""The badge overlaid on a Target's own icon when it's marked
|
||||
underground at this tier (1..3, harder to hit = higher). Reuses the
|
||||
game's own Armor-tier additive art (assets/icons/targets/additives/
|
||||
Additive_Armor{1,2,3}.png) rather than inventing bespoke "underground"
|
||||
art of our own -- there's nothing else in the game's icon set for
|
||||
"buried/fortified", and Armor's own visual (a plate) already reads
|
||||
right for that."""
|
||||
return _ADDITIVES_DIR / f"Additive_Armor{tier}.png"
|
||||
|
||||
|
||||
def underground_tier_image(tier: int | None, width: int = _TYPE_GRID_ICON_WIDTH) -> Gtk.Widget:
|
||||
"""A widget for one cell of the underground-tier picker: the additive
|
||||
badge itself for a real tier, or a plain dot (this module's usual
|
||||
'nothing chosen' placeholder) for the "not underground" cell."""
|
||||
if tier is None:
|
||||
return _plain_dot(False, width)
|
||||
path = underground_icon_path(tier)
|
||||
if path.exists():
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(path), width, -1, True)
|
||||
picture = Gtk.Picture.new_for_pixbuf(pixbuf)
|
||||
picture.set_content_fit(Gtk.ContentFit.CONTAIN)
|
||||
picture.set_can_shrink(True)
|
||||
picture.set_size_request(pixbuf.get_width(), pixbuf.get_height())
|
||||
return picture
|
||||
return _plain_dot(False, width)
|
||||
|
||||
|
||||
def build_underground_tier_grid(selected: int | None, on_pick) -> Gtk.Widget:
|
||||
"""Same radio-style grid idea as build_target_type_grid, just over
|
||||
(None, 1, 2, 3) instead of TargetType -- None first, as "not
|
||||
underground" (clearing an existing tier) is exactly as valid a pick
|
||||
as any real tier, not a separate "remove" action bolted on
|
||||
afterward."""
|
||||
_ensure_icon_button_css()
|
||||
leader: Gtk.ToggleButton | None = None
|
||||
items = [None, *UNDERGROUND_TIERS]
|
||||
|
||||
def make_button(tier: int | None) -> Gtk.Widget:
|
||||
nonlocal leader
|
||||
cell = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
|
||||
margin_top=4, margin_bottom=4, margin_start=2, margin_end=2)
|
||||
cell.append(underground_tier_image(tier))
|
||||
label = Gtk.Label(label="None" if tier is None else f"Tier {tier}",
|
||||
wrap=False, single_line_mode=True,
|
||||
justify=Gtk.Justification.CENTER, width_chars=9,
|
||||
max_width_chars=9, ellipsize=Pango.EllipsizeMode.END)
|
||||
label.add_css_class("caption")
|
||||
label.add_css_class("dim-label")
|
||||
cell.append(label)
|
||||
btn = Gtk.ToggleButton(child=cell)
|
||||
btn.add_css_class("flat")
|
||||
btn.add_css_class(_ICON_BUTTON_CSS_CLASS)
|
||||
btn.set_tooltip_text("Not underground" if tier is None else f"Underground, tier {tier}")
|
||||
if leader is None:
|
||||
leader = btn
|
||||
else:
|
||||
btn.set_group(leader)
|
||||
if tier is selected:
|
||||
btn.set_active(True)
|
||||
btn.connect("clicked", lambda _b, tier=tier: on_pick(tier))
|
||||
return btn
|
||||
|
||||
return _build_icon_grid(items, _TYPE_GRID_COLUMNS, make_button)
|
||||
|
||||
|
||||
def _has_own_icon(t: "TargetType", is_ally: bool) -> bool:
|
||||
"""Whether THIS side specifically has real art for t -- as opposed to
|
||||
target_icon_path() quietly handing back the other side's icon because
|
||||
this side has none of its own (see its own docstring). Used to keep
|
||||
that cross-side fallback out of the picker grids entirely: showing a
|
||||
red diamond as an option for 'Add ally', or offering 'King'/'Police'/
|
||||
etc. (friendly-only, see _TARGET_ICON) as an enemy type, reads as a
|
||||
real option of the wrong side rather than a missing-icon placeholder.
|
||||
UNKNOWN and ENEMY are the exception: deliberately generic/icon-less on
|
||||
BOTH sides (see their comments on TargetType), always offered
|
||||
regardless."""
|
||||
if t in (TargetType.UNKNOWN, TargetType.ENEMY):
|
||||
return True
|
||||
return _icon_for_side(t, is_ally) is not None
|
||||
|
||||
|
||||
def available_target_types(is_ally: bool = False):
|
||||
"""TargetType members worth offering in a picker for this side.
|
||||
|
||||
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 not in (TargetType.STRIKE, TargetType.STRIKE_REQUEST) and _has_own_icon(t, is_ally)
|
||||
]
|
||||
|
||||
|
||||
def target_type_label(t: "TargetType", is_ally: bool) -> str:
|
||||
"""Display text for a picker cell/tooltip, or any other UI spot that
|
||||
would otherwise print obj.type.value directly (map popover headings,
|
||||
"Change type" buttons, toasts, ...). TargetType.ENEMY's own value is
|
||||
literally 'Enemy' (it's the word the game's OCR'd text uses for an
|
||||
ad-hoc *hostile* installation, see TargetType's own comment) --
|
||||
exactly right in the enemy picker, but confusing in the Ally one,
|
||||
where the very same generic/ad-hoc-named-unit case reads as 'Enemy'
|
||||
is somehow a kind of Ally. Cosmetic only: the underlying TargetType
|
||||
stored on the entity is still ENEMY either way, only the label shown
|
||||
changes -- callers that need an id-safe short form (Ally.name etc.)
|
||||
keep using TargetType.short, not this."""
|
||||
if is_ally and t is TargetType.ENEMY:
|
||||
return "Ally"
|
||||
return t.value
|
||||
|
||||
|
||||
# Old private name, kept as an alias: nothing outside this module should
|
||||
# gain a new dependency on it, but this file's own internal callers below
|
||||
# were written against it.
|
||||
_target_type_label = target_type_label
|
||||
|
||||
|
||||
def _target_type_cell(t: "TargetType", is_ally: bool) -> Gtk.Widget:
|
||||
"""Icon + name, both a FIXED size regardless of how long the name is --
|
||||
a real cell size that varies with its label text (three-line names next
|
||||
to one-line ones) makes every row in the grid a different height, which
|
||||
reads as broken/uneven rather than a grid. One line, ellipsized, with
|
||||
the full name in the button's tooltip (see build_target_type_grid)
|
||||
covers the names a single line can't fit."""
|
||||
cell = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
|
||||
margin_top=4, margin_bottom=4, margin_start=2, margin_end=2)
|
||||
cell.append(target_type_icon_image(t, is_ally=is_ally))
|
||||
name_label = Gtk.Label(label=_target_type_label(t, is_ally), wrap=False, single_line_mode=True,
|
||||
justify=Gtk.Justification.CENTER, width_chars=9,
|
||||
max_width_chars=9, ellipsize=Pango.EllipsizeMode.END)
|
||||
name_label.add_css_class("caption")
|
||||
name_label.add_css_class("dim-label")
|
||||
cell.append(name_label)
|
||||
return cell
|
||||
|
||||
|
||||
def build_target_type_grid(selected: "TargetType | None", on_pick, *, is_ally: bool = False) -> Gtk.Widget:
|
||||
"""Inline radio-style grid of every available TargetType (icon + name
|
||||
below, same idea as build_shell_grid), replacing the old plain-text
|
||||
dropdown/list. `is_ally` both picks the friendly icon set over the
|
||||
enemy one and restricts the offered types to ones with real friendly
|
||||
art (see available_target_types). Exactly one cell is ever
|
||||
highlighted (`selected`, or none if `selected` is None or not offered
|
||||
on this side).
|
||||
|
||||
`on_pick(target_type)` fires on every click, including a click on the
|
||||
already-selected cell -- deliberately listening for "clicked", not
|
||||
"toggled": a ToggleButton in a radio group doesn't emit "toggled" when
|
||||
you click the one that's already active (nothing about its state
|
||||
changed), which meant clicking the pre-selected default -- usually
|
||||
exactly the type someone wants, e.g. plain "Target" -- silently did
|
||||
nothing. "clicked" fires every time regardless, so confirming the
|
||||
default now works the same as picking anything else.
|
||||
|
||||
See _target_type_cell for why the name label is single-line and
|
||||
ellipsized rather than wrapped: an unbounded label size, besides
|
||||
making uneven-height rows, could also (being inside a homogeneous
|
||||
row) stretch every cell in that row wide enough to force the whole
|
||||
dialog into horizontal scrolling -- coord_dialog.py's ScrolledWindow
|
||||
has hscrollbar_policy=NEVER as a backstop against that same failure."""
|
||||
_ensure_icon_button_css()
|
||||
leader: Gtk.ToggleButton | None = None
|
||||
|
||||
def make_button(t: "TargetType") -> Gtk.Widget:
|
||||
nonlocal leader
|
||||
btn = Gtk.ToggleButton(child=_target_type_cell(t, is_ally))
|
||||
btn.add_css_class("flat")
|
||||
btn.add_css_class(_ICON_BUTTON_CSS_CLASS)
|
||||
btn.set_tooltip_text(_target_type_label(t, is_ally))
|
||||
if leader is None:
|
||||
leader = btn
|
||||
else:
|
||||
btn.set_group(leader)
|
||||
if t is selected:
|
||||
btn.set_active(True)
|
||||
btn.connect("clicked", lambda _b, t=t: on_pick(t))
|
||||
return btn
|
||||
|
||||
return _build_icon_grid(available_target_types(is_ally), _TYPE_GRID_COLUMNS, make_button)
|
||||
|
||||
@@ -41,6 +41,29 @@ class Proposal:
|
||||
box: tuple
|
||||
accepted: bool = False
|
||||
rejected: bool = False
|
||||
# classify_marker's own raw numbers behind `unit` (best-match score,
|
||||
# and its margin over the runner-up) -- unit alone only says whether
|
||||
# it beat min_score/min_margin, not by how much or how close a call
|
||||
# it was. Ground truth needs these to tell "confidently wrong" apart
|
||||
# from "just barely missed the bar", which `unit=None` alone can't.
|
||||
unit_score: float = 0.0
|
||||
unit_margin: float = 0.0
|
||||
# The TargetType.name actually applied when accepted -- usually just
|
||||
# `unit` translated through icons.target_type_from_icon, but can
|
||||
# differ if the user corrected it via "Accept as...". Set by
|
||||
# app.py's _accept_proposal, the only writer. Ground truth for
|
||||
# debug_capture.save_marker_ground_truth: `unit` is what the
|
||||
# classifier guessed, this is what the user actually confirmed.
|
||||
confirmed_type: str | None = None
|
||||
# The marker's own "#<N>" id label, as read off the screenshot by
|
||||
# map_vision.read_marker_id -- distinct from `label`/sub_x/sub_y
|
||||
# (the grid CELL this marker is in), this is the small per-unit id
|
||||
# the game itself draws. None when unread/unconfident (see
|
||||
# read_marker_id's own docstring: best-effort, not yet validated
|
||||
# against a real ground-truth batch). Meant for future dedup work
|
||||
# (see TODO.md) once there's confidence in the read; not otherwise
|
||||
# consumed yet.
|
||||
detected_id: str | None = None
|
||||
|
||||
@property
|
||||
def coord(self) -> str:
|
||||
@@ -59,18 +82,49 @@ 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
|
||||
# of the actual live objects, not ids/copies. Whatever's in
|
||||
# board.targets/board.allies but NOT in these sets when the screenshot
|
||||
# is later dropped was added while this screenshot was up, by
|
||||
# whatever means (an accepted proposal, a manual add, an OCR-text
|
||||
# merge run alongside it, ...) -- see app.py's _remove_screenshot,
|
||||
# which treats that as this screenshot's ground truth for
|
||||
# debug_capture.save_marker_ground_truth. Left for app.py to populate
|
||||
# rather than done here, this module stays ignorant of the Board/
|
||||
# Target/Ally types on purpose (see this file's own docstring).
|
||||
baseline_targets: set = field(default_factory=set)
|
||||
baseline_allies: set = field(default_factory=set)
|
||||
|
||||
def set_proposals(self, markers):
|
||||
self.proposals = [
|
||||
Proposal(side=m["side"], label=m["label"], sub_x=m["sub_x"],
|
||||
sub_y=m["sub_y"], unit=m.get("unit"),
|
||||
centre=m["centre"], box=m["box"]) for m in markers]
|
||||
centre=m["centre"], box=m["box"],
|
||||
detected_id=m.get("detected_id"),
|
||||
unit_score=m.get("unit_score", 0.0),
|
||||
unit_margin=m.get("unit_margin", 0.0)) for m in markers]
|
||||
return self.proposals
|
||||
|
||||
def build_overlay(self, px_per_km=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):
|
||||
@@ -145,7 +199,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")
|
||||
|
||||
@@ -154,9 +219,16 @@ class ImportJob:
|
||||
|
||||
Fills imp.proposals and delivers on_done(imp, error). Its own thread,
|
||||
because the user's grid correction sits between the two phases.
|
||||
|
||||
Marker detection itself always runs against imp.image (WORK_W,
|
||||
same as solving used); imp.full_image is passed through only for
|
||||
reading each marker's own tiny id label off a sharper source, see
|
||||
map_vision.find_markers' own id_img param.
|
||||
"""
|
||||
def work():
|
||||
imp.set_proposals(map_vision.find_markers(imp.image, imp.solution))
|
||||
id_img = imp.full_image # None is fine, find_markers falls back to imp.image
|
||||
imp.set_proposals(map_vision.find_markers(
|
||||
imp.image, imp.solution, id_img=id_img, id_scale=imp.full_image_scale))
|
||||
return imp, None
|
||||
|
||||
return self._run(work, on_done, "map-markers")
|
||||
|
||||
@@ -29,12 +29,15 @@ Shape of the solution:
|
||||
therefore selects scale, axis assignment, direction, phase and anchor
|
||||
together.
|
||||
|
||||
Measured on the 10 fixtures in tests/fixtures/map_shots: solves 7 of them,
|
||||
Measured on the 12 fixtures in tests/fixtures/map_shots: solves 9 of them,
|
||||
with 100% of each solved shot's annotated points landing in the correct cell
|
||||
(85 of 112 overall) and a residual spread of 0.005-0.033 cells. The other
|
||||
(104 of 131 overall) and a residual spread of 0.005-0.033 cells. The other
|
||||
three are rejected rather than guessed at, and no fixture has ever produced
|
||||
a plausible-but-wrong grid. Rejection is a supported outcome -- a silently
|
||||
misplaced target is far worse than a refusal.
|
||||
misplaced target is far worse than a refusal. A further 11 screenshots sit
|
||||
in tests/fixtures/map_shots/too_hard/, kept for the record but excluded from
|
||||
the evaluation set: see its README.md for why each one is a legitimate
|
||||
rejection rather than a bug.
|
||||
|
||||
Over the 122 typewriter screenshots this was checked against, solve()
|
||||
accepted none, which is what makes it safe to route clipboard images through
|
||||
@@ -79,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)
|
||||
@@ -467,6 +486,62 @@ def read_cell_label(cell_gray, glyph_fracs=(0.10, 0.13, 0.17)):
|
||||
return best
|
||||
|
||||
|
||||
# Every marker the game draws also carries a small "#<N>" id label just
|
||||
# above-left of its icon (distinct from the big per-cell grid label
|
||||
# read_cell_label reads) -- calibrated by eye against a real screenshot
|
||||
# saved under debug_captures/marker_ground_truth: it sits roughly one
|
||||
# marker-width to the left and level with the marker's own top edge.
|
||||
# Observed ids in practice are small (single or double digit); 1-99
|
||||
# covers that generously without the search space growing large.
|
||||
MARKER_ID_CANDIDATES = [f"#{n}" for n in range(1, 100)]
|
||||
MIN_MARKER_ID_SCORE = 0.55 # unmeasured starting point, see read_marker_id's own docstring
|
||||
|
||||
|
||||
def read_marker_id(gray, box, glyph_fracs=(0.30, 0.40, 0.50, 0.60)):
|
||||
"""Which '#<N>' id best explains the pixels just above-left of this
|
||||
marker? Same template-correlation approach as read_cell_label, and
|
||||
for the same reason (see this module's own docstring): this text
|
||||
sits over the same aerial-photo backdrop that defeated every
|
||||
detection-based approach tried for grid labels, so glyph correlation
|
||||
against a known-position crop is used here too rather than OCR.
|
||||
|
||||
`box` is the marker's own detected (x, y, w, h), in `gray`'s pixel
|
||||
space -- the caller is responsible for scaling it if `gray` isn't
|
||||
the same image the marker was detected in (see find_markers' own
|
||||
id_img/id_scale params, for reading against a sharper source than
|
||||
detection ran on).
|
||||
|
||||
Best-effort and NOT validated against a real ground-truth batch yet
|
||||
(unlike read_cell_label's measured 0.73-0.87 vs 0.40-0.56 -- there's
|
||||
no equivalent number here): both the crop region and
|
||||
MIN_MARKER_ID_SCORE are a single-screenshot calibration, expect this
|
||||
to need retuning once there's a real batch of debug_capture ground
|
||||
truth with confirmed ids to check against (see TODO.md). Returns
|
||||
None below the threshold rather than guessing.
|
||||
"""
|
||||
x, y, w, h = box
|
||||
left = max(0, int(x - 1.0 * w))
|
||||
top = max(0, int(y - 0.45 * h))
|
||||
right = min(gray.shape[1], int(x + 0.65 * w))
|
||||
bottom = min(gray.shape[0], int(y + 0.55 * h))
|
||||
if right - left < 6 or bottom - top < 6:
|
||||
return None
|
||||
patch = np.ascontiguousarray(gray[top:bottom, left:right])
|
||||
best = (None, -1.0)
|
||||
for gf in glyph_fracs:
|
||||
th = max(6, int(gf * h))
|
||||
for cand in MARKER_ID_CANDIDATES:
|
||||
t = glyph_template(cand, th)
|
||||
if t is None or t.shape[0] >= patch.shape[0] or t.shape[1] >= patch.shape[1]:
|
||||
continue
|
||||
sc = float(cv2.matchTemplate(patch, t, cv2.TM_CCOEFF_NORMED).max())
|
||||
if sc > best[1]:
|
||||
best = (cand, sc)
|
||||
if best[1] < MIN_MARKER_ID_SCORE:
|
||||
return None
|
||||
return best[0].lstrip("#")
|
||||
|
||||
|
||||
def visible_cells(H, shape, limit=6):
|
||||
"""Lattice cells whose centre is on screen, nearest the frame centre
|
||||
first (least perspective distortion, so the easiest to read)."""
|
||||
@@ -646,10 +721,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.
|
||||
@@ -670,6 +754,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,
|
||||
@@ -744,12 +837,29 @@ def solve_path(path):
|
||||
|
||||
_ICON_BANK: dict = {}
|
||||
ICON_SIZE = 64
|
||||
DIAMOND_IOU = 0.64 # blob-vs-ideal-diamond overlap needed to be a marker.
|
||||
MARKER_IOU = 0.64 # blob-vs-ideal-shape overlap needed to be a marker.
|
||||
# Swept against verified counts: 0.64 keeps every shot
|
||||
# confirmed correct by hand (5/2/2/3 markers) while cutting
|
||||
# ribbon+hatching false positives from 43 to 2 on the worst
|
||||
# fixture. Loosening to 0.50 regains one real marker on one
|
||||
# shot but quadruples the false positives.
|
||||
#
|
||||
# The ideal shape depends on SIDE: hostile markers are
|
||||
# diamonds, friendly ones are rectangles (see
|
||||
# `_ideal_shape`) -- the game draws the two factions with
|
||||
# different marker geometry, not just different colours.
|
||||
# Fitting only the diamond shape used to mean no cyan
|
||||
# rectangle could ever pass this gate, however good its
|
||||
# colour match.
|
||||
FILL_RANGE = { # min/max of (blob area / bbox area); the diamond and
|
||||
# rectangle marker families genuinely differ here (a diamond covers half
|
||||
# its bounding box, a solid rectangle covers nearly all of it), so one
|
||||
# shared band would either admit hatching as "diamonds" or reject real
|
||||
# rectangles as "not filled enough". Measured off real markers: diamonds
|
||||
# 0.30-0.85, rectangles (minus their border and X-crossing) 0.75-0.97.
|
||||
"diamond": (0.30, 0.85),
|
||||
"rect": (0.55, 0.97),
|
||||
}
|
||||
SYMBOL_KEEP = 0.52 # central fraction of the marker that carries the symbol
|
||||
|
||||
|
||||
@@ -852,16 +962,32 @@ def marker_masks(img):
|
||||
for m in (hostile, friendly)]
|
||||
|
||||
|
||||
def diamonds(mask, cell_px):
|
||||
def _ideal_mask(shape, w, h):
|
||||
"""The filled-in silhouette a marker of this SHAPE should have, inscribed
|
||||
in a w x h bounding box: a diamond for hostile markers, the full box
|
||||
itself for friendly ones (they are drawn as solid rectangles, so their
|
||||
own bounding box IS their ideal silhouette)."""
|
||||
if shape == "rect":
|
||||
return np.ones((h, w), bool)
|
||||
ideal = np.zeros((h, w), np.uint8)
|
||||
cv2.fillConvexPoly(ideal, np.array(
|
||||
[[w // 2, 0], [w - 1, h // 2], [w // 2, h - 1], [0, h // 2]], np.int32), 1)
|
||||
return ideal.astype(bool)
|
||||
|
||||
|
||||
def diamonds(mask, cell_px, shape="diamond"):
|
||||
"""Marker-sized, marker-shaped blobs.
|
||||
|
||||
The markers scale with the map, so a solved grid tells us how big one
|
||||
must be (~0.14 of a cell). A diamond also fills about half its bounding
|
||||
box, which rejects the long thin territory hatching and front-line
|
||||
ribbons that share the markers' colours.
|
||||
must be (~0.14 of a cell). SHAPE selects which silhouette a blob must
|
||||
match -- "diamond" for hostile markers, "rect" for friendly ones, which
|
||||
the game draws as solid rectangles rather than diamonds. Matching the
|
||||
shape (not just the size and colour) rejects the long thin territory
|
||||
hatching and front-line ribbons that share the markers' colours.
|
||||
"""
|
||||
want = 0.14 * cell_px
|
||||
lo, hi = 0.55 * want, 1.9 * want
|
||||
fill_lo, fill_hi = FILL_RANGE[shape]
|
||||
n, lab, stats, cent = cv2.connectedComponentsWithStats(mask, 8)
|
||||
out = []
|
||||
for i in range(1, n):
|
||||
@@ -870,39 +996,54 @@ def diamonds(mask, cell_px):
|
||||
continue
|
||||
if not (0.55 <= w / h <= 1.8):
|
||||
continue
|
||||
if not (0.30 <= a / float(w * h) <= 0.85):
|
||||
if not (fill_lo <= a / float(w * h) <= fill_hi):
|
||||
continue
|
||||
# Actually test for a DIAMOND. A bounding-box fill ratio near 0.5 is
|
||||
# not enough: a chunk of the territory hatching or of a front-line
|
||||
# Actually test the SHAPE. A bounding-box fill ratio alone is not
|
||||
# enough: a chunk of the territory hatching or of a front-line
|
||||
# ribbon hits the same ratio and the same colour, which is where the
|
||||
# tens of spurious markers came from. Compare the blob against an
|
||||
# ideal diamond inscribed in its own bounding box.
|
||||
# tens of spurious markers came from. Compare the blob against the
|
||||
# ideal silhouette inscribed in its own bounding box.
|
||||
blob = (lab[y:y + h, x:x + w] == i)
|
||||
ideal = np.zeros((h, w), np.uint8)
|
||||
cv2.fillConvexPoly(ideal, np.array(
|
||||
[[w // 2, 0], [w - 1, h // 2], [w // 2, h - 1], [0, h // 2]], np.int32), 1)
|
||||
ideal = ideal.astype(bool)
|
||||
ideal = _ideal_mask(shape, w, h)
|
||||
union = int(np.logical_or(blob, ideal).sum())
|
||||
if union == 0:
|
||||
continue
|
||||
if int(np.logical_and(blob, ideal).sum()) / union < DIAMOND_IOU:
|
||||
if int(np.logical_and(blob, ideal).sum()) / union < MARKER_IOU:
|
||||
continue
|
||||
out.append((float(cent[i][0]), float(cent[i][1]), (int(x), int(y), int(w), int(h))))
|
||||
return out
|
||||
|
||||
|
||||
def find_markers(img, sol):
|
||||
"""-> list of dicts: side, unit, label, sub_x, sub_y, coord, centre, box."""
|
||||
MARKER_SHAPE = {"hostile": "diamond", "friendly": "rect"}
|
||||
|
||||
|
||||
def find_markers(img, sol, id_img=None, id_scale=1.0):
|
||||
"""-> list of dicts: side, unit, label, sub_x, sub_y, coord, centre, box,
|
||||
detected_id.
|
||||
|
||||
`id_img`/`id_scale`: read each marker's small "#<N>" id label (see
|
||||
read_marker_id) against a sharper source than detection ran on --
|
||||
ScreenshotImport.full_image over the WORK_W-downscaled `img`, same
|
||||
reasoning as build_overlay's own img_scale (id text is tiny; reading
|
||||
it off the downscaled image loses too much detail). `id_scale` is
|
||||
id_img's width / img's width. Detection itself (marker
|
||||
position/shape/color, unit classification) always runs against `img`
|
||||
-- only the id read benefits from more resolution. Falls back to
|
||||
reading against `img` itself when id_img is None (still better than
|
||||
nothing, just at WORK_W's lower detail)."""
|
||||
cell = max(sol.steps)
|
||||
found = []
|
||||
id_gray = cv2.cvtColor(id_img if id_img is not None else img, cv2.COLOR_BGR2GRAY)
|
||||
for side, mask in zip(("hostile", "friendly"), marker_masks(img)):
|
||||
for (cx, cy, box) in diamonds(mask, cell):
|
||||
for (cx, cy, box) in diamonds(mask, cell, MARKER_SHAPE[side]):
|
||||
c = sol.cell_of(cx, cy)
|
||||
if c is None:
|
||||
continue
|
||||
unit, score, margin = classify_marker(img, box, side)
|
||||
id_box = box if id_scale == 1.0 else tuple(v * id_scale for v in box)
|
||||
detected_id = read_marker_id(id_gray, id_box)
|
||||
found.append(dict(side=side, unit=unit, unit_score=score,
|
||||
unit_margin=margin, label=c[0], sub_x=c[1],
|
||||
sub_y=c[2], coord=format_coord(c),
|
||||
centre=(cx, cy), box=box))
|
||||
centre=(cx, cy), box=box, detected_id=detected_id))
|
||||
return found
|
||||
|
||||
@@ -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
|
||||
@@ -44,20 +45,86 @@ class TargetType(Enum):
|
||||
collection from Target (see Board.allies), not this same type with
|
||||
a flag flipped, targets and allies don't share an id-namespace or a
|
||||
firing-relevant shape (no shell/powder_charges/assignment)."""
|
||||
# Declaration order is also picker order (icons.build_target_type_grid
|
||||
# and the old plain-text dropdown both just iterate TargetType), so
|
||||
# it's grouped by category, most-reached-for category first, and
|
||||
# alphabetical by value within a category -- not the order these were
|
||||
# added to the codebase.
|
||||
|
||||
# -- Generic / non-unit -------------------------------------------
|
||||
UNKNOWN = "Target" # generic contact, spotted but not yet identified; default choice
|
||||
SUPPLY_CACHE = "Supply Cache"
|
||||
FDC = "FDC" # Fire Direction Center, coordinates counter-battery fire
|
||||
INFANTRY = "Infantry" # ground troops
|
||||
MECHANIZED = "Mechanized" # armored/vehicle unit
|
||||
ARTILLERY = "Artillery" # "Coastal Battery" is just this, see _TYPE_WORD_ALIASES in ocr.py
|
||||
TANK = "Tank"
|
||||
PILLBOX = "Pillbox" # armoured emplacement, fixed position
|
||||
MARINE_GARRISON = "Marine Garrison" # requests fire support (see Target.requested_time)
|
||||
ENEMY = "Enemy" # ad-hoc installation named directly in the intel text
|
||||
# ("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"
|
||||
ANTI_TANK = "Anti-Tank"
|
||||
ARTILLERY = "Artillery" # "Coastal Battery" is just this, see _TYPE_WORD_ALIASES in ocr.py
|
||||
ARTILLERY_OBSERVER = "Field Artillery Observer"
|
||||
HEAVY_GUN_TURRET = "Heavy Gun Turret"
|
||||
INFANTRY = "Infantry" # ground troops
|
||||
INFANTRY_MECHANIZED = "Mechanized Infantry"
|
||||
MECH_ANTI_TANK = "Mechanized Anti-Tank" # friendly-only, no Enemy_ art (icons.py's
|
||||
# _FRIENDLY_ONLY_BASENAME), unlike ANTI_TANK which both sides draw
|
||||
MECHANIZED = "Mechanized" # armored/vehicle unit
|
||||
PILLBOX = "Pillbox" # armoured emplacement, fixed position
|
||||
TANK = "Tank"
|
||||
|
||||
# -- Command & installations ----------------------------------------
|
||||
BASE = "Base"
|
||||
COMMANDER = "Commander"
|
||||
FDC = "FDC" # Fire Direction Center, coordinates counter-battery fire
|
||||
FORT = "Fort" # friendly-only; UNDERGROUND_FORT is the enemy-side equivalent concept
|
||||
GENERAL = "General" # friendly-only, no Enemy_ art
|
||||
KING = "King" # friendly-only, no Enemy_ art
|
||||
MARINE_GARRISON = "Marine Garrison" # requests fire support (see Target.requested_time)
|
||||
POLICE = "Police" # friendly-only, no Enemy_ art
|
||||
SUPPLY_CACHE = "Supply Cache"
|
||||
UNDERGROUND_FORT = "Underground Fort"
|
||||
|
||||
# -- Medical ----------------------------------------------------------
|
||||
EMERGENCY_MEDICAL = "Emergency Medical Operation"
|
||||
HOSPITAL = "Hospital" # friendly-only; MEDICAL_FACILITY is the enemy-side equivalent concept
|
||||
MEDICAL = "Medical"
|
||||
MEDICAL_FACILITY = "Medical Treatment Facility"
|
||||
|
||||
# -- Civil --------------------------------------------------------------
|
||||
CIVIL_MILITARY = "Civil–Military" # friendly-only, no Enemy_ art
|
||||
CIVILIAN = "Civilian"
|
||||
CIVIL_RIOTING = "Civil Rioting"
|
||||
RIOTING = "Rioting"
|
||||
TV_RADIO_PROPAGANDA = "TV and Radio Propaganda"
|
||||
|
||||
# -- Naval ------------------------------------------------------------
|
||||
PORT = "Port"
|
||||
SHIP = "Ship"
|
||||
SHIP_ENGINE = "Ship Engine"
|
||||
SHIP_FDC = "Ship FDC"
|
||||
SHIP_STRIPE = "Ship (Stripe)"
|
||||
SHIP_TURRET = "Ship Turret"
|
||||
|
||||
# -- Rail -----------------------------------------------------------
|
||||
TRAIN_LOCOMOTIVE = "Train Locomotive"
|
||||
TRAIN_STATION = "Train Station"
|
||||
TRAIN_TRANSPORT = "Train Transport"
|
||||
|
||||
# -- Reconnaissance ---------------------------------------------------
|
||||
RECON = "Recon"
|
||||
RECON_LISTENING = "Recon (Listening)"
|
||||
|
||||
@property
|
||||
def short(self) -> str:
|
||||
@@ -344,6 +411,10 @@ class Target:
|
||||
# raw string as printed, this app doesn't track a game clock to compare
|
||||
# it against, it's shown as-is for the player's own reference.
|
||||
requested_time: str | None = None
|
||||
# None = not underground. 1..3 = underground, at that hardening tier
|
||||
# (see icons.UNDERGROUND_TIERS) -- higher survives more. Target-only:
|
||||
# there's no such thing as an underground Ally in this game.
|
||||
underground_tier: int | None = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -432,6 +503,39 @@ class ScoutFlight:
|
||||
return f"ScoutFlight#{self.id}"
|
||||
|
||||
|
||||
def _next_free_id(used: set[str]) -> str:
|
||||
"""Next unused id in a short, human-friendly LETTER sequence: single
|
||||
uppercase letters (A..Z) first, then two-letter combinations
|
||||
(AA..ZZ, spreadsheet-column style) once those run out, and so on.
|
||||
Scoped per TYPE, not per group: add_target()/add_ally() only look at
|
||||
existing entities of the SAME type when building `used`, so a Tank
|
||||
and an Infantry added back to back both start at 'A' (Tank#A,
|
||||
Infantry#A), each type keeping its own independent sequence.
|
||||
|
||||
Deliberately letters, not numbers: a manually-added entity (map
|
||||
right-click "Add target", or an accepted screenshot proposal with no
|
||||
confident id read) has no real game id to report, so it gets an
|
||||
obviously-not-a-real-id placeholder instead -- app.py's
|
||||
_accept_proposal reserves plain numbers for an id it's actually
|
||||
confident was read off the marker itself (map_vision.read_marker_id
|
||||
via Proposal.detected_id), passed straight through as this
|
||||
function's caller's `id_` and never touching this auto-assignment at
|
||||
all. Letters can't collide with a real (numeric) detected id either,
|
||||
on top of just reading honestly as 'made up'.
|
||||
|
||||
Can't run out the way `next(c for c in string.ascii_uppercase if c
|
||||
not in used)` used to (a real regression, see TODO.md): rolls over to
|
||||
two-letter ids ('AA', 'AB', ...) past 26 instead of raising
|
||||
StopIteration."""
|
||||
length = 1
|
||||
while True:
|
||||
for combo in itertools.product(string.ascii_uppercase, repeat=length):
|
||||
candidate = "".join(combo)
|
||||
if candidate not in used:
|
||||
return candidate
|
||||
length += 1
|
||||
|
||||
|
||||
SAVE_FORMAT_VERSION = 3
|
||||
|
||||
|
||||
@@ -502,9 +606,18 @@ class Board:
|
||||
location: Location | Coord | None = None,
|
||||
id_: str | None = None,
|
||||
) -> Target:
|
||||
# Own A/B/C... sequence per TYPE, not one shared across every
|
||||
# target regardless of type -- a Tank and an Infantry auto-
|
||||
# assigned back to back both start at 'A' (Tank#A, Infantry#A).
|
||||
# targets-vs-allies is still its own separate id namespace (see
|
||||
# add_ally); type now subdivides it further too. Letters, not
|
||||
# numbers, when auto-assigning here specifically: see
|
||||
# _next_free_id's own docstring for why (a real detected id, when
|
||||
# there is one, is passed in as `id_` and never reaches this
|
||||
# auto-assignment at all).
|
||||
if not id_:
|
||||
used = {t.id for t in self.targets if t.type == type_}
|
||||
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))
|
||||
self.targets.append(t)
|
||||
return t
|
||||
@@ -519,13 +632,14 @@ class Board:
|
||||
location: Location | Coord | None = None,
|
||||
id_: str | None = None,
|
||||
) -> Ally:
|
||||
# A separate id namespace from add_target()'s: an ally Tank#1
|
||||
# and a hostile Target Tank#1 are unrelated, so auto-assignment
|
||||
# here only looks at other allies of the same type, never
|
||||
# self.targets.
|
||||
# A separate id namespace from add_target()'s: an ally Tank#A
|
||||
# and a hostile Target Tank#A are unrelated, so auto-assignment
|
||||
# here only looks at other allies, never self.targets. Own
|
||||
# A/B/C... sequence per TYPE too, same as add_target -- see its
|
||||
# own comment and _next_free_id.
|
||||
if not id_:
|
||||
used = {a.id for a in self.allies if a.type == type_}
|
||||
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))
|
||||
self.allies.append(a)
|
||||
return a
|
||||
@@ -553,16 +667,30 @@ class Board:
|
||||
# -- reset ----------------------------------------------------------
|
||||
def clear(self) -> None:
|
||||
"""Drop everything: Nest position, spotters, reference points,
|
||||
targets, scout flights. Used by the "clear board" action for a
|
||||
fresh start without restarting the app."""
|
||||
targets, allies, scout flights. Used by the "clear board" action
|
||||
for a fresh start without restarting the app."""
|
||||
self.nest = Nest()
|
||||
self.spotters.clear()
|
||||
self.reference_points.clear()
|
||||
self.targets.clear()
|
||||
self.allies.clear()
|
||||
self.scout_flights.clear()
|
||||
self._spotter_seq = 0
|
||||
self._scout_flight_seq = 0
|
||||
|
||||
def clear_units(self) -> None:
|
||||
"""Partial reset: drop targets, allies, and scout flights, but keep
|
||||
the Nest, spotters, and reference points -- those are recon
|
||||
infrastructure the player set up deliberately and usually wants to
|
||||
keep across a round, unlike enemy/ally contacts and planned
|
||||
overflights, which go stale fast. Wired to the Clear button's
|
||||
right-click menu ("Clear enemies, units & flights") as a lighter
|
||||
alternative to clear()."""
|
||||
self.targets.clear()
|
||||
self.allies.clear()
|
||||
self.scout_flights.clear()
|
||||
self._scout_flight_seq = 0
|
||||
|
||||
def reorder_target(self, target: Target, new_index: int) -> None:
|
||||
"""Manual drag-order: `self.targets`' list order is itself the
|
||||
persisted order (saved/loaded as a plain JSON array), and is what
|
||||
@@ -660,6 +788,7 @@ class Board:
|
||||
"shell": t.shell.name if t.shell is not None else None,
|
||||
"assignment": t.assignment,
|
||||
"requested_time": t.requested_time,
|
||||
"underground_tier": t.underground_tier,
|
||||
}
|
||||
for t in self.targets
|
||||
],
|
||||
@@ -727,6 +856,7 @@ class Board:
|
||||
shell=Shell[t["shell"]] if t.get("shell") else None,
|
||||
assignment=t.get("assignment", "unassigned"),
|
||||
requested_time=t.get("requested_time"),
|
||||
underground_tier=t.get("underground_tier"),
|
||||
)
|
||||
for t in data.get("targets", [])
|
||||
]
|
||||
|
||||
@@ -197,6 +197,106 @@ def _extract_requested_time(text: str) -> str | None:
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
# A second, unrelated fire-support-request grammar, seen from Infantry
|
||||
# under attack ("taking fire") rather than a pinned Marine Garrison:
|
||||
# Infantry#1 taking fire from id1! Requesting SMK Shell on our
|
||||
# position at J6 2:7 before 10:38:57!
|
||||
# Infantry#3 taking fire! Requesting HE Shell at bearing 239°,
|
||||
# distance 10.76km from our position, J6 2:5, by 10:38:18 or we
|
||||
# will be overrun!
|
||||
# Differs from the Marine Garrison shape in every particular: the shell
|
||||
# word order is reversed ("Requesting X Shell", not "X Shells requested"),
|
||||
# the deadline has no "Requested"/dashes, just a bare "before"/"by <time>",
|
||||
# and the target position is either given directly ("on our position at
|
||||
# <coord>") or as a bearing/distance offset from that same inline
|
||||
# position (never a *named* reference -- "our position" isn't a board
|
||||
# entity, so this resolves the offset directly rather than going through
|
||||
# a Clue).
|
||||
# \s* (not \s+) between the shell code and 'Shell(s)': a rich-text paste's
|
||||
# '<b>SMK Shell</b>' span gets squashed into one no-space token 'SMKShell'
|
||||
# by squash_bold_spans() before this ever runs (same as any other
|
||||
# multi-word bold span, see _squash_span_content), same reasoning as
|
||||
# _extract_requesting_shell()'s docstring.
|
||||
_REQUESTING_SHELL_RE = re.compile(r"Requesting\s+([A-Za-z]+?)\s*Shells?\b", re.IGNORECASE)
|
||||
_TAKING_FIRE_TIME_RE = re.compile(r"\b(?:before|by)\s+(T?\d{1,2}:\d{2}:\d{2})\b", re.IGNORECASE)
|
||||
# "<Type>#<id> taking fire!" always names the REPORTING unit calling in
|
||||
# its own distress -- necessarily a friendly, no hostile ever radios in
|
||||
# about itself under attack. There's no "Friendly"/"Hostile" prefix word
|
||||
# anywhere in this grammar (see this module's own comment above) for
|
||||
# _resolve_target_type to key off of, so without this the reporting unit
|
||||
# defaults to not-ally (its own default) and gets added as an enemy.
|
||||
_TAKING_FIRE_RE = re.compile(r"\btaking fire\b", re.IGNORECASE)
|
||||
_ON_OUR_POSITION_COORD_RE = re.compile(
|
||||
rf"on\s+our\s+position\s+at\s+{_COORD_FRAGMENT}", re.IGNORECASE
|
||||
)
|
||||
_BEARING_DISTANCE_FROM_POSITION_RE = re.compile(
|
||||
rf"bearing\s*({_DIGIT_CLASS}{{1,3}})\s*°?\s*,?\s*distance\s*([\d.]+)\s*k?m\s+from\s+our\s+"
|
||||
rf"position,?\s*{_COORD_FRAGMENT}",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _extract_requesting_shell(text: str) -> Shell | None:
|
||||
"""'Requesting SMK Shell' -- the taking-fire grammar's shell mention,
|
||||
word order reversed from _extract_shell_request()'s Marine Garrison
|
||||
one ('SMK Shells requested')."""
|
||||
m = _REQUESTING_SHELL_RE.search(text)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return Shell[m.group(1).upper()]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_taking_fire_time(text: str) -> str | None:
|
||||
m = _TAKING_FIRE_TIME_RE.search(text)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _extract_on_our_position_coord(text: str) -> Coord | None:
|
||||
m = _ON_OUR_POSITION_COORD_RE.search(text)
|
||||
if not m:
|
||||
return None
|
||||
return _coord_from_groups(*m.groups())
|
||||
|
||||
|
||||
def _extract_bearing_distance_from_position_coord(text: str) -> Coord | None:
|
||||
"""The bearing/distance variant of a taking-fire request: the shell is
|
||||
wanted somewhere OFF the reporting unit's own position, given as a
|
||||
bearing/distance from it, with that position itself given inline
|
||||
right there ('...from our position, J6 2:5, by ...'). "our position"
|
||||
isn't a named board entity to hang a Clue off of, so this resolves
|
||||
the offset directly via the same polar-projection math solve_location()
|
||||
uses for an ordinary single bearing+distance clue."""
|
||||
m = _BEARING_DISTANCE_FROM_POSITION_RE.search(text)
|
||||
if not m:
|
||||
return None
|
||||
bearing, distance, letter, y, x, yy = m.groups()
|
||||
origin = _coord_from_groups(letter, y, x, yy)
|
||||
if origin is None:
|
||||
return None
|
||||
point = solver.point_from_bearing_distance(
|
||||
origin.as_fraction(), float(_fix_digits(bearing)), float(distance))
|
||||
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
|
||||
@@ -275,6 +375,17 @@ _NAMED_HEADER_RE = re.compile(rf"^{_TYPE_ID_FRAGMENT}\s*:?\s*(.*)$")
|
||||
# one), nothing else anchors this match, so an optional colon would
|
||||
# false-positive on an ordinary clue-continuation line's leading word.
|
||||
_BARE_NAME_HEADER_RE = re.compile(r"^([A-Za-z][A-Za-z0-9]*)\s*:\s*(.*)$")
|
||||
# Even requiring the colon isn't quite enough: a prose lead-in word right
|
||||
# before a genuinely useful follow-up line ("Important: TEAR Shell first,
|
||||
# then HE Shell.") false-positives the same way -- confirmed live against
|
||||
# a real taking-fire message, where "Important:" got read as a brand new
|
||||
# named entity ("Target#Important"), stealing the actual report's own
|
||||
# "Answer by <time>" deadline into that bogus entry instead of the real
|
||||
# one. None of these read as an actual thing being spotted/named, only
|
||||
# ever as a prose interjection.
|
||||
_BARE_NAME_HEADER_BLOCKLIST = {
|
||||
"important", "note", "warning", "attention", "caution", "alert", "reminder", "priority",
|
||||
}
|
||||
|
||||
# Ad-hoc enemy installations are named in plain English rather than given a
|
||||
# Type#N id ("Enemy Signal Station:", "Bearing 034 from Enemy Signal
|
||||
@@ -515,10 +626,15 @@ _BARE_CLUE_VALUE_RE = re.compile(
|
||||
|
||||
_TYPE_BY_SHORT = {t.short: t for t in TargetType}
|
||||
# The game's typewriter has used "AmmoCache" for what's now modeled as
|
||||
# SupplyCache, and "CoastalBattery" for what's just a HostileArtillery
|
||||
# under a different name, treat both as the same type rather than
|
||||
# dropping the target or inventing a redundant enum member for it.
|
||||
_TYPE_WORD_ALIASES = {"AmmoCache": "SupplyCache", "CoastalBattery": "HostileArtillery"}
|
||||
# SupplyCache, "CoastalBattery" for what's just a HostileArtillery under
|
||||
# a different name, and "Field Gun" for plain Artillery too (confirmed
|
||||
# by the user against a real "Enemy Field Gun#1 Destroyed" kill-feed line
|
||||
# that was otherwise silently dropping) -- treat all three as the same
|
||||
# type rather than dropping the target or inventing a redundant enum
|
||||
# member for each alternate name.
|
||||
_TYPE_WORD_ALIASES = {
|
||||
"AmmoCache": "SupplyCache", "CoastalBattery": "HostileArtillery", "FieldGun": "Artillery",
|
||||
}
|
||||
|
||||
|
||||
_REF_NAMED_RE = re.compile(rf"^{_TYPE_ID_FRAGMENT}")
|
||||
@@ -606,17 +722,27 @@ def parse_clues_from_text(text: str) -> list[Clue]:
|
||||
return _parse_all_clues(squash_enemy_names(squash_multiword_ids(text)))
|
||||
|
||||
|
||||
_ALLY_PREFIX_RE = re.compile(r"^(Friendly|Hostile)", re.IGNORECASE)
|
||||
# 'Enemy' is also a valid not-ally prefix ("Enemy Field Gun#1 Destroyed",
|
||||
# squashed to "EnemyFieldGun#1" by squash_multiword_ids -- confirmed live
|
||||
# by the user this was silently dropping, the FieldGun->Artillery alias
|
||||
# added below wasn't even reached because "Enemy" was never stripped off
|
||||
# first, so alias/fuzzy lookup ran against "EnemyFieldGun" as a whole,
|
||||
# not just "FieldGun"). The lookahead requires something AFTER the
|
||||
# prefix: a bare "Enemy" alone must NOT match here and fall through
|
||||
# un-stripped instead, since TargetType.ENEMY's own value IS "Enemy" --
|
||||
# stripping it there would leave an empty type_word with nothing left to
|
||||
# resolve, dropping every bare ad-hoc "Enemy#N Destroyed" report.
|
||||
_ALLY_PREFIX_RE = re.compile(r"^(Friendly|Hostile|Enemy)(?=.)", re.IGNORECASE)
|
||||
|
||||
|
||||
def _resolve_target_type(type_word: str) -> tuple[TargetType | None, bool]:
|
||||
"""(TargetType, is_ally). A leading 'Friendly'/'Hostile' word is
|
||||
stripped off the type word first ('FriendlyTank' -> ally, TANK;
|
||||
'HostileTank' or bare 'Tank' -> not ally, TANK, an explicit
|
||||
'Hostile' and no prefix at all mean the same thing, not-ally is the
|
||||
default). What's left is matched exactly against the type word
|
||||
(after aliasing), falling back to fuzzy (OCR can garble the type
|
||||
word itself, e.g. 'AmmoCoche')."""
|
||||
"""(TargetType, is_ally). A leading 'Friendly'/'Hostile'/'Enemy' word
|
||||
is stripped off the type word first ('FriendlyTank' -> ally, TANK;
|
||||
'HostileTank'/'EnemyTank'/bare 'Tank' -> not ally, TANK -- explicit
|
||||
'Hostile'/'Enemy' and no prefix at all all mean the same thing,
|
||||
not-ally is the default). What's left is matched exactly against the
|
||||
type word (after aliasing), falling back to fuzzy (OCR can garble
|
||||
the type word itself, e.g. 'AmmoCoche')."""
|
||||
is_ally = False
|
||||
prefix_m = _ALLY_PREFIX_RE.match(type_word)
|
||||
if prefix_m:
|
||||
@@ -657,16 +783,66 @@ 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_our_position_coord(joined)
|
||||
or _extract_large_grid_only_coord(joined)
|
||||
)
|
||||
current["shell"] = _extract_shell_request(joined)
|
||||
current["requested_time"] = _extract_requested_time(joined)
|
||||
shell = _extract_shell_request(joined) or _extract_requesting_shell(joined)
|
||||
requested_time = _extract_requested_time(joined) or _extract_taking_fire_time(joined)
|
||||
# See _TAKING_FIRE_RE's own comment: overrides whatever
|
||||
# _resolve_target_type would otherwise infer from type_word
|
||||
# alone (no "Friendly"/"Hostile" prefix exists in this
|
||||
# grammar to key off of) -- the reporting unit is always the
|
||||
# friendly calling this in, never the hostile.
|
||||
is_taking_fire = bool(_TAKING_FIRE_RE.search(joined))
|
||||
current["force_ally"] = is_taking_fire
|
||||
# Fire-support info (shell/deadline) always describes the FIRE
|
||||
# POINT, never the reporting/named entity itself. For the
|
||||
# bearing/distance offset variant that's a different place
|
||||
# than the entity's own position. For a plain taking-fire
|
||||
# report ("on our position") it's the SAME coord as the
|
||||
# entity's own position, but the entity itself is now an ally
|
||||
# (force_ally above), and Ally entries carry no shell/deadline
|
||||
# fields at all to hold it -- either way a taking-fire
|
||||
# report's shell+deadline moves to a synthetic StrikeRequest
|
||||
# entry below rather than staying on this one, where it would
|
||||
# either be wrong (offset case) or silently dropped (ally
|
||||
# case, once merge_all only reads (raw, clues, coord) for allies).
|
||||
split_fire_request = offset_coord is not None or is_taking_fire
|
||||
current["shell"] = None if split_fire_request else shell
|
||||
current["requested_time"] = None if split_fire_request else requested_time
|
||||
if (current["clues"] or current["coord"] is not None
|
||||
or current["shell"] is not None or current["requested_time"] is not None):
|
||||
current["raw"] = joined
|
||||
entries.append(current)
|
||||
# No offset given ("on our position") means the fire point
|
||||
# IS the entity's own position, not a separate place.
|
||||
fire_coord = offset_coord if offset_coord is not None else current["coord"]
|
||||
if split_fire_request and fire_coord is not None and (shell is not None or requested_time is not None):
|
||||
# TargetType.STRIKE_REQUEST, not STRIKE: this is a
|
||||
# friendly unit calling in a strike over the radio, not
|
||||
# one the player placed themselves (see that type's own
|
||||
# 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": fire_coord,
|
||||
"shell": shell, "requested_time": requested_time,
|
||||
})
|
||||
current = None
|
||||
|
||||
for raw_line in text.splitlines():
|
||||
@@ -743,7 +919,11 @@ def parse_intel_blocks(text: str) -> list[dict]:
|
||||
# 'Target'), not a Reference Point, a named thing giving its own
|
||||
# clues is being spotted, not a fixed landmark spotters aim off
|
||||
# of, same reasoning as the "Target is at-" calibration line.
|
||||
bare_m = next((m for c in candidates if (m := _BARE_NAME_HEADER_RE.match(c))), None)
|
||||
bare_m = next(
|
||||
(m for c in candidates if (m := _BARE_NAME_HEADER_RE.match(c))
|
||||
and m.group(1).lower() not in _BARE_NAME_HEADER_BLOCKLIST),
|
||||
None,
|
||||
)
|
||||
if bare_m:
|
||||
flush()
|
||||
name = bare_m.group(1)
|
||||
@@ -1007,6 +1187,7 @@ def parse_text(text: str) -> ParsedInfo:
|
||||
info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"])
|
||||
continue
|
||||
target_type, is_ally = _resolve_target_type(entry["type_word"])
|
||||
is_ally = is_ally or entry.get("force_ally", False)
|
||||
if target_type is None:
|
||||
continue
|
||||
if is_ally:
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 791 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 570 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
@@ -10,5 +10,14 @@ them would mean guessing:
|
||||
- `12.png` — 710x594 native. Too few pixels per cell for the label glyphs to
|
||||
correlate; measured, the best label score stays ~0.44 at every working
|
||||
resolution, so it is not a tuning problem.
|
||||
- `17.png`-`24.png` — same class as `12.png`: native resolution too low (605-1789px
|
||||
wide, vs. 1392-2400px for the fixtures that do solve) for the label glyphs to
|
||||
correlate once warped to the canonical cell size; measured, the best label
|
||||
score stays ~0.45-0.52 at every working resolution tried (including the
|
||||
2400px retry pass), well short of LABEL_ACCEPT (0.62) and inside the
|
||||
documented "wrong read" band (0.40-0.56), not close enough to call it a
|
||||
tuning problem. Two of the ten screenshots this batch came from (now
|
||||
`15.png`/`16.png` in the main set) were high enough resolution to solve --
|
||||
the same camera distance/game zoom just wasn't consistent across the batch.
|
||||
|
||||
"Too zoomed in" and "too low resolution" are legitimate hard rejections.
|
||||
|
||||
@@ -636,5 +636,104 @@
|
||||
1659
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"15.png": [
|
||||
[
|
||||
"N8",
|
||||
140,
|
||||
859
|
||||
],
|
||||
[
|
||||
"N9",
|
||||
153,
|
||||
2
|
||||
],
|
||||
[
|
||||
"O8",
|
||||
1042,
|
||||
859
|
||||
],
|
||||
[
|
||||
"P8",
|
||||
1949,
|
||||
859
|
||||
]
|
||||
],
|
||||
"16.png": [
|
||||
[
|
||||
"J5",
|
||||
62,
|
||||
573
|
||||
],
|
||||
[
|
||||
"J6",
|
||||
80,
|
||||
289
|
||||
],
|
||||
[
|
||||
"J7",
|
||||
97,
|
||||
31
|
||||
],
|
||||
[
|
||||
"K5",
|
||||
370,
|
||||
572
|
||||
],
|
||||
[
|
||||
"K6",
|
||||
374,
|
||||
289
|
||||
],
|
||||
[
|
||||
"K7",
|
||||
377,
|
||||
31
|
||||
],
|
||||
[
|
||||
"L5",
|
||||
678,
|
||||
572
|
||||
],
|
||||
[
|
||||
"L6",
|
||||
667,
|
||||
289
|
||||
],
|
||||
[
|
||||
"L7",
|
||||
657,
|
||||
31
|
||||
],
|
||||
[
|
||||
"M5",
|
||||
985,
|
||||
572
|
||||
],
|
||||
[
|
||||
"M6",
|
||||
959,
|
||||
288
|
||||
],
|
||||
[
|
||||
"M7",
|
||||
936,
|
||||
31
|
||||
],
|
||||
[
|
||||
"N5",
|
||||
1293,
|
||||
571
|
||||
],
|
||||
[
|
||||
"N6",
|
||||
1252,
|
||||
288
|
||||
],
|
||||
[
|
||||
"N7",
|
||||
1215,
|
||||
31
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"""_accept_proposal: an accepted proposal's entity id should prefer the
|
||||
marker's own detected "#<N>" id (map_vision.read_marker_id, via
|
||||
Proposal.detected_id) over an auto-assigned letter, so ids on the board
|
||||
match what's actually on screen -- falling back to auto-assign (a
|
||||
letter, deliberately not a number, so it can't collide with or be
|
||||
mistaken for a real detected id -- see models.py's _next_free_id) only
|
||||
when there's no detection, or it collides with an id already used for
|
||||
that type in that group (see _accept_proposal's own docstring).
|
||||
|
||||
Needs a real Adw/Gtk init (MainWindow.__new__ skips __init__, so no
|
||||
window/widgets are actually built, but Adw.init() is still required for
|
||||
the module import chain), same pattern proven in this repo already for
|
||||
headlessly exercising GTK-adjacent code.
|
||||
"""
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Gdk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
from gi.repository import Adw # noqa: E402
|
||||
|
||||
Adw.init()
|
||||
|
||||
from fenigma.app import MainWindow # noqa: E402
|
||||
from fenigma.map_import import Proposal # noqa: E402
|
||||
from fenigma.models import Board # noqa: E402
|
||||
|
||||
|
||||
def _window() -> MainWindow:
|
||||
win = MainWindow.__new__(MainWindow) # skip __init__: no widgets needed for this
|
||||
win.board = Board()
|
||||
return win
|
||||
|
||||
|
||||
def _proposal(detected_id=None, side="hostile", sub_x=0, sub_y=0) -> Proposal:
|
||||
return Proposal(side=side, label="G8", sub_x=sub_x, sub_y=sub_y, unit=None,
|
||||
centre=(0, 0), box=(0, 0, 0, 0), detected_id=detected_id)
|
||||
|
||||
|
||||
def test_accept_uses_the_detected_id_when_present():
|
||||
win = _window()
|
||||
win._accept_proposal(_proposal(detected_id="8"))
|
||||
assert win.board.targets[0].id == "8"
|
||||
|
||||
|
||||
def test_accept_falls_back_to_auto_id_with_no_detection():
|
||||
win = _window()
|
||||
win._accept_proposal(_proposal(detected_id=None))
|
||||
assert win.board.targets[0].id == "A"
|
||||
|
||||
|
||||
def test_accept_falls_back_to_auto_id_on_a_detected_id_collision():
|
||||
win = _window()
|
||||
win._accept_proposal(_proposal(detected_id="8", sub_x=1))
|
||||
win._accept_proposal(_proposal(detected_id="8", sub_x=2)) # same detected id, must not collide
|
||||
ids = [t.id for t in win.board.targets]
|
||||
assert ids[0] == "8"
|
||||
assert ids[1] != "8"
|
||||
|
||||
|
||||
def test_accept_keeps_target_and_ally_id_detection_in_separate_namespaces():
|
||||
"""A detected id colliding with an existing ALLY id shouldn't force a
|
||||
TARGET accept to fall back -- targets/allies are already a separate
|
||||
id namespace everywhere else (Board.add_target/add_ally), detected-id
|
||||
preference shouldn't quietly merge them."""
|
||||
win = _window()
|
||||
win._accept_proposal(_proposal(detected_id="8", side="friendly"))
|
||||
win._accept_proposal(_proposal(detected_id="8", side="hostile"))
|
||||
assert win.board.allies[0].id == "8"
|
||||
assert win.board.targets[0].id == "8"
|
||||
@@ -0,0 +1,100 @@
|
||||
"""debug_capture just needs to reliably write what it's given and never
|
||||
raise into caller code -- these are format/plumbing checks, not vision
|
||||
tests."""
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from fenigma import debug_capture
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolated_debug_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _tiny_png_bytes() -> bytes:
|
||||
import io
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (4, 4), (10, 20, 30)).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_save_map_read_failure_writes_png_and_reason(_isolated_debug_dir):
|
||||
path = debug_capture.save_map_read_failure(_tiny_png_bytes(), "too few grid line families")
|
||||
assert path is not None
|
||||
assert path.exists()
|
||||
meta = json.loads(path.with_suffix(".json").read_text())
|
||||
assert meta["reason"] == "too few grid line families"
|
||||
|
||||
|
||||
def test_save_maybe_map_accepts_numpy_bgr_array(_isolated_debug_dir):
|
||||
bgr = np.zeros((4, 4, 3), dtype=np.uint8)
|
||||
bgr[..., 0] = 200 # blue channel, would come out red if BGR/RGB got swapped
|
||||
path = debug_capture.save_maybe_map(bgr)
|
||||
assert path is not None
|
||||
saved = Image.open(path)
|
||||
assert saved.getpixel((0, 0)) == (0, 0, 200) # still blue, not swapped to red
|
||||
|
||||
|
||||
def test_save_grid_correction_writes_both_solutions(_isolated_debug_dir):
|
||||
class FakeSolution:
|
||||
def __init__(self, du):
|
||||
self.H = np.eye(3)
|
||||
self.si, self.sj, self.du, self.dv = 1, 1, du, 0
|
||||
|
||||
path = debug_capture.save_grid_correction(_tiny_png_bytes(), FakeSolution(0.0), FakeSolution(0.3))
|
||||
assert path is not None
|
||||
meta = json.loads(path.with_suffix(".json").read_text())
|
||||
assert meta["original"]["du"] == 0.0
|
||||
assert meta["corrected"]["du"] == 0.3
|
||||
|
||||
|
||||
def test_unsupported_image_type_returns_none_without_raising(_isolated_debug_dir):
|
||||
assert debug_capture.save_maybe_map(object()) is None
|
||||
|
||||
|
||||
def _proposal(**overrides):
|
||||
from fenigma.map_import import Proposal
|
||||
defaults = dict(side="hostile", label="K8", sub_x=3, sub_y=4, unit="Armor_Tank.png",
|
||||
centre=(0, 0), box=(0, 0, 0, 0))
|
||||
defaults.update(overrides)
|
||||
return Proposal(**defaults)
|
||||
|
||||
|
||||
def test_marker_ground_truth_records_verdict_per_proposal(_isolated_debug_dir):
|
||||
accepted = _proposal(accepted=True, confirmed_type="TANK")
|
||||
rejected = _proposal(label="K9", rejected=True)
|
||||
undecided = _proposal(label="L1")
|
||||
|
||||
path = debug_capture.save_marker_ground_truth(_tiny_png_bytes(), [accepted, rejected, undecided])
|
||||
assert path is not None
|
||||
meta = json.loads(path.with_suffix(".json").read_text())
|
||||
by_label = {p["label"]: p for p in meta["proposals"]}
|
||||
assert by_label["K8"]["verdict"] == "accepted"
|
||||
assert by_label["K8"]["confirmed_type"] == "TANK"
|
||||
assert by_label["K9"]["verdict"] == "rejected"
|
||||
assert by_label["L1"]["verdict"] == "undecided"
|
||||
|
||||
|
||||
def test_marker_ground_truth_records_units_with_no_matching_proposal(_isolated_debug_dir):
|
||||
from fenigma.models import Board, Coord, TargetType
|
||||
|
||||
board = Board()
|
||||
manual_target = board.add_target(TargetType.TANK, Coord("K", 8, 3, 4))
|
||||
manual_ally = board.add_ally(TargetType.INFANTRY, Coord("K", 9, 0, 0))
|
||||
|
||||
path = debug_capture.save_marker_ground_truth(
|
||||
_tiny_png_bytes(), [], added_targets=[manual_target], added_allies=[manual_ally])
|
||||
assert path is not None
|
||||
meta = json.loads(path.with_suffix(".json").read_text())
|
||||
kinds = {(u["kind"], u["type"], u["coord"]) for u in meta["added_units"]}
|
||||
assert ("target", "TANK", "K8 3:4") in kinds
|
||||
assert ("ally", "INFANTRY", "K9 0:0") in kinds
|
||||
|
||||
|
||||
def test_marker_ground_truth_skips_when_nothing_to_say(_isolated_debug_dir):
|
||||
assert debug_capture.save_marker_ground_truth(_tiny_png_bytes(), []) is None
|
||||
@@ -0,0 +1,54 @@
|
||||
"""read_marker_id: template-correlation read of a marker's own small
|
||||
"#<N>" id label (see map_vision.read_marker_id's own docstring for why
|
||||
this is template correlation, not OCR -- same reasoning as
|
||||
read_cell_label). Synthetic image, real font, no fixture screenshot or
|
||||
the (slow) detection pipeline needed -- just render the label the way
|
||||
the game does and check it round-trips.
|
||||
"""
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from fenigma import map_vision
|
||||
|
||||
|
||||
def _render_label(text: str, height: int) -> Image.Image:
|
||||
"""Cream glyph, heavy dark outline, same style glyph_template expects
|
||||
to correlate against -- see glyph_template's own docstring."""
|
||||
font = ImageFont.truetype(str(map_vision.FONT_PATH), height)
|
||||
pad = height
|
||||
im = Image.new("L", (height * 4 + pad, height * 2 + pad), 30) # dark "photo" background
|
||||
ImageDraw.Draw(im).text((pad // 2, pad // 4), text, font=font, fill=230,
|
||||
stroke_width=max(1, int(height * 0.10)), stroke_fill=0)
|
||||
return im
|
||||
|
||||
|
||||
def test_reads_a_clean_id_label():
|
||||
# A marker box roughly where a real one measures (see read_marker_id's
|
||||
# own calibration note), with a rendered "#8" sitting where the game
|
||||
# draws it: above-left of the box.
|
||||
box_w, box_h = 40, 40
|
||||
label_h = int(0.45 * box_h)
|
||||
label_im = _render_label("#8", label_h)
|
||||
|
||||
canvas = Image.new("L", (200, 200), 60)
|
||||
label_x, label_y = 60, 60
|
||||
canvas.paste(label_im, (label_x, label_y))
|
||||
gray = np.array(canvas)
|
||||
|
||||
box_x = label_x + int(1.0 * box_w) - 5 # box sits to the right of/below the label
|
||||
box_y = label_y + int(0.45 * box_h)
|
||||
box = (box_x, box_y, box_w, box_h)
|
||||
|
||||
assert map_vision.read_marker_id(gray, box) == "8"
|
||||
|
||||
|
||||
def test_returns_none_on_a_blank_patch():
|
||||
gray = np.full((200, 200), 60, dtype=np.uint8)
|
||||
box = (100, 100, 40, 40)
|
||||
assert map_vision.read_marker_id(gray, box) is None
|
||||
|
||||
|
||||
def test_returns_none_on_a_degenerate_box_at_the_image_edge():
|
||||
gray = np.full((200, 200), 60, dtype=np.uint8)
|
||||
box = (0, 0, 2, 2) # crop region collapses to nothing usable
|
||||
assert map_vision.read_marker_id(gray, box) is None
|
||||
@@ -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")
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Regression coverage for Board's bulk-mutation helpers (clear/clear_units)
|
||||
and the id namespaces Target/Ally are supposed to keep separate."""
|
||||
from fenigma.models import Board, Coord, TargetType
|
||||
|
||||
|
||||
def _coord(x=0, y=0):
|
||||
return Coord(X="A", Y=1, x=x, y=y)
|
||||
|
||||
|
||||
def test_clear_drops_allies_too():
|
||||
"""Board.clear() used to leave self.allies untouched -- the "clear
|
||||
board" action then reported success but a previously-placed ally
|
||||
stayed on the map."""
|
||||
board = Board()
|
||||
board.nest.coord = _coord()
|
||||
board.add_spotter(_coord())
|
||||
board.add_reference_point(_coord())
|
||||
board.add_target(TargetType.TANK, _coord())
|
||||
board.add_ally(TargetType.TANK, _coord())
|
||||
board.add_scout_flight((1.0, 1.0), 45.0)
|
||||
|
||||
board.clear()
|
||||
|
||||
assert board.nest.coord is None
|
||||
assert board.spotters == []
|
||||
assert board.reference_points == []
|
||||
assert board.targets == []
|
||||
assert board.allies == []
|
||||
assert board.scout_flights == []
|
||||
|
||||
|
||||
def test_clear_units_keeps_recon_infrastructure():
|
||||
"""The Clear button's right-click "Clear enemies, units & flights"
|
||||
option: drops targets/allies/scout flights but keeps the Nest,
|
||||
spotters, and reference points."""
|
||||
board = Board()
|
||||
board.nest.coord = _coord()
|
||||
sp = board.add_spotter(_coord())
|
||||
rp = board.add_reference_point(_coord())
|
||||
board.add_target(TargetType.TANK, _coord())
|
||||
board.add_ally(TargetType.TANK, _coord())
|
||||
board.add_scout_flight((1.0, 1.0), 45.0)
|
||||
|
||||
board.clear_units()
|
||||
|
||||
assert board.nest.coord is not None
|
||||
assert board.spotters == [sp]
|
||||
assert board.reference_points == [rp]
|
||||
assert board.targets == []
|
||||
assert board.allies == []
|
||||
assert board.scout_flights == []
|
||||
|
||||
|
||||
def test_ally_and_target_ids_are_independent_namespaces():
|
||||
"""An ally Tank#1 and a hostile Target Tank#1 are unrelated -- adding
|
||||
one must never be influenced by the other's ids, and auto-assignment
|
||||
on each side starts from 'A' independently. Explicit id_="1" here
|
||||
(as an accepted screenshot proposal's detected_id would pass, see
|
||||
app.py's _accept_proposal) to also check that auto-assignment
|
||||
correctly skips a real numeric id already in use, not just other
|
||||
letters."""
|
||||
board = Board()
|
||||
t1 = board.add_target(TargetType.TANK, _coord(), id_="1")
|
||||
a1 = board.add_ally(TargetType.TANK, _coord(), id_="1")
|
||||
assert t1.id == a1.id == "1"
|
||||
assert t1 is not a1
|
||||
|
||||
t_auto = board.add_target(TargetType.TANK, _coord())
|
||||
a_auto = board.add_ally(TargetType.TANK, _coord())
|
||||
assert t_auto.id == "A" # first free letter among *target* Tanks only
|
||||
assert a_auto.id == "A" # first free letter among *ally* Tanks only, unaffected by the target above
|
||||
|
||||
|
||||
def test_auto_id_is_per_type_within_targets_and_within_allies():
|
||||
"""Each TYPE gets its own independent A/B/C... sequence within a group
|
||||
(all targets, or all allies) -- a Tank and an Infantry auto-assigned
|
||||
back to back both start at 'A' (Tank#A, Infantry#A), rather than
|
||||
sharing one sequence across every type in the group."""
|
||||
board = Board()
|
||||
tank = board.add_target(TargetType.TANK, _coord())
|
||||
infantry = board.add_target(TargetType.INFANTRY, _coord())
|
||||
assert tank.id == "A"
|
||||
assert infantry.id == "A" # own sequence, not 'B' just because a Tank came first
|
||||
|
||||
second_tank = board.add_target(TargetType.TANK, _coord())
|
||||
assert second_tank.id == "B" # but a SECOND Tank does advance the Tank sequence
|
||||
|
||||
ally_tank = board.add_ally(TargetType.TANK, _coord())
|
||||
ally_infantry = board.add_ally(TargetType.INFANTRY, _coord())
|
||||
assert ally_tank.id == "A"
|
||||
assert ally_infantry.id == "A"
|
||||
|
||||
|
||||
def test_auto_id_survives_past_26_entities_of_one_type():
|
||||
"""A real crash: `next(c for c in string.ascii_uppercase if c not in
|
||||
used)` raises StopIteration the instant all 26 letters are taken --
|
||||
reachable after accepting/adding 26+ of the same type into one group
|
||||
in a single session. Must roll over to two-letter ids ('AA', 'AB',
|
||||
...) instead of raising."""
|
||||
board = Board()
|
||||
for _ in range(26):
|
||||
board.add_target(TargetType.TANK, _coord())
|
||||
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
|
||||
collision: an ally and a same-typed/same-id target are still two
|
||||
distinct objects, this only matters when something's Clue names one
|
||||
ambiguously by the shared display name."""
|
||||
board = Board()
|
||||
target = board.add_target(TargetType.TANK, _coord(x=1), id_="1")
|
||||
board.add_ally(TargetType.TANK, _coord(x=2), id_="1")
|
||||
assert board.find_by_name("Tank#1") is target
|
||||
@@ -10,6 +10,7 @@ being noticed (or not) days later.
|
||||
"""
|
||||
from fenigma import ocr
|
||||
from fenigma.models import Coord, TargetType
|
||||
from fenigma.shells import Shell
|
||||
|
||||
|
||||
def test_standard_target_and_rp_blocks():
|
||||
@@ -51,6 +52,43 @@ def test_destroyed_reports_digit_and_letter_id():
|
||||
assert info.destroyed == {(TargetType.SUPPLY_CACHE, "2"), (TargetType.TANK, "3")}
|
||||
|
||||
|
||||
def test_destroyed_report_strips_a_leading_enemy_prefix():
|
||||
"""A real kill-feed paste with an "Enemy <Type>#<id> Destroyed" shape
|
||||
(squashed by squash_multiword_ids to "EnemyMechanizedInfantry#1"
|
||||
before this ever runs) was silently dropping every single-word type
|
||||
("Enemy Infantry#11 Destroyed") -- the un-stripped "Enemy" prefix
|
||||
only accidentally fuzzy-matched for longer/more distinctive type
|
||||
words (Mechanized Infantry), not shorter/more different ones (Field
|
||||
Gun -- see test_field_gun_is_an_artillery_alias). _ALLY_PREFIX_RE now
|
||||
strips "Enemy" the same as "Hostile"."""
|
||||
text = ("Enemy Mechanized Infantry#1 Destroyed, +5 Requisition.\n"
|
||||
"Enemy Infantry#11 Destroyed, +5 Requisition.")
|
||||
info = ocr.parse_text(text)
|
||||
assert info.destroyed == {(TargetType.INFANTRY_MECHANIZED, "1"), (TargetType.INFANTRY, "11")}
|
||||
|
||||
|
||||
def test_bare_enemy_destroyed_report_is_still_target_type_enemy():
|
||||
"""The lookahead in _ALLY_PREFIX_RE (only strip "Enemy" when there's
|
||||
something AFTER it) matters here specifically: a BARE "Enemy#N" is
|
||||
TargetType.ENEMY itself (its own value IS "Enemy") -- stripping the
|
||||
prefix unconditionally would leave an empty type_word and silently
|
||||
drop every ad-hoc "Enemy#N Destroyed" report instead."""
|
||||
text = "Enemy#7 Destroyed, +5 Requisition."
|
||||
info = ocr.parse_text(text)
|
||||
assert (TargetType.ENEMY, "7") in info.destroyed
|
||||
|
||||
|
||||
def test_field_gun_is_an_artillery_alias():
|
||||
"""The game calls plain Artillery "Field Gun" in at least this kill-
|
||||
feed message -- confirmed by the user against a real "Enemy Field
|
||||
Gun#1 Destroyed" line that was otherwise silently dropping (no
|
||||
TargetType.FIELD_GUN exists, nor should one -- see _TYPE_WORD_ALIASES'
|
||||
own comment, same treatment as AmmoCache/CoastalBattery)."""
|
||||
text = "Priority target Enemy Field Gun#1 Destroyed, +50 Requisition."
|
||||
info = ocr.parse_text(text)
|
||||
assert (TargetType.ARTILLERY, "1") in info.destroyed
|
||||
|
||||
|
||||
def test_train_arrival_intel():
|
||||
text = """ARRIVAL STATION:
|
||||
Valle de Mula MainStation: J6 0:4
|
||||
@@ -236,3 +274,142 @@ Tank#3 Spotted. 095, 3.00km from Spotter#1
|
||||
assert (TargetType.TANK, "1") not in info.targets
|
||||
assert (TargetType.TANK, "2") in info.targets
|
||||
assert (TargetType.TANK, "3") in info.targets
|
||||
|
||||
|
||||
def test_infantry_taking_fire_direct_position_request():
|
||||
"""A different fire-support-request grammar from Marine Garrison's:
|
||||
shell word order reversed ('Requesting X Shell' not 'X Shells
|
||||
requested'), deadline is a bare 'before <time>' with no 'Requested'/
|
||||
dashes. The '<b>id1</b>' attacker mention is just prose here, not
|
||||
parsed into anything -- only the request itself (shell, position,
|
||||
deadline) matters.
|
||||
|
||||
The reporting unit ('Infantry#1 taking fire') is always a FRIENDLY
|
||||
calling in its own distress -- no hostile ever radios in about
|
||||
itself -- so it lands in info.allies, not info.targets (a real bug:
|
||||
it used to default to not-ally, no "Friendly"/"Hostile" prefix word
|
||||
exists in this grammar for the usual inference to key off of). The
|
||||
shell/deadline still need a home a plain Ally tuple doesn't have
|
||||
room for, so they move to a synthetic StrikeRequest target at the
|
||||
SAME coord as the reporting unit ('on our position' means the fire
|
||||
point IS that position, no offset given)."""
|
||||
text = ("Infantry#1 taking fire from <b>id1</b>!\n"
|
||||
"Requesting <u><b>SMK Shell</b></u> on our position at <b>J6 2:7</b> "
|
||||
"before <u>10:38:57</u>!")
|
||||
info = ocr.parse_text(text)
|
||||
|
||||
assert (TargetType.INFANTRY, "1") in info.allies
|
||||
assert (TargetType.INFANTRY, "1") not in info.targets
|
||||
raw, clues, coord = info.allies[(TargetType.INFANTRY, "1")]
|
||||
assert coord == Coord("J", 6, 2, 7)
|
||||
|
||||
assert (TargetType.STRIKE_REQUEST, "Infantry1") in info.targets
|
||||
raw, clues, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry1")]
|
||||
assert coord == Coord("J", 6, 2, 7)
|
||||
assert shell is Shell.SMK
|
||||
assert requested_time == "10:38:57"
|
||||
|
||||
|
||||
def test_infantry_taking_fire_no_attacker_mention():
|
||||
text = ("Infantry#3 taking fire!\n"
|
||||
"Requesting <u><b>SMK Shell</b></u> on our position at <b>J6 2:5</b> "
|
||||
"before <u>10:37:52</u>!")
|
||||
info = ocr.parse_text(text)
|
||||
|
||||
assert (TargetType.INFANTRY, "3") in info.allies
|
||||
assert (TargetType.INFANTRY, "3") not in info.targets
|
||||
raw, clues, coord = info.allies[(TargetType.INFANTRY, "3")]
|
||||
assert coord == Coord("J", 6, 2, 5)
|
||||
|
||||
assert (TargetType.STRIKE_REQUEST, "Infantry3") in info.targets
|
||||
raw, clues, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry3")]
|
||||
assert coord == Coord("J", 6, 2, 5)
|
||||
assert shell is Shell.SMK
|
||||
assert requested_time == "10:37:52"
|
||||
|
||||
|
||||
def test_infantry_taking_fire_bearing_distance_from_position():
|
||||
"""The other request shape: the shell isn't wanted right on top of the
|
||||
reporting unit, but at a bearing/distance offset from its own
|
||||
(inline-given) position -- two different places, so this becomes two
|
||||
entries: Infantry#3 stays at its own reported position, as an ALLY
|
||||
(see test_infantry_taking_fire_no_attacker_mention's own docstring --
|
||||
same reasoning, this is still a taking-fire report), and a separate
|
||||
synthetic Strike entry carries the shell/deadline at the computed
|
||||
offset coord ('our position' isn't a named board entity to hang a
|
||||
Clue off of, so this resolves straight to an absolute coord rather
|
||||
than via one)."""
|
||||
text = ("Infantry#3 taking fire!\n"
|
||||
"Requesting <u><b>HE Shell</b></u> at bearing <b>239°</b>, distance "
|
||||
"<b>10.76km</b> from our position, <b>J6 2:5</b>, by <u>10:38:18</u> "
|
||||
"or we will be overrun!")
|
||||
info = ocr.parse_text(text)
|
||||
|
||||
assert (TargetType.INFANTRY, "3") in info.allies
|
||||
assert (TargetType.INFANTRY, "3") not in info.targets
|
||||
raw, clues, coord = info.allies[(TargetType.INFANTRY, "3")]
|
||||
assert coord == Coord("J", 6, 2, 5)
|
||||
|
||||
assert (TargetType.STRIKE_REQUEST, "Infantry3") in info.targets
|
||||
raw, clues, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry3")]
|
||||
assert 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.allies
|
||||
assert (TargetType.INFANTRY, "11") not in info.targets
|
||||
_, _, coord = info.allies[(TargetType.INFANTRY, "11")]
|
||||
assert coord == Coord("I", 7, 0, 8)
|
||||
|
||||
assert (TargetType.STRIKE_REQUEST, "Infantry11") in info.targets
|
||||
_, _, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry11")]
|
||||
assert coord == Coord("H", 7, 8, 4)
|
||||
assert shell is Shell.HE
|
||||
assert requested_time == "10:17:37"
|
||||
|
||||
|
||||
def test_taking_fire_important_followup_line_does_not_steal_the_deadline():
|
||||
"""A real user-pasted message: a same-report "Important: ... Answer by
|
||||
<time>" follow-up line was being misread as a brand new named entity
|
||||
header (the last-resort bare-"<Name>:" fallback matched "Important:"
|
||||
itself), creating a bogus Target#Important that stole the actual
|
||||
report's own deadline into that wrong entry instead of the real
|
||||
StrikeRequest. "Answer by <time>" is also a deadline phrasing
|
||||
_TAKING_FIRE_TIME_RE already covers (any 'before'/'by <time>') --
|
||||
once the phantom split stops happening, it resolves correctly with
|
||||
no extra fix needed."""
|
||||
text = ("Infantry#4 taking fire!\n"
|
||||
"Requesting <u><b>TEAR Shell</b></u> first, then <u><b>HE Shell</b></u>,\n"
|
||||
"at bearing <b>308°</b>, distance <b>1.86km</b> from our position, <b>N2 0:9</b>\n"
|
||||
"<u>Important:</u> <u><b>TEAR Shell</b></u> first, then <u><b>HE Shell</b></u>.\n"
|
||||
"Answer by <u>10:30:00</u>")
|
||||
info = ocr.parse_text(text)
|
||||
|
||||
assert (TargetType.UNKNOWN, "Important") not in info.targets
|
||||
|
||||
assert (TargetType.INFANTRY, "4") in info.allies
|
||||
assert (TargetType.INFANTRY, "4") not in info.targets
|
||||
|
||||
assert (TargetType.STRIKE_REQUEST, "Infantry4") in info.targets
|
||||
_, _, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry4")]
|
||||
assert requested_time == "10:30:00"
|
||||
# Known gap, not asserted as fixed here: only the FIRST shell of a
|
||||
# "X first, then Y" sequence is captured -- see TODO.md.
|
||||
assert shell is Shell.TEAR
|
||||
|
||||