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 |
@@ -5,3 +5,8 @@ captures/*.png
|
|||||||
GameAssets
|
GameAssets
|
||||||
# tools/eval_map_vision.py renders its overlays here
|
# tools/eval_map_vision.py renders its overlays here
|
||||||
build/
|
build/
|
||||||
|
|
||||||
|
# packaging/windows/build_windows.sh's VM disk/scratch and build output
|
||||||
|
packaging/windows/storage/
|
||||||
|
packaging/windows/shared/
|
||||||
|
dist-windows/
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -14,6 +14,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import pickle
|
||||||
|
import signal
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
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 gi.repository import Adw, Gdk, Gio, GLib, Gtk # noqa: E402
|
||||||
from PIL import Image # 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 .coord_dialog import CoordDialog # noqa: E402
|
||||||
from .firing_panel import FiringPanel # noqa: E402
|
from .firing_panel import FiringPanel # noqa: E402
|
||||||
from .grid_fix_dialog import GridFixDialog # 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])
|
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:
|
def _coord_from_proposal(p) -> Coord | None:
|
||||||
"""map_vision reports "K8" plus sub-cell 0..9 in each axis, matching
|
"""map_vision reports "K8" plus sub-cell 0..9 in each axis, matching
|
||||||
Coord's own convention (see GridSolution.lattice_to_grid)."""
|
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._import_job = None # in-flight map_import.ImportJob, if any
|
||||||
self.screenshot_import = None # the map screenshot currently on the board
|
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.toast_overlay = Adw.ToastOverlay()
|
||||||
self.set_content(self.toast_overlay)
|
self.set_content(self.toast_overlay)
|
||||||
|
|
||||||
@@ -282,8 +304,13 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
header.pack_start(load_btn)
|
header.pack_start(load_btn)
|
||||||
|
|
||||||
clear_btn = Gtk.Button(icon_name="edit-clear-all-symbolic")
|
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_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)
|
header.pack_start(clear_btn)
|
||||||
|
|
||||||
self._clip_btn = Gtk.Button(icon_name="edit-paste-symbolic")
|
self._clip_btn = Gtk.Button(icon_name="edit-paste-symbolic")
|
||||||
@@ -367,6 +394,7 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
self.firing_panel = FiringPanel(
|
self.firing_panel = FiringPanel(
|
||||||
self.board,
|
self.board,
|
||||||
on_change=self._refresh,
|
on_change=self._refresh,
|
||||||
|
on_visual_change=self.canvas.refresh,
|
||||||
on_select=self._set_selection,
|
on_select=self._set_selection,
|
||||||
on_edit_position=self._edit_target_position,
|
on_edit_position=self._edit_target_position,
|
||||||
on_set_position=self._start_target_placement,
|
on_set_position=self._start_target_placement,
|
||||||
@@ -529,7 +557,17 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
except Exception as exc: # OCR/parsing hiccups shouldn't crash the app
|
except Exception as exc: # OCR/parsing hiccups shouldn't crash the app
|
||||||
self.toast(f"OCR failed: {exc}")
|
self.toast(f"OCR failed: {exc}")
|
||||||
return
|
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:
|
def _start_map_import(self, png: bytes, not_a_map) -> None:
|
||||||
"""Try to read the clipboard image as a map screenshot, off-thread.
|
"""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.
|
# one and the user wants to know why it didn't take.
|
||||||
if error != map_import.NOT_A_MAP:
|
if error != map_import.NOT_A_MAP:
|
||||||
self.toast(f"Couldn't read the grid ({error}), trying as text.")
|
self.toast(f"Couldn't read the grid ({error}), trying as text.")
|
||||||
|
debug_capture.save_map_read_failure(png, error)
|
||||||
not_a_map()
|
not_a_map()
|
||||||
return
|
return
|
||||||
self._on_map_import_ready(result)
|
self._on_map_import_ready(result)
|
||||||
@@ -594,8 +633,31 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
).present(self)
|
).present(self)
|
||||||
|
|
||||||
def _accept_grid(self, imp, solution) -> None:
|
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
|
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
|
self.screenshot_import = imp
|
||||||
imp.build_overlay()
|
imp.build_overlay()
|
||||||
self.canvas.set_screenshot(imp.overlay, imp.px_per_km)
|
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._drop_shot_btn.set_visible(imp is not None)
|
||||||
self._accept_all_btn.set_sensitive(bool(imp is not None and imp.pending()))
|
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:
|
def _accept_proposal(self, proposal, type_=None) -> None:
|
||||||
coord = _coord_from_proposal(proposal)
|
coord = _coord_from_proposal(proposal)
|
||||||
if coord is None:
|
if coord is None:
|
||||||
return
|
return
|
||||||
if type_ is None:
|
if type_ is None:
|
||||||
type_ = icons.target_type_from_icon(proposal.unit) or TargetType.UNKNOWN
|
type_ = icons.target_type_from_icon(proposal.unit) or TargetType.UNKNOWN
|
||||||
if proposal.side == "friendly":
|
is_ally = proposal.side == "friendly"
|
||||||
self.board.add_ally(type_, coord)
|
# 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:
|
else:
|
||||||
self.board.add_target(type_, coord)
|
self.board.add_target(type_, coord, id_=id_)
|
||||||
proposal.accepted = True
|
proposal.accepted = True
|
||||||
|
proposal.confirmed_type = type_.name
|
||||||
|
|
||||||
def _accept_all_proposals(self) -> None:
|
def _accept_all_proposals(self) -> None:
|
||||||
imp = self.screenshot_import
|
imp = self.screenshot_import
|
||||||
@@ -661,6 +782,26 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
self._refresh_proposals()
|
self._refresh_proposals()
|
||||||
self.toast(f"Accepted {len(pending)} unit(s).")
|
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:
|
def _remove_screenshot(self) -> None:
|
||||||
"""Dropping the screenshot also drops every proposal never accepted:
|
"""Dropping the screenshot also drops every proposal never accepted:
|
||||||
they were only ever readings OF that screenshot, so without it there is
|
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
|
imp = self.screenshot_import
|
||||||
if imp is None:
|
if imp is None:
|
||||||
return
|
return
|
||||||
|
self._capture_screenshot_ground_truth(imp)
|
||||||
dropped = len(imp.pending())
|
dropped = len(imp.pending())
|
||||||
imp.drop_unaccepted()
|
imp.drop_unaccepted()
|
||||||
self.screenshot_import = None
|
self.screenshot_import = None
|
||||||
@@ -709,9 +851,17 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
box = page()
|
box = page()
|
||||||
lbl = Gtk.Label(xalign=0, margin_start=4, margin_bottom=2)
|
lbl = Gtk.Label(xalign=0, margin_start=4, margin_bottom=2)
|
||||||
side = "friendly" if proposal.side == "friendly" else "hostile"
|
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(
|
lbl.set_markup(
|
||||||
f"<b>{GLib.markup_escape_text(proposal.coord)}</b> — {side}, "
|
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(lbl)
|
||||||
box.append(Gtk.Separator(margin_top=2, margin_bottom=2))
|
box.append(Gtk.Separator(margin_top=2, margin_bottom=2))
|
||||||
button(box, f"Accept as {detected.value if detected else TargetType.UNKNOWN.value}",
|
button(box, f"Accept as {detected.value if detected else TargetType.UNKNOWN.value}",
|
||||||
@@ -722,15 +872,37 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
|
|
||||||
def show_type():
|
def show_type():
|
||||||
box = page()
|
box = page()
|
||||||
|
# Same icon grid the entity-edit "Change type" popover uses
|
||||||
|
# (see _open_entity_menu's own show_type below), not a plain
|
||||||
|
# text list -- also gets that grid's filtering for free
|
||||||
|
# (icons.available_target_types), which a bare `for t in
|
||||||
|
# TargetType` here didn't have: STRIKE/STRIKE_REQUEST aren't
|
||||||
|
# real pickable unit types (see their own comments in
|
||||||
|
# models.py) and shouldn't have been offered as "what this
|
||||||
|
# detected marker actually is".
|
||||||
|
grid = icons.build_target_type_grid(
|
||||||
|
detected, lambda t: accept(t), is_ally=(proposal.side == "friendly"),
|
||||||
|
)
|
||||||
scroller = Gtk.ScrolledWindow(propagate_natural_height=True,
|
scroller = Gtk.ScrolledWindow(propagate_natural_height=True,
|
||||||
|
propagate_natural_width=True,
|
||||||
max_content_height=340,
|
max_content_height=340,
|
||||||
hscrollbar_policy=Gtk.PolicyType.NEVER)
|
hscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||||
inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
scroller.set_child(grid)
|
||||||
for t in TargetType:
|
|
||||||
button(inner, t.value, lambda t=t: accept(t))
|
|
||||||
scroller.set_child(inner)
|
|
||||||
box.append(scroller)
|
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)
|
popover.set_child(box)
|
||||||
|
old_popover.popdown()
|
||||||
|
popover.popup()
|
||||||
|
|
||||||
show_main()
|
show_main()
|
||||||
popover.popup()
|
popover.popup()
|
||||||
@@ -764,16 +936,80 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
if self._clipboard_watch_handler is not None:
|
if self._clipboard_watch_handler is not None:
|
||||||
Gdk.Display.get_default().get_clipboard().disconnect(self._clipboard_watch_handler)
|
Gdk.Display.get_default().get_clipboard().disconnect(self._clipboard_watch_handler)
|
||||||
self._clipboard_watch_handler = None
|
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:
|
def _clear_board(self) -> None:
|
||||||
board = self.board
|
board = self.board
|
||||||
if (board.nest.coord is None and not board.spotters and not board.reference_points
|
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
|
return # nothing to clear
|
||||||
dialog = Adw.AlertDialog(
|
dialog = Adw.AlertDialog(
|
||||||
heading="Clear board?",
|
heading="Clear board?",
|
||||||
body="Drops the Nest position and every spotter, reference point, target, and scout flight. "
|
body="Drops the Nest position and every spotter, reference point, target, ally, and scout "
|
||||||
"This can't be undone.",
|
"flight. This can't be undone.",
|
||||||
)
|
)
|
||||||
dialog.add_response("cancel", "Cancel")
|
dialog.add_response("cancel", "Cancel")
|
||||||
dialog.add_response("clear", "Clear")
|
dialog.add_response("clear", "Clear")
|
||||||
@@ -791,7 +1027,7 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
self._refresh()
|
self._refresh()
|
||||||
self.toast("Board cleared.")
|
self.toast("Board cleared.")
|
||||||
|
|
||||||
def _merge_all(self, info: "ocr.ParsedInfo") -> None:
|
def _merge_all(self, info: "ocr.ParsedInfo") -> list[str]:
|
||||||
changed = []
|
changed = []
|
||||||
if info.nest_coord is not None:
|
if info.nest_coord is not None:
|
||||||
self.board.nest.coord = info.nest_coord
|
self.board.nest.coord = info.nest_coord
|
||||||
@@ -806,6 +1042,7 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
else:
|
else:
|
||||||
self.toast("Merged from screenshot: " + ", ".join(changed))
|
self.toast("Merged from screenshot: " + ", ".join(changed))
|
||||||
self._refresh()
|
self._refresh()
|
||||||
|
return changed
|
||||||
|
|
||||||
def _merge_spotters(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]:
|
def _merge_spotters(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]:
|
||||||
changed = []
|
changed = []
|
||||||
@@ -1291,6 +1528,7 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
def _add_strike_at(self, coord, shell: Shell) -> None:
|
def _add_strike_at(self, coord, shell: Shell) -> None:
|
||||||
target = self.board.add_target(TargetType.STRIKE, coord)
|
target = self.board.add_target(TargetType.STRIKE, coord)
|
||||||
target.shell = shell
|
target.shell = shell
|
||||||
|
target.show_geo_desc = True # a strike's whole point is its blast radius; show it without needing a click
|
||||||
self.board.reorder_target(target, 0) # new strikes go to the front of the list
|
self.board.reorder_target(target, 0) # new strikes go to the front of the list
|
||||||
self._refresh()
|
self._refresh()
|
||||||
|
|
||||||
@@ -1369,17 +1607,45 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
def show_main():
|
def show_main():
|
||||||
box = page()
|
box = page()
|
||||||
where = obj.coord.label() if getattr(obj, "coord", None) else "unplaced"
|
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))
|
box.append(Gtk.Separator(margin_top=2, margin_bottom=2))
|
||||||
if hasattr(obj, "type"):
|
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:
|
if self._id_field_of(obj) is not None:
|
||||||
button(box, "Change ID", show_id)
|
button(box, "Change ID", show_id)
|
||||||
button(box, "Change position (click the map)", change_position)
|
button(box, "Change position (click the map)", change_position)
|
||||||
|
if isinstance(obj, Target):
|
||||||
|
# Alive/dead is Target-only (see models.py's Target.alive),
|
||||||
|
# same "Mark destroyed"/"Mark alive" toggle the firing
|
||||||
|
# panel's own alive button offers, just reachable from the
|
||||||
|
# map too rather than only from the sidebar.
|
||||||
|
button(box, "Mark destroyed" if obj.alive else "Mark alive", toggle_alive)
|
||||||
|
# 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):
|
if not isinstance(obj, Nest):
|
||||||
button(box, "Delete", delete, css="destructive-action")
|
button(box, "Delete", delete, css="destructive-action")
|
||||||
popover.set_child(box)
|
popover.set_child(box)
|
||||||
|
|
||||||
|
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():
|
def show_type():
|
||||||
box = page()
|
box = page()
|
||||||
heading(box, "Type")
|
heading(box, "Type")
|
||||||
@@ -1397,13 +1663,34 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
obj.type, lambda t: set_type(t), is_ally=isinstance(obj, Ally),
|
obj.type, lambda t: set_type(t), is_ally=isinstance(obj, Ally),
|
||||||
))
|
))
|
||||||
box.append(scroller)
|
box.append(scroller)
|
||||||
popover.set_child(box)
|
_reopen_with(box)
|
||||||
|
|
||||||
def set_type(t):
|
def set_type(t):
|
||||||
obj.type = t
|
obj.type = t
|
||||||
self._refresh()
|
self._refresh()
|
||||||
popover.popdown()
|
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():
|
def show_id():
|
||||||
box = page()
|
box = page()
|
||||||
@@ -1433,6 +1720,21 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
if any(s is not obj and s.id == value for s in self.board.spotters):
|
if any(s is not obj and s.id == value for s in self.board.spotters):
|
||||||
self.toast(f"Spotter#{value} already exists.")
|
self.toast(f"Spotter#{value} already exists.")
|
||||||
return
|
return
|
||||||
|
elif field == "id" and isinstance(obj, (Target, Ally)):
|
||||||
|
# Same invariant as Board.add_target/add_ally's auto-id
|
||||||
|
# (see their comments): 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:
|
else:
|
||||||
value = text
|
value = text
|
||||||
old = obj.name
|
old = obj.name
|
||||||
@@ -1450,6 +1752,17 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
lambda c: self._apply_and_refresh(obj, Location.from_coord(c)))
|
lambda c: self._apply_and_refresh(obj, Location.from_coord(c)))
|
||||||
self.toast(f"Click the map to place {obj.name}, Esc to cancel.")
|
self.toast(f"Click the map to place {obj.name}, Esc to cancel.")
|
||||||
|
|
||||||
|
def toggle_alive():
|
||||||
|
popover.popdown()
|
||||||
|
obj.alive = not obj.alive
|
||||||
|
# NOT self._refresh(): same reasoning as firing_panel.py's own
|
||||||
|
# alive toggle (see refresh_after_alive_change) -- this can
|
||||||
|
# never affect the solver or dedupe, doesn't need that full
|
||||||
|
# pipeline just because it's triggered from the map instead of
|
||||||
|
# the sidebar.
|
||||||
|
self.firing_panel.refresh_after_alive_change(obj)
|
||||||
|
self.toast(f"{_display_name(obj)} marked {'alive' if obj.alive else 'destroyed'}.")
|
||||||
|
|
||||||
def delete():
|
def delete():
|
||||||
popover.popdown()
|
popover.popdown()
|
||||||
name = obj.name
|
name = obj.name
|
||||||
@@ -1578,6 +1891,7 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
|
|
||||||
def add_strike():
|
def add_strike():
|
||||||
target = self.board.add_target(TargetType.STRIKE, coord)
|
target = self.board.add_target(TargetType.STRIKE, coord)
|
||||||
|
target.show_geo_desc = True # a strike's whole point is its blast radius; show it without needing a click
|
||||||
self.board.reorder_target(target, 0) # new strikes go to the front of the list
|
self.board.reorder_target(target, 0) # new strikes go to the front of the list
|
||||||
self._refresh()
|
self._refresh()
|
||||||
popover.popdown()
|
popover.popdown()
|
||||||
|
|||||||
@@ -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."""
|
"""Right-hand sidebar content: sort/filter toolbar + scrollable cards."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, board: Board, *, on_change, on_select, on_edit_position, on_set_position, on_remove,
|
self, board: Board, *, on_change, on_visual_change, on_select, on_edit_position, on_set_position,
|
||||||
on_toggle_hide_dead_map,
|
on_remove, on_toggle_hide_dead_map,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(orientation=Gtk.Orientation.VERTICAL)
|
super().__init__(orientation=Gtk.Orientation.VERTICAL)
|
||||||
self.board = board
|
self.board = board
|
||||||
self.on_change = on_change
|
self.on_change = on_change
|
||||||
|
# app.py's full pipeline (solver + dedupe + redraw + THIS panel's
|
||||||
|
# own full rebuild) -- for mutations that actually need it (a
|
||||||
|
# position/clue changed, a target was added/removed/reordered).
|
||||||
|
# Assignment/alive/shell changes don't: nothing about them can
|
||||||
|
# ever be produced by the solver or change dedupe's outcome, they
|
||||||
|
# just need the MAP redrawn (assignment isn't drawn there at all;
|
||||||
|
# alive dims a marker; shell can change a selected/pinned
|
||||||
|
# target's blast-radius circle). on_visual_change is that lighter
|
||||||
|
# path -- just a map redraw, no solver/dedupe/panel-rebuild -- see
|
||||||
|
# _cycle_assignment/_toggle_alive/_pick_shell, which pair it with
|
||||||
|
# _rebuild_one() for this panel's own (single-card, not
|
||||||
|
# whole-board) update. Was a real, measured lag source: every one
|
||||||
|
# of those three going through on_change() meant every single
|
||||||
|
# click rebuilt every card of every target on the board, not just
|
||||||
|
# the one that changed.
|
||||||
|
self.on_visual_change = on_visual_change
|
||||||
self.on_select = on_select
|
self.on_select = on_select
|
||||||
self.on_edit_position = on_edit_position
|
self.on_edit_position = on_edit_position
|
||||||
self.on_set_position = on_set_position
|
self.on_set_position = on_set_position
|
||||||
@@ -135,14 +151,14 @@ class FiringPanel(Gtk.Box):
|
|||||||
self._list_box.set_margin_bottom(10)
|
self._list_box.set_margin_bottom(10)
|
||||||
self._list_box.set_margin_start(10)
|
self._list_box.set_margin_start(10)
|
||||||
self._list_box.set_margin_end(10)
|
self._list_box.set_margin_end(10)
|
||||||
scroller = Gtk.ScrolledWindow(child=self._list_box, vexpand=True)
|
self._scroller = Gtk.ScrolledWindow(child=self._list_box, vexpand=True)
|
||||||
# Horizontal scrolling is never wanted here (fixed-width sidebar),
|
# Horizontal scrolling is never wanted here (fixed-width sidebar),
|
||||||
# leaving it on AUTOMATIC (the default) lets a vertical scrollbar's
|
# leaving it on AUTOMATIC (the default) lets a vertical scrollbar's
|
||||||
# own width shrink the content area enough to trigger a horizontal
|
# own width shrink the content area enough to trigger a horizontal
|
||||||
# one too, which then perturbs card heights and can trip vertical
|
# one too, which then perturbs card heights and can trip vertical
|
||||||
# scrolling that wasn't actually needed. Pin it off outright.
|
# scrolling that wasn't actually needed. Pin it off outright.
|
||||||
scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
self._scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||||||
self.append(scroller)
|
self.append(self._scroller)
|
||||||
|
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
@@ -195,6 +211,33 @@ class FiringPanel(Gtk.Box):
|
|||||||
self._restyle(self.selected, self.selected_point, _SELECTED_CSS, False)
|
self._restyle(self.selected, self.selected_point, _SELECTED_CSS, False)
|
||||||
self.selected, self.selected_point = target, point
|
self.selected, self.selected_point = target, point
|
||||||
self._restyle(self.selected, self.selected_point, _SELECTED_CSS, True)
|
self._restyle(self.selected, self.selected_point, _SELECTED_CSS, True)
|
||||||
|
if target is not None:
|
||||||
|
self._scroll_into_view(target, point)
|
||||||
|
|
||||||
|
def _scroll_into_view(self, target, point) -> None:
|
||||||
|
"""Selecting a target on the map (or cycling selection some other
|
||||||
|
way) should bring its card on-screen if the sidebar's scrolled
|
||||||
|
past it -- otherwise "selected" is invisible state the map alone
|
||||||
|
shows, and the firing panel this is FOR doesn't actually show what
|
||||||
|
got picked. A no-op if the card's already fully visible, this
|
||||||
|
only nudges the scroll position the minimum needed, never
|
||||||
|
recentres unnecessarily."""
|
||||||
|
card = next(
|
||||||
|
(c for c, p in self._cards_by_target.get(target, []) if point is None or p == point),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if card is None:
|
||||||
|
return
|
||||||
|
ok, bounds = card.compute_bounds(self._list_box)
|
||||||
|
if not ok:
|
||||||
|
return # not laid out yet (e.g. called right after a rebuild); skip rather than guess
|
||||||
|
vadj = self._scroller.get_vadjustment()
|
||||||
|
top, bottom = bounds.get_y(), bounds.get_y() + bounds.get_height()
|
||||||
|
view_top, view_bottom = vadj.get_value(), vadj.get_value() + vadj.get_page_size()
|
||||||
|
if top < view_top:
|
||||||
|
vadj.set_value(top)
|
||||||
|
elif bottom > view_bottom:
|
||||||
|
vadj.set_value(bottom - vadj.get_page_size())
|
||||||
|
|
||||||
def set_hovered(self, target, point=None) -> None:
|
def set_hovered(self, target, point=None) -> None:
|
||||||
if target is self.hovered and point == self.hovered_point:
|
if target is self.hovered and point == self.hovered_point:
|
||||||
@@ -203,6 +246,28 @@ class FiringPanel(Gtk.Box):
|
|||||||
self.hovered, self.hovered_point = target, point
|
self.hovered, self.hovered_point = target, point
|
||||||
self._restyle(self.hovered, self.hovered_point, _HOVERED_CSS, True)
|
self._restyle(self.hovered, self.hovered_point, _HOVERED_CSS, True)
|
||||||
|
|
||||||
|
def _rebuild_one(self, target: Target) -> None:
|
||||||
|
"""Rebuild just `target`'s own card(s) in place -- O(1) in the
|
||||||
|
number of OTHER targets on the board, unlike refresh() (which
|
||||||
|
tears down and rebuilds every card) -- for a mutation that only
|
||||||
|
changes this target's own display and can never add/remove a
|
||||||
|
card or move anything in the sort order (see
|
||||||
|
_cycle_assignment/_pick_shell; _toggle_alive uses this only when
|
||||||
|
that's also true for it, falling back to refresh() otherwise).
|
||||||
|
"""
|
||||||
|
old_cards = self._cards_by_target.get(target)
|
||||||
|
if not old_cards:
|
||||||
|
return # not currently shown (e.g. filtered out) -- nothing to update
|
||||||
|
new_cards = self._build_cards(target)
|
||||||
|
for (old_widget, _old_point), (new_widget, new_point) in zip(old_cards, new_cards):
|
||||||
|
self._list_box.insert_child_after(new_widget, old_widget)
|
||||||
|
self._list_box.remove(old_widget)
|
||||||
|
if target is self.selected and (self.selected_point is None or new_point == self.selected_point):
|
||||||
|
new_widget.add_css_class(_SELECTED_CSS)
|
||||||
|
if target is self.hovered and (self.hovered_point is None or new_point == self.hovered_point):
|
||||||
|
new_widget.add_css_class(_HOVERED_CSS)
|
||||||
|
self._cards_by_target[target] = new_cards
|
||||||
|
|
||||||
def _restyle(self, target, point, css_class: str, add: bool) -> None:
|
def _restyle(self, target, point, css_class: str, add: bool) -> None:
|
||||||
"""point=None means "the whole target" (every one of its cards);
|
"""point=None means "the whole target" (every one of its cards);
|
||||||
otherwise only the card for that specific ambiguous candidate,
|
otherwise only the card for that specific ambiguous candidate,
|
||||||
@@ -369,7 +434,16 @@ class FiringPanel(Gtk.Box):
|
|||||||
if dragged not in targets or drop_onto not in targets:
|
if dragged not in targets or drop_onto not in targets:
|
||||||
return
|
return
|
||||||
self.board.reorder_target(dragged, targets.index(drop_onto))
|
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:
|
def _build_unresolved_card(self, target: Target) -> Gtk.Widget:
|
||||||
card, inner = self._build_card_shell(target)
|
card, inner = self._build_card_shell(target)
|
||||||
@@ -496,14 +570,39 @@ class FiringPanel(Gtk.Box):
|
|||||||
return row
|
return row
|
||||||
|
|
||||||
def _cycle_assignment(self, target: Target) -> None:
|
def _cycle_assignment(self, target: Target) -> None:
|
||||||
|
# Assignment (L/R/unassigned) isn't drawn on the map at all, so
|
||||||
|
# this doesn't even need on_visual_change, just the card itself.
|
||||||
idx = _ASSIGNMENT_STATES.index(target.assignment)
|
idx = _ASSIGNMENT_STATES.index(target.assignment)
|
||||||
target.assignment = _ASSIGNMENT_STATES[(idx + 1) % len(_ASSIGNMENT_STATES)]
|
target.assignment = _ASSIGNMENT_STATES[(idx + 1) % len(_ASSIGNMENT_STATES)]
|
||||||
self.on_change()
|
self._rebuild_one(target)
|
||||||
|
|
||||||
def _toggle_alive(self, target: Target) -> None:
|
def _toggle_alive(self, target: Target) -> None:
|
||||||
target.alive = not target.alive
|
target.alive = not target.alive
|
||||||
self.on_change()
|
self.refresh_after_alive_change(target)
|
||||||
|
|
||||||
|
def refresh_after_alive_change(self, target: Target) -> None:
|
||||||
|
"""The display-only aftermath of target.alive flipping, split out
|
||||||
|
from _toggle_alive so app.py's map-popover "Mark destroyed"/"Mark
|
||||||
|
alive" (which flips target.alive itself, reaching this same
|
||||||
|
target) can reuse the same cheap-when-possible logic rather than
|
||||||
|
going through on_change()'s full solver+dedupe+canvas+panel pass
|
||||||
|
again -- exactly the rebuild this class exists to avoid paying
|
||||||
|
for a change that was never going to affect the solver or dedupe.
|
||||||
|
|
||||||
|
A card's presence/position can depend on alive (show_dead "hide"
|
||||||
|
drops dead cards entirely, "sort_later" moves them to their own
|
||||||
|
group at the bottom) -- only "show" guarantees this card stays
|
||||||
|
exactly where it is, just dimmed, so only that mode gets the
|
||||||
|
cheap single-card path; the other two need this panel's own full
|
||||||
|
rebuild (still far cheaper than on_change()'s, since it skips
|
||||||
|
everything but the last step)."""
|
||||||
|
if self.show_dead == "show":
|
||||||
|
self._rebuild_one(target)
|
||||||
|
else:
|
||||||
|
self.refresh()
|
||||||
|
self.on_visual_change() # dead dimming / hide_dead_from_map affects the map too
|
||||||
|
|
||||||
def _pick_shell(self, target: Target, shell: Shell) -> None:
|
def _pick_shell(self, target: Target, shell: Shell) -> None:
|
||||||
target.shell = shell
|
target.shell = shell
|
||||||
self.on_change()
|
self._rebuild_one(target)
|
||||||
|
self.on_visual_change() # a selected/pinned target's blast-radius circle depends on its shell
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from collections import namedtuple
|
|||||||
import cairo
|
import cairo
|
||||||
import gi
|
import gi
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
from PIL import Image as PILImage
|
||||||
|
|
||||||
gi.require_version("Gtk", "4.0")
|
gi.require_version("Gtk", "4.0")
|
||||||
gi.require_version("Gdk", "4.0")
|
gi.require_version("Gdk", "4.0")
|
||||||
@@ -195,6 +196,44 @@ def _icon_for(category: str, obj) -> cairo.ImageSurface | None:
|
|||||||
return 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):
|
class GridCanvas(Gtk.DrawingArea):
|
||||||
def __init__(self, board: Board) -> None:
|
def __init__(self, board: Board) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -599,8 +638,24 @@ class GridCanvas(Gtk.DrawingArea):
|
|||||||
what a proposal is until the user accepts it."""
|
what a proposal is until the user accepts it."""
|
||||||
for p, coord in self._pending_proposals():
|
for p, coord in self._pending_proposals():
|
||||||
color = CATEGORY_COLOR["ally" if p.side == "friendly" else "target"]
|
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,
|
self._draw_marker(cr, view, coord.as_fraction(), color,
|
||||||
f"? {coord.label()}", width, height,
|
label, width, height,
|
||||||
hollow=True, coord=coord)
|
hollow=True, coord=coord)
|
||||||
|
|
||||||
def _hit_test(self, view: _View, x: float, y: float):
|
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,
|
dim=(category == "target" and not obj.alive) or obj.hidden,
|
||||||
selected=(obj is self.selected), coord=obj.coord,
|
selected=(obj is self.selected), coord=obj.coord,
|
||||||
extra_line=getattr(obj, "requested_time", None),
|
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():
|
for category, obj in self.board.ambiguous_entities_all():
|
||||||
if self._excluded_from_map(obj):
|
if self._excluded_from_map(obj):
|
||||||
@@ -878,7 +934,8 @@ class GridCanvas(Gtk.DrawingArea):
|
|||||||
|
|
||||||
def _draw_marker(self, cr, view, point_km, color, label,
|
def _draw_marker(self, cr, view, point_km, color, label,
|
||||||
canvas_width, canvas_height, *, hollow=False, dim=False,
|
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)
|
x, y = self._km_to_px(view, point_km)
|
||||||
r, g, b = color
|
r, g, b = color
|
||||||
alpha = 0.45 if dim else 1.0
|
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.
|
# 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:
|
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)
|
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:
|
elif hollow:
|
||||||
cr.new_path() # cairo's arc() draws a line from any stale current
|
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
|
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.paint_with_alpha(alpha)
|
||||||
cr.restore()
|
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:
|
def _draw_firing_arrows(self, cr, view) -> None:
|
||||||
"""Red arrow(s) Nest -> Target, for whatever's hovered or selected.
|
"""Red arrow(s) Nest -> Target, for whatever's hovered or selected.
|
||||||
Points at exactly the hovered/selected candidate when one is known
|
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)
|
self._draw_arrow(cr, nx, ny, tx, ty)
|
||||||
|
|
||||||
def _draw_blast_radius(self, cr, view) -> None:
|
def _draw_blast_radius(self, cr, view) -> None:
|
||||||
"""When a Target is selected, its effective shell's blast radius,
|
"""Every Target's effective shell's blast radius, for whichever
|
||||||
selection only, not hover (unlike the geo overlays/firing arrow),
|
ones are selected or pinned via the same "always show geo"
|
||||||
per spec. Uses the specific selected candidate point if the target
|
show_geo_desc toggle the bearing/distance overlay uses (not on
|
||||||
is ambiguous; skipped entirely if there's no known point yet, or
|
plain hover, unlike that overlay -- a blast radius circle
|
||||||
the shell's blast radius isn't known."""
|
flickering in on every hover was judged too noisy, selection/
|
||||||
if not isinstance(self.selected, Target):
|
pinning is a deliberate choice). Uses the specific selected
|
||||||
return
|
candidate point if an ambiguous target is the selected one;
|
||||||
target = self.selected
|
skipped per-target if there's no known point yet, or the shell's
|
||||||
point = target.coord if target.coord is not None else self.selected_point
|
blast radius isn't known."""
|
||||||
if point is None:
|
targets = [
|
||||||
return
|
t for t in self.board.targets
|
||||||
radius_km = target.effective_shell.blast_radius_km
|
if not self._excluded_from_map(t) and (t is self.selected or t.show_geo_desc)
|
||||||
if radius_km is None:
|
]
|
||||||
return
|
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())
|
x, y = self._km_to_px(view, point.as_fraction())
|
||||||
rx, ry = view.cell_w * radius_km, view.cell_h * radius_km
|
rx, ry = view.cell_w * radius_km, view.cell_h * radius_km
|
||||||
self._draw_ellipse(cr, x, y, rx, ry)
|
self._draw_ellipse(cr, x, y, rx, ry)
|
||||||
cr.set_source_rgba(*BLAST_RADIUS, 0.18)
|
cr.set_source_rgba(*BLAST_RADIUS, 0.18)
|
||||||
cr.fill_preserve()
|
cr.fill_preserve()
|
||||||
cr.set_source_rgba(*BLAST_RADIUS, 0.85)
|
cr.set_source_rgba(*BLAST_RADIUS, 0.85)
|
||||||
cr.set_line_width(2)
|
cr.set_line_width(2)
|
||||||
cr.stroke()
|
cr.stroke()
|
||||||
|
|
||||||
def _draw_scout_flight_rect(self, cr, view, center_km, bearing_deg, *,
|
def _draw_scout_flight_rect(self, cr, view, center_km, bearing_deg, *,
|
||||||
dashed=False, alpha_mult=1.0) -> None:
|
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,
|
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
|
the only way to let the user eyeball a bad-but-close reading
|
||||||
against what it should have crossed."""
|
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 = [
|
to_show = [
|
||||||
obj for obj in candidates
|
obj for obj in candidates
|
||||||
if obj.location.clues and not self._excluded_from_map(obj)
|
if obj.location.clues and not self._excluded_from_map(obj)
|
||||||
|
|||||||
@@ -101,9 +101,9 @@ _TARGET_ICON = {
|
|||||||
TargetType.RECON: ("Recon.png", "Reconnaissance.png"), # name differs
|
TargetType.RECON: ("Recon.png", "Reconnaissance.png"), # name differs
|
||||||
TargetType.RECON_LISTENING: ("Recon_Listening.png", "Recon_Listening.png"),
|
TargetType.RECON_LISTENING: ("Recon_Listening.png", "Recon_Listening.png"),
|
||||||
}
|
}
|
||||||
assert {*_TARGET_ICON} | {TargetType.STRIKE} == {*TargetType}, (
|
assert {*_TARGET_ICON} | {TargetType.STRIKE, TargetType.STRIKE_REQUEST} == {*TargetType}, (
|
||||||
"every TargetType needs a row in _TARGET_ICON (STRIKE is the one "
|
"every TargetType needs a row in _TARGET_ICON (STRIKE/STRIKE_REQUEST "
|
||||||
"deliberate exception, see the comment above it)"
|
"are the deliberate exceptions, see the comment above target_icon_path)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -150,10 +150,11 @@ def target_icon_path(target_type: TargetType, is_ally: bool = False) -> Path | N
|
|||||||
good one. `is_ally` picks the friendly side of _TARGET_ICON over the
|
good one. `is_ally` picks the friendly side of _TARGET_ICON over the
|
||||||
enemy one, falling back to the enemy icon if this particular type has
|
enemy one, falling back to the enemy icon if this particular type has
|
||||||
no friendly art of its own at all (the two sets aren't the same size,
|
no friendly art of its own at all (the two sets aren't the same size,
|
||||||
see assets/icons/README.md). STRIKE (a planned impact point, not a
|
see assets/icons/README.md). STRIKE/STRIKE_REQUEST (a planned impact
|
||||||
unit) gets its own crosshair rather than a unit icon, it doesn't fit
|
point, not a unit -- player-placed vs called in by a friendly, see
|
||||||
the Enemy_/Friendly_ naming scheme at all."""
|
STRIKE_REQUEST's own comment) both get the same crosshair rather than
|
||||||
if target_type is TargetType.STRIKE:
|
a unit icon, neither fits the Enemy_/Friendly_ naming scheme at all."""
|
||||||
|
if target_type in (TargetType.STRIKE, TargetType.STRIKE_REQUEST):
|
||||||
return STRIKE_ICON_PATH
|
return STRIKE_ICON_PATH
|
||||||
own = _icon_for_side(target_type, is_ally)
|
own = _icon_for_side(target_type, is_ally)
|
||||||
if own is not None:
|
if own is not None:
|
||||||
@@ -448,6 +449,76 @@ def target_type_icon_image(target_type: "TargetType", is_ally: bool = False, wid
|
|||||||
return _plain_dot(is_ally, width)
|
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:
|
def _has_own_icon(t: "TargetType", is_ally: bool) -> bool:
|
||||||
"""Whether THIS side specifically has real art for t -- as opposed to
|
"""Whether THIS side specifically has real art for t -- as opposed to
|
||||||
target_icon_path() quietly handing back the other side's icon because
|
target_icon_path() quietly handing back the other side's icon because
|
||||||
@@ -467,35 +538,48 @@ def _has_own_icon(t: "TargetType", is_ally: bool) -> bool:
|
|||||||
def available_target_types(is_ally: bool = False):
|
def available_target_types(is_ally: bool = False):
|
||||||
"""TargetType members worth offering in a picker for this side.
|
"""TargetType members worth offering in a picker for this side.
|
||||||
|
|
||||||
STRIKE is never offered: it's not a unit type at all (a planned
|
STRIKE/STRIKE_REQUEST are never offered: neither is a unit type at
|
||||||
impact point, not a contact), it's always created through its own
|
all (a planned impact point, not a contact), each is always created
|
||||||
dedicated "Add strike" action (see app.py's _open_quick_add_menu),
|
through its own path instead -- STRIKE via app.py's dedicated "Add
|
||||||
never by picking a type from this generic grid -- there's no such
|
strike" action, STRIKE_REQUEST via ocr.py parsing a fire-support
|
||||||
thing as a Strike-typed Ally either, offering it there is just
|
request -- never by picking a type from this generic grid. There's
|
||||||
confusing, not merely unlikely.
|
no such thing as a Strike-typed Ally either, offering either one
|
||||||
|
here is just confusing, not merely unlikely.
|
||||||
|
|
||||||
Otherwise: each side only offers types it actually has its own art
|
Otherwise: each side only offers types it actually has its own art
|
||||||
for (see _has_own_icon / _TARGET_ICON) -- some types are enemy-only
|
for (see _has_own_icon / _TARGET_ICON) -- some types are enemy-only
|
||||||
and some are friendly-only (King, Police, a friendly hospital, ...),
|
and some are friendly-only (King, Police, a friendly hospital, ...),
|
||||||
the game simply doesn't draw an installation of every kind on both
|
the game simply doesn't draw an installation of every kind on both
|
||||||
sides."""
|
sides."""
|
||||||
return [t for t in TargetType if t is not TargetType.STRIKE and _has_own_icon(t, is_ally)]
|
return [
|
||||||
|
t for t in TargetType
|
||||||
|
if t not in (TargetType.STRIKE, TargetType.STRIKE_REQUEST) and _has_own_icon(t, is_ally)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _target_type_label(t: "TargetType", is_ally: bool) -> str:
|
def target_type_label(t: "TargetType", is_ally: bool) -> str:
|
||||||
"""Display text for a picker cell/tooltip. TargetType.ENEMY's own
|
"""Display text for a picker cell/tooltip, or any other UI spot that
|
||||||
value is literally 'Enemy' (it's the word the game's OCR'd text uses
|
would otherwise print obj.type.value directly (map popover headings,
|
||||||
for an ad-hoc *hostile* installation, see TargetType's own comment) --
|
"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,
|
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'
|
where the very same generic/ad-hoc-named-unit case reads as 'Enemy'
|
||||||
is somehow a kind of Ally. Cosmetic only: the underlying TargetType
|
is somehow a kind of Ally. Cosmetic only: the underlying TargetType
|
||||||
stored on the entity is still ENEMY either way, only the label shown
|
stored on the entity is still ENEMY either way, only the label shown
|
||||||
while picking it changes."""
|
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:
|
if is_ally and t is TargetType.ENEMY:
|
||||||
return "Ally"
|
return "Ally"
|
||||||
return t.value
|
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:
|
def _target_type_cell(t: "TargetType", is_ally: bool) -> Gtk.Widget:
|
||||||
"""Icon + name, both a FIXED size regardless of how long the name is --
|
"""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
|
a real cell size that varies with its label text (three-line names next
|
||||||
|
|||||||
@@ -41,6 +41,29 @@ class Proposal:
|
|||||||
box: tuple
|
box: tuple
|
||||||
accepted: bool = False
|
accepted: bool = False
|
||||||
rejected: 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
|
@property
|
||||||
def coord(self) -> str:
|
def coord(self) -> str:
|
||||||
@@ -59,18 +82,49 @@ class ScreenshotImport:
|
|||||||
proposals: list = field(default_factory=list)
|
proposals: list = field(default_factory=list)
|
||||||
overlay: object = None # BGRA array in map space
|
overlay: object = None # BGRA array in map space
|
||||||
px_per_km: int = 0
|
px_per_km: int = 0
|
||||||
|
# The same screenshot at full resolution, plus its width / `image`'s
|
||||||
|
# width -- `image` is downscaled to WORK_W for solving/marker-detection
|
||||||
|
# speed (see map_vision.WORK_W), which is plenty for those but throws
|
||||||
|
# away real detail the map overlay doesn't need to give up too (a
|
||||||
|
# screenshot can be up to 6880px wide, see map_vision.load_full_res's
|
||||||
|
# docstring). None/1.0 (rather than always loading it) because it's
|
||||||
|
# only needed for build_overlay(), and app.py sets it right after
|
||||||
|
# solving, before build_overlay() is ever called.
|
||||||
|
full_image: object = None
|
||||||
|
full_image_scale: float = 1.0
|
||||||
|
# Board.targets/Board.allies as they stood right when this screenshot's
|
||||||
|
# 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):
|
def set_proposals(self, markers):
|
||||||
self.proposals = [
|
self.proposals = [
|
||||||
Proposal(side=m["side"], label=m["label"], sub_x=m["sub_x"],
|
Proposal(side=m["side"], label=m["label"], sub_x=m["sub_x"],
|
||||||
sub_y=m["sub_y"], unit=m.get("unit"),
|
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
|
return self.proposals
|
||||||
|
|
||||||
def build_overlay(self, px_per_km=100):
|
def build_overlay(self, px_per_km=150):
|
||||||
"""Rectify the screenshot into map space, ready to draw under the grid."""
|
"""Rectify the screenshot into map space, ready to draw under the grid.
|
||||||
|
Uses full_image (full resolution) over image (WORK_W-downscaled) when
|
||||||
|
available, see full_image's own docstring."""
|
||||||
|
src, scale = (self.full_image, self.full_image_scale) if self.full_image is not None \
|
||||||
|
else (self.image, 1.0)
|
||||||
self.overlay, self.px_per_km = map_vision.warp_to_map(
|
self.overlay, self.px_per_km = map_vision.warp_to_map(
|
||||||
self.image, self.solution, px_per_km=px_per_km)
|
src, self.solution, px_per_km=px_per_km, img_scale=scale)
|
||||||
return self.overlay
|
return self.overlay
|
||||||
|
|
||||||
def accept_all(self):
|
def accept_all(self):
|
||||||
@@ -145,7 +199,18 @@ class ImportJob:
|
|||||||
sol, img, err = map_vision.solve_path(path)
|
sol, img, err = map_vision.solve_path(path)
|
||||||
if sol is None:
|
if sol is None:
|
||||||
return None, err
|
return None, err
|
||||||
return ScreenshotImport(solution=sol, image=img), None
|
imp = ScreenshotImport(solution=sol, image=img)
|
||||||
|
# Best-effort: a sharper source for build_overlay() than the
|
||||||
|
# WORK_W-downscaled `img` solving used (see full_image's own
|
||||||
|
# docstring). Anything going wrong here just means the overlay
|
||||||
|
# falls back to `img`, not worth failing the whole import over.
|
||||||
|
try:
|
||||||
|
full = map_vision.load_full_res(path)
|
||||||
|
imp.full_image = full
|
||||||
|
imp.full_image_scale = full.shape[1] / img.shape[1]
|
||||||
|
except (ValueError, ZeroDivisionError, OSError):
|
||||||
|
pass
|
||||||
|
return imp, None
|
||||||
|
|
||||||
return self._run(work, on_done, "map-import")
|
return self._run(work, on_done, "map-import")
|
||||||
|
|
||||||
@@ -154,9 +219,16 @@ class ImportJob:
|
|||||||
|
|
||||||
Fills imp.proposals and delivers on_done(imp, error). Its own thread,
|
Fills imp.proposals and delivers on_done(imp, error). Its own thread,
|
||||||
because the user's grid correction sits between the two phases.
|
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():
|
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 imp, None
|
||||||
|
|
||||||
return self._run(work, on_done, "map-markers")
|
return self._run(work, on_done, "map-markers")
|
||||||
|
|||||||
@@ -82,6 +82,22 @@ def load(path, work_w=None) -> np.ndarray:
|
|||||||
return downscale(img, work_w)
|
return downscale(img, work_w)
|
||||||
|
|
||||||
|
|
||||||
|
def load_full_res(path) -> np.ndarray:
|
||||||
|
"""Same read as load(), but never downscaled -- solving and marker
|
||||||
|
detection deliberately work at WORK_W (a screenshot's real resolution
|
||||||
|
only matters up to what a grid label needs to stay legible, see
|
||||||
|
solve_path's own docstring), but that same downscaled image is a poor
|
||||||
|
source for the map overlay the app draws the screenshot as: a
|
||||||
|
screenshot wider than WORK_W (the docstring above notes these run
|
||||||
|
700..6880px) was throwing away real detail there for no benefit. See
|
||||||
|
warp_to_map's img_scale param, which is how a caller tells it "this
|
||||||
|
image isn't the one `sol` was solved against, here's the size ratio"."""
|
||||||
|
img = cv2.imread(str(path), cv2.IMREAD_COLOR)
|
||||||
|
if img is None:
|
||||||
|
raise ValueError(f"cannot read image: {path}")
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
def downscale(img, work_w=None) -> np.ndarray:
|
def downscale(img, work_w=None) -> np.ndarray:
|
||||||
h, w = img.shape[:2]
|
h, w = img.shape[:2]
|
||||||
s = min(1.0, (work_w or WORK_W) / w)
|
s = min(1.0, (work_w or WORK_W) / w)
|
||||||
@@ -470,6 +486,62 @@ def read_cell_label(cell_gray, glyph_fracs=(0.10, 0.13, 0.17)):
|
|||||||
return best
|
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):
|
def visible_cells(H, shape, limit=6):
|
||||||
"""Lattice cells whose centre is on screen, nearest the frame centre
|
"""Lattice cells whose centre is on screen, nearest the frame centre
|
||||||
first (least perspective distortion, so the easiest to read)."""
|
first (least perspective distortion, so the easiest to read)."""
|
||||||
@@ -649,10 +721,19 @@ def centre_cell_quad(sol, shape):
|
|||||||
MAP_KM_W, MAP_KM_H = 20.0, 10.0
|
MAP_KM_W, MAP_KM_H = 20.0, 10.0
|
||||||
|
|
||||||
|
|
||||||
def warp_to_map(img, sol, px_per_km=100):
|
def warp_to_map(img, sol, px_per_km=150, img_scale=1.0):
|
||||||
"""Rectify a screenshot into map space, ready to composite under the app's
|
"""Rectify a screenshot into map space, ready to composite under the app's
|
||||||
own grid.
|
own grid.
|
||||||
|
|
||||||
|
`img` need not be the exact image `sol` was solved against (usually a
|
||||||
|
WORK_W-downscaled one, see solve_path) -- pass the original full-
|
||||||
|
resolution screenshot instead (see load_full_res) for a sharper overlay,
|
||||||
|
with `img_scale` set to img's width / the solved image's width, so this
|
||||||
|
can still map `sol`'s coordinates (which are in the SOLVED image's pixel
|
||||||
|
space) onto `img`'s actual pixels. img_scale=1.0 (the default) means
|
||||||
|
`img` IS the image `sol` was solved against, same as before this param
|
||||||
|
existed.
|
||||||
|
|
||||||
Returns (BGRA array, px_per_km). Only the region the screenshot actually
|
Returns (BGRA array, px_per_km). Only the region the screenshot actually
|
||||||
covers is opaque; everything else is transparent, so a partial view of the
|
covers is opaque; everything else is transparent, so a partial view of the
|
||||||
table does not blank out the rest of the map.
|
table does not blank out the rest of the map.
|
||||||
@@ -673,6 +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
|
# du, dv) is what pins those to named cells, and leaving it out put the
|
||||||
# screenshot in the wrong place for every automatically solved grid.
|
# screenshot in the wrong place for every automatically solved grid.
|
||||||
M = grid_to_map @ sol.lattice_to_grid() @ np.linalg.inv(sol.H)
|
M = grid_to_map @ sol.lattice_to_grid() @ np.linalg.inv(sol.H)
|
||||||
|
if img_scale != 1.0:
|
||||||
|
# img's pixels are img_scale times bigger than what M expects
|
||||||
|
# (the solved image's pixel space) -- shrink img-space coordinates
|
||||||
|
# down to that space first, applied first since matrices compose
|
||||||
|
# right-to-left.
|
||||||
|
to_solved_px = np.array([[1.0 / img_scale, 0.0, 0.0],
|
||||||
|
[0.0, 1.0 / img_scale, 0.0],
|
||||||
|
[0.0, 0.0, 1.0]])
|
||||||
|
M = M @ to_solved_px
|
||||||
bgra = cv2.cvtColor(img, cv2.COLOR_BGR2BGRA)
|
bgra = cv2.cvtColor(img, cv2.COLOR_BGR2BGRA)
|
||||||
bgra[:, :, 3] = 255
|
bgra[:, :, 3] = 255
|
||||||
return cv2.warpPerspective(bgra, M, (out_w, out_h), flags=cv2.INTER_LINEAR,
|
return cv2.warpPerspective(bgra, M, (out_w, out_h), flags=cv2.INTER_LINEAR,
|
||||||
@@ -927,18 +1017,33 @@ def diamonds(mask, cell_px, shape="diamond"):
|
|||||||
MARKER_SHAPE = {"hostile": "diamond", "friendly": "rect"}
|
MARKER_SHAPE = {"hostile": "diamond", "friendly": "rect"}
|
||||||
|
|
||||||
|
|
||||||
def find_markers(img, sol):
|
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."""
|
"""-> 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)
|
cell = max(sol.steps)
|
||||||
found = []
|
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 side, mask in zip(("hostile", "friendly"), marker_masks(img)):
|
||||||
for (cx, cy, box) in diamonds(mask, cell, MARKER_SHAPE[side]):
|
for (cx, cy, box) in diamonds(mask, cell, MARKER_SHAPE[side]):
|
||||||
c = sol.cell_of(cx, cy)
|
c = sol.cell_of(cx, cy)
|
||||||
if c is None:
|
if c is None:
|
||||||
continue
|
continue
|
||||||
unit, score, margin = classify_marker(img, box, side)
|
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,
|
found.append(dict(side=side, unit=unit, unit_score=score,
|
||||||
unit_margin=margin, label=c[0], sub_x=c[1],
|
unit_margin=margin, label=c[0], sub_x=c[1],
|
||||||
sub_y=c[2], coord=format_coord(c),
|
sub_y=c[2], coord=format_coord(c),
|
||||||
centre=(cx, cy), box=box))
|
centre=(cx, cy), box=box, detected_id=detected_id))
|
||||||
return found
|
return found
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Coord) to work out everything else. This module just defines the shape.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import itertools
|
||||||
import string
|
import string
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
@@ -56,7 +57,18 @@ class TargetType(Enum):
|
|||||||
# ("Enemy Signal Station", "Enemy Field Command"), not one of the
|
# ("Enemy Signal Station", "Enemy Field Command"), not one of the
|
||||||
# game's fixed unit types, its id is the rest of that name with
|
# game's fixed unit types, its id is the rest of that name with
|
||||||
# spaces stripped, see ocr.py's squash_enemy_names()
|
# spaces stripped, see ocr.py's squash_enemy_names()
|
||||||
STRIKE = "Strike" # a planned impact point, not an enemy contact
|
STRIKE = "Strike" # a planned impact point, not an enemy contact --
|
||||||
|
# player-placed only (app.py's dedicated "Add Strike" flow / map
|
||||||
|
# right-click), never produced by OCR.
|
||||||
|
STRIKE_REQUEST = "Strike Request" # a planned impact point a friendly
|
||||||
|
# unit is calling in over the radio (ocr.py's "taking fire" fire-
|
||||||
|
# support-request grammar, when it names a bearing/distance offset
|
||||||
|
# from the reporter rather than the reporter's own position), as
|
||||||
|
# opposed to STRIKE, which the player places themselves. Same
|
||||||
|
# "not an enemy contact, just an impact point" shape as STRIKE
|
||||||
|
# (dedupe_generic_targets/icons.py both treat the two the same way),
|
||||||
|
# kept as its own type rather than reusing STRIKE so a request that
|
||||||
|
# came in over the radio is never confused for one the player chose.
|
||||||
|
|
||||||
# -- Ground combat units -------------------------------------------
|
# -- Ground combat units -------------------------------------------
|
||||||
ANTI_AIR = "Anti-Air"
|
ANTI_AIR = "Anti-Air"
|
||||||
@@ -399,6 +411,10 @@ class Target:
|
|||||||
# raw string as printed, this app doesn't track a game clock to compare
|
# 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.
|
# it against, it's shown as-is for the player's own reference.
|
||||||
requested_time: str | None = None
|
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
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
@@ -487,6 +503,39 @@ class ScoutFlight:
|
|||||||
return f"ScoutFlight#{self.id}"
|
return f"ScoutFlight#{self.id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _next_free_id(used: set[str]) -> str:
|
||||||
|
"""Next unused id in a short, human-friendly 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
|
SAVE_FORMAT_VERSION = 3
|
||||||
|
|
||||||
|
|
||||||
@@ -557,9 +606,18 @@ class Board:
|
|||||||
location: Location | Coord | None = None,
|
location: Location | Coord | None = None,
|
||||||
id_: str | None = None,
|
id_: str | None = None,
|
||||||
) -> Target:
|
) -> 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_:
|
if not id_:
|
||||||
used = {t.id for t in self.targets if t.type == type_}
|
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))
|
t = Target(type=type_, id=id_, location=_as_location(location))
|
||||||
self.targets.append(t)
|
self.targets.append(t)
|
||||||
return t
|
return t
|
||||||
@@ -574,13 +632,14 @@ class Board:
|
|||||||
location: Location | Coord | None = None,
|
location: Location | Coord | None = None,
|
||||||
id_: str | None = None,
|
id_: str | None = None,
|
||||||
) -> Ally:
|
) -> Ally:
|
||||||
# A separate id namespace from add_target()'s: an ally Tank#1
|
# A separate id namespace from add_target()'s: an ally Tank#A
|
||||||
# and a hostile Target Tank#1 are unrelated, so auto-assignment
|
# and a hostile Target Tank#A are unrelated, so auto-assignment
|
||||||
# here only looks at other allies of the same type, never
|
# here only looks at other allies, never self.targets. Own
|
||||||
# self.targets.
|
# A/B/C... sequence per TYPE too, same as add_target -- see its
|
||||||
|
# own comment and _next_free_id.
|
||||||
if not id_:
|
if not id_:
|
||||||
used = {a.id for a in self.allies if a.type == type_}
|
used = {a.id for a in self.allies 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))
|
a = Ally(type=type_, id=id_, location=_as_location(location))
|
||||||
self.allies.append(a)
|
self.allies.append(a)
|
||||||
return a
|
return a
|
||||||
@@ -608,16 +667,30 @@ class Board:
|
|||||||
# -- reset ----------------------------------------------------------
|
# -- reset ----------------------------------------------------------
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
"""Drop everything: Nest position, spotters, reference points,
|
"""Drop everything: Nest position, spotters, reference points,
|
||||||
targets, scout flights. Used by the "clear board" action for a
|
targets, allies, scout flights. Used by the "clear board" action
|
||||||
fresh start without restarting the app."""
|
for a fresh start without restarting the app."""
|
||||||
self.nest = Nest()
|
self.nest = Nest()
|
||||||
self.spotters.clear()
|
self.spotters.clear()
|
||||||
self.reference_points.clear()
|
self.reference_points.clear()
|
||||||
self.targets.clear()
|
self.targets.clear()
|
||||||
|
self.allies.clear()
|
||||||
self.scout_flights.clear()
|
self.scout_flights.clear()
|
||||||
self._spotter_seq = 0
|
self._spotter_seq = 0
|
||||||
self._scout_flight_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:
|
def reorder_target(self, target: Target, new_index: int) -> None:
|
||||||
"""Manual drag-order: `self.targets`' list order is itself the
|
"""Manual drag-order: `self.targets`' list order is itself the
|
||||||
persisted order (saved/loaded as a plain JSON array), and is what
|
persisted order (saved/loaded as a plain JSON array), and is what
|
||||||
@@ -715,6 +788,7 @@ class Board:
|
|||||||
"shell": t.shell.name if t.shell is not None else None,
|
"shell": t.shell.name if t.shell is not None else None,
|
||||||
"assignment": t.assignment,
|
"assignment": t.assignment,
|
||||||
"requested_time": t.requested_time,
|
"requested_time": t.requested_time,
|
||||||
|
"underground_tier": t.underground_tier,
|
||||||
}
|
}
|
||||||
for t in self.targets
|
for t in self.targets
|
||||||
],
|
],
|
||||||
@@ -782,6 +856,7 @@ class Board:
|
|||||||
shell=Shell[t["shell"]] if t.get("shell") else None,
|
shell=Shell[t["shell"]] if t.get("shell") else None,
|
||||||
assignment=t.get("assignment", "unassigned"),
|
assignment=t.get("assignment", "unassigned"),
|
||||||
requested_time=t.get("requested_time"),
|
requested_time=t.get("requested_time"),
|
||||||
|
underground_tier=t.get("underground_tier"),
|
||||||
)
|
)
|
||||||
for t in data.get("targets", [])
|
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
|
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
|
# "Reported active in grid D10": only the large-grid cell, no sub-grid
|
||||||
# x:y at all, unlike every other coord shape in this file. Tried last
|
# x:y at all, unlike every other coord shape in this file. Tried last
|
||||||
# (after _extract_grid_coord, which requires the full x:y and so is
|
# (after _extract_grid_coord, which requires the full x:y and so is
|
||||||
@@ -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
|
# one), nothing else anchors this match, so an optional colon would
|
||||||
# false-positive on an ordinary clue-continuation line's leading word.
|
# 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*(.*)$")
|
_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
|
# Ad-hoc enemy installations are named in plain English rather than given a
|
||||||
# Type#N id ("Enemy Signal Station:", "Bearing 034 from Enemy Signal
|
# 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}
|
_TYPE_BY_SHORT = {t.short: t for t in TargetType}
|
||||||
# The game's typewriter has used "AmmoCache" for what's now modeled as
|
# The game's typewriter has used "AmmoCache" for what's now modeled as
|
||||||
# SupplyCache, and "CoastalBattery" for what's just a HostileArtillery
|
# SupplyCache, "CoastalBattery" for what's just a HostileArtillery under
|
||||||
# under a different name, treat both as the same type rather than
|
# a different name, and "Field Gun" for plain Artillery too (confirmed
|
||||||
# dropping the target or inventing a redundant enum member for it.
|
# by the user against a real "Enemy Field Gun#1 Destroyed" kill-feed line
|
||||||
_TYPE_WORD_ALIASES = {"AmmoCache": "SupplyCache", "CoastalBattery": "HostileArtillery"}
|
# 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}")
|
_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)))
|
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]:
|
def _resolve_target_type(type_word: str) -> tuple[TargetType | None, bool]:
|
||||||
"""(TargetType, is_ally). A leading 'Friendly'/'Hostile' word is
|
"""(TargetType, is_ally). A leading 'Friendly'/'Hostile'/'Enemy' word
|
||||||
stripped off the type word first ('FriendlyTank' -> ally, TANK;
|
is stripped off the type word first ('FriendlyTank' -> ally, TANK;
|
||||||
'HostileTank' or bare 'Tank' -> not ally, TANK, an explicit
|
'HostileTank'/'EnemyTank'/bare 'Tank' -> not ally, TANK -- explicit
|
||||||
'Hostile' and no prefix at all mean the same thing, not-ally is the
|
'Hostile'/'Enemy' and no prefix at all all mean the same thing,
|
||||||
default). What's left is matched exactly against the type word
|
not-ally is the default). What's left is matched exactly against the
|
||||||
(after aliasing), falling back to fuzzy (OCR can garble the type
|
type word (after aliasing), falling back to fuzzy (OCR can garble
|
||||||
word itself, e.g. 'AmmoCoche')."""
|
the type word itself, e.g. 'AmmoCoche')."""
|
||||||
is_ally = False
|
is_ally = False
|
||||||
prefix_m = _ALLY_PREFIX_RE.match(type_word)
|
prefix_m = _ALLY_PREFIX_RE.match(type_word)
|
||||||
if prefix_m:
|
if prefix_m:
|
||||||
@@ -657,16 +783,66 @@ def parse_intel_blocks(text: str) -> list[dict]:
|
|||||||
if current is not None:
|
if current is not None:
|
||||||
joined = "\n".join(current["raw"])
|
joined = "\n".join(current["raw"])
|
||||||
current["clues"] = _parse_all_clues(joined)
|
current["clues"] = _parse_all_clues(joined)
|
||||||
|
# The bearing/distance taking-fire variant names TWO different
|
||||||
|
# places (see _extract_our_position_coord's docstring): the
|
||||||
|
# reporting unit's own position, and a separate fire point
|
||||||
|
# offset from it. Everything else in this module is "one block
|
||||||
|
# -> one entry", so that offset gets split into a second,
|
||||||
|
# synthetic StrikeRequest entry below rather than folded into
|
||||||
|
# this one -- otherwise the fire point either overwrites the
|
||||||
|
# unit's real position (wrong place) or gets silently dropped.
|
||||||
|
offset_coord = _extract_bearing_distance_from_position_coord(joined)
|
||||||
current["coord"] = (
|
current["coord"] = (
|
||||||
_extract_grid_coord(joined) or _extract_requested_on_coord(joined)
|
_extract_grid_coord(joined) or _extract_requested_on_coord(joined)
|
||||||
|
or _extract_on_our_position_coord(joined)
|
||||||
|
or _extract_our_position_coord(joined)
|
||||||
or _extract_large_grid_only_coord(joined)
|
or _extract_large_grid_only_coord(joined)
|
||||||
)
|
)
|
||||||
current["shell"] = _extract_shell_request(joined)
|
shell = _extract_shell_request(joined) or _extract_requesting_shell(joined)
|
||||||
current["requested_time"] = _extract_requested_time(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
|
if (current["clues"] or current["coord"] is not None
|
||||||
or current["shell"] is not None or current["requested_time"] is not None):
|
or current["shell"] is not None or current["requested_time"] is not None):
|
||||||
current["raw"] = joined
|
current["raw"] = joined
|
||||||
entries.append(current)
|
entries.append(current)
|
||||||
|
# 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
|
current = None
|
||||||
|
|
||||||
for raw_line in text.splitlines():
|
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
|
# 'Target'), not a Reference Point, a named thing giving its own
|
||||||
# clues is being spotted, not a fixed landmark spotters aim off
|
# clues is being spotted, not a fixed landmark spotters aim off
|
||||||
# of, same reasoning as the "Target is at-" calibration line.
|
# 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:
|
if bare_m:
|
||||||
flush()
|
flush()
|
||||||
name = bare_m.group(1)
|
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"])
|
info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"])
|
||||||
continue
|
continue
|
||||||
target_type, is_ally = _resolve_target_type(entry["type_word"])
|
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:
|
if target_type is None:
|
||||||
continue
|
continue
|
||||||
if is_ally:
|
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
|
same* position as an already-known specific target, it's not a new
|
||||||
contact, it's the same one being spotted, just described more
|
contact, it's the same one being spotted, just described more
|
||||||
precisely. Drop the redundant generic entry, keep the specific one.
|
precisely. Drop the redundant generic entry, keep the specific one.
|
||||||
Strikes are our own planned impacts, not enemy contacts, and never
|
Strikes (player-placed or requested) are planned impacts, not enemy
|
||||||
participate. Run this after resolve_board(), since positions may
|
contacts, and never participate. Run this after resolve_board(),
|
||||||
only become comparable once resolved. Returns the names removed."""
|
since positions may only become comparable once resolved. Returns
|
||||||
|
the names removed."""
|
||||||
removed: list[str] = []
|
removed: list[str] = []
|
||||||
unknowns = [t for t in board.targets if t.type is TargetType.UNKNOWN and t.coord is not None]
|
unknowns = [t for t in board.targets if t.type is TargetType.UNKNOWN and t.coord is not None]
|
||||||
specifics = [
|
specifics = [
|
||||||
t for t in board.targets
|
t for t in board.targets
|
||||||
if t.type not in (TargetType.UNKNOWN, TargetType.STRIKE) and t.coord is not None
|
if t.type not in (TargetType.UNKNOWN, TargetType.STRIKE, TargetType.STRIKE_REQUEST)
|
||||||
|
and t.coord is not None
|
||||||
]
|
]
|
||||||
for generic in unknowns:
|
for generic in unknowns:
|
||||||
if any(generic.coord == specific.coord for specific in specifics):
|
if any(generic.coord == specific.coord for specific in specifics):
|
||||||
|
|||||||
@@ -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 import ocr
|
||||||
from fenigma.models import Coord, TargetType
|
from fenigma.models import Coord, TargetType
|
||||||
|
from fenigma.shells import Shell
|
||||||
|
|
||||||
|
|
||||||
def test_standard_target_and_rp_blocks():
|
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")}
|
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():
|
def test_train_arrival_intel():
|
||||||
text = """ARRIVAL STATION:
|
text = """ARRIVAL STATION:
|
||||||
Valle de Mula MainStation: J6 0:4
|
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, "1") not in info.targets
|
||||||
assert (TargetType.TANK, "2") in info.targets
|
assert (TargetType.TANK, "2") in info.targets
|
||||||
assert (TargetType.TANK, "3") 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
|
||||||
|
|||||||