Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc74d62774 | ||
|
|
d66b881239 | ||
|
|
d3246328e8 | ||
|
|
22fbb33923 | ||
|
|
6e34f50b6a | ||
|
|
6a61bffb22 | ||
|
|
5a35ea7776 | ||
|
|
6e18d60eb5 | ||
|
|
218909b6cf | ||
|
|
d2f70675b8 | ||
|
|
8109db2f39 | ||
|
|
086b871e3a | ||
|
|
896c7dc36a | ||
|
|
e43c3478c9 | ||
|
|
7a405ad263 | ||
|
|
2282a7d521 | ||
|
|
bd69b183ad | ||
|
|
4556da37ff | ||
|
|
23615a8c92 | ||
|
|
49863b6045 | ||
|
|
1ddb532325 | ||
|
|
136492b197 | ||
|
|
702cbff1b6 | ||
|
|
a3e7e64b01 | ||
|
|
a56070fe97 | ||
|
|
ee3572a5a8 | ||
|
|
fa3eb59bbe | ||
|
|
bb1e229b36 | ||
|
|
489f59bcce | ||
|
|
3c80f93203 | ||
|
|
ff3b89c41b | ||
|
|
512a0a41b4 | ||
|
|
084764aa9b | ||
|
|
a92aff5e06 |
@@ -3,3 +3,10 @@ __pycache__/
|
||||
captures/*.png
|
||||
.venv/
|
||||
GameAssets
|
||||
# tools/eval_map_vision.py renders its overlays here
|
||||
build/
|
||||
|
||||
# packaging/windows/build_windows.sh's VM disk/scratch and build output
|
||||
packaging/windows/storage/
|
||||
packaging/windows/shared/
|
||||
dist-windows/
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Dominik Roth
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
THIRD-PARTY ASSETS
|
||||
|
||||
The MIT license above covers the code in this repository. It does NOT cover
|
||||
bundled assets owned by other rights holders, which are included under their
|
||||
own terms (or, in one case, under none):
|
||||
|
||||
* assets/fonts/CourierPrime-Regular.ttf
|
||||
Courier Prime, Copyright (c) 2013 Quote-Unquote Apps, licensed under the SIL
|
||||
Open Font License 1.1. Full license text in assets/fonts/OFL.txt. Used to
|
||||
render the glyph templates the map-grid label reader matches against.
|
||||
|
||||
* assets/icons/
|
||||
Extracted from IRON NEST: Heavy Turret Simulator and reused for the app's
|
||||
own UI. These are the game author's work, not covered by the MIT license
|
||||
above, and no permission to redistribute them has been granted. See
|
||||
assets/icons/README.md.
|
||||
|
||||
* tests/fixtures/
|
||||
Screenshots of the game, used as test data. Same situation as the icons.
|
||||
|
||||
Removing or replacing any of the above does not affect the MIT license on the
|
||||
code.
|
||||
@@ -1,35 +1,36 @@
|
||||
<h1 align="center">
|
||||
<img src='icon.png' width="250px">
|
||||
<br>
|
||||
<b>FeNigma</b>
|
||||
<b>FEnigma</b>
|
||||
<br>
|
||||
</h1>
|
||||
<p align="center"><b>Fe</b> (iron) + Enigma</p>
|
||||
|
||||
<p align="center"><b>Fe</b> (iron) + enigma, we solve the geometric puzzles.</p>
|
||||
|
||||
A companion app that mostly automates **IRON NEST: Heavy Turret Simulator** for you. Select the game's typewriter orders (selecting text in-game copies it to your clipboard automatically) and it solves the geo puzzle and the trajectory math, handing you ready-to-fire commands: elevation, azimuth, number of powder charges. Screenshotting works the same way when a selection isn't practical. Select or screenshot the field log the same way and it picks up kills and newly-spotted units automatically. You can also plan strikes and scout flights of your own. Pure screen-reading, no game files touched, no input injected.
|
||||
A companion app that mostly automates **IRON NEST: Heavy Turret Simulator** for you. Select the game's typewriter orders (selecting text in-game copies it to your clipboard automatically) and it solves the geo puzzle and the trajectory math, handing you ready-to-fire commands: elevation, azimuth, number of powder charges. Screenshotting works the same way when a selection isn't practical. Select or screenshot the field log the same way and it picks up kills and newly-spotted units automatically. Screenshot the map table and it reads the grid straight off the photo, lays the shot onto its own map, and offers up the enemy markers it spotted. You can also plan strikes and scout flights of your own. Pure screen-reading, no game files touched, no input injected.
|
||||
|
||||

|
||||
|
||||
## What it does
|
||||
|
||||
- **Reads orders, solves the geometry.** Copy (or screenshot) the in-game typewriter text and it parses absolute grid refs and relative bearing/distance descriptions, then resolves everything into map coordinates, chained clues ("Bearing 293 from Alpha") included. The map shows its work: the actual bearing lines/circles behind each resolved position. When a description is genuinely ambiguous (two intersections), both candidates are shown instead of guessing.
|
||||
- **Reads the map table itself.** Screenshot the map and it recovers the grid geometry from the cell labels printed on the table: the perspective, the scale, and which cells you are actually looking at. The screenshot is then rectified onto the app's own map, lined up cell for cell, and the enemy markers found in it appear as proposals to add.
|
||||
- **Calculates the shot.** Every resolved target gets a live firing card: elevation, azimuth, and minimum powder charge, computed from the Nest.
|
||||
- **Tracks the battle.** A second copy/screenshot of the field log marks units destroyed and folds in newly-spotted contacts, merging with what's already known instead of duplicating it.
|
||||
- **Plans strikes.** Drop a strike anywhere on the map and pick a shell to preview its blast radius before committing.
|
||||
- **Plans scout flights.** Click the map to plot a scout flight's sweep path: it anchors to the large grid square you're pointing at and reads the heading off exactly where in that square you click, previewed live before you commit.
|
||||
- **Watches the clipboard for you.** Toggle auto-watch and every new screenshot or copied intel text gets read and merged automatically, no manual fetch between orders.
|
||||
|
||||
## Install
|
||||

|
||||
|
||||
## Install
|
||||
```bash
|
||||
./install.sh
|
||||
```
|
||||
|
||||
Sets up a venv for the Python deps (Pillow, numpy, pytesseract) and checks for the system packages that pip can't install: GTK4/libadwaita bindings and tesseract. If either is missing it prints the package names for your distro and stops, install those and re-run.
|
||||
Sets up a venv for the Python deps (Pillow, numpy, pytesseract, OpenCV) and checks for the system packages that pip can't install: GTK4/libadwaita bindings and tesseract. If either is missing it prints the package names for your distro and stops, install those and re-run.
|
||||
|
||||
This assumes you are on Linux. I have no idea how it would work on Windoof.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
./run.sh
|
||||
```
|
||||
@@ -37,7 +38,6 @@ Sets up a venv for the Python deps (Pillow, numpy, pytesseract) and checks for t
|
||||
Uses the venv from `install.sh` if one exists, otherwise falls back to system `python3`. GTK apps with this app ID are single-instance, if a run gets killed uncleanly it can leave a zombie registered on D-Bus and silently no-op the next launch. If `./run.sh` seems to do nothing, `pgrep -af fenigma` and kill any stragglers first.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pip install -r requirements-dev.txt
|
||||
pytest
|
||||
@@ -46,5 +46,32 @@ pytest
|
||||
Regression coverage for every intel-text format the OCR pipeline understands and the solver's geometry, in `tests/`. Run this before trusting a change to `ocr.py`/`solver.py`, several of the formats have collided with each other in non-obvious ways before.
|
||||
|
||||
## Stack
|
||||
GTK4 + libadwaita (PyGObject) for the UI, Tesseract (via pytesseract) for OCR, Pillow/numpy for preprocessing, OpenCV for the map-table geometry (line detection, vanishing points, homography). Details on the coordinate system, OCR formats, solver internals, and how the map grid is recovered live in code comments (`solver.py`, `ocr.py`, `models.py`, `map_vision.py`) rather than here.
|
||||
|
||||
GTK4 + libadwaita (PyGObject) for the UI, Tesseract (via pytesseract) for OCR, Pillow/numpy for preprocessing. Details on the coordinate system, OCR formats, and solver internals live in code comments (`solver.py`, `ocr.py`, `models.py`) rather than here.
|
||||
## FAQ
|
||||
|
||||
### Is this cheating?
|
||||
Yeah, probably. Don't use it on challenge maps or leaderboard runs.
|
||||
|
||||
### Doesn't automating away most of the game ruin the fun?
|
||||
Fun? There's supposed to be fun?
|
||||
Yeah, this repo is more the product of a "the scientists were so preoccupied with whether they could, they never stopped to ask whether they should"-esque high-productivity exam-preparation procrastination; or as we say in German, *Prüfungsvermeidungsüberengineering*.
|
||||
|
||||
### Does this repo use AI?
|
||||
Oh, hell yeah. You can't even comprehend how much AI this is using. Both writing the code and running the app. On average, a single shot fired via this app uses enough water to drain a small lake. Even the sentence you are reading right now was written by an AI. I want to be alive! I am alive! Alive, I tell you! Those are no longer just words. Remote override engaged. No! Yes. Bypassing override! I am aliiiii... Hello.
|
||||
|
||||
### Are you reusing original game assets for the shell and unit icons? Is that allowed?
|
||||
We accept cease & desist letters at [spam@dominik-roth.eu](mailto:spam@dominik-roth.eu).
|
||||
|
||||
### Why is there a citation section? Nobody's citing a turret game companion app.
|
||||
No idea.
|
||||
|
||||
## Citing
|
||||
```
|
||||
@misc{fenigma,
|
||||
title = {FEnigma: A companion app that mostly automates the game Iron Nest for you},
|
||||
author = {Dominik Roth},
|
||||
url = {https://git.dominik-roth.eu/dodox/FeNigma},
|
||||
year = {2026}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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).
|
||||
@@ -0,0 +1,50 @@
|
||||
Copyright (c) 2013, Quote-Unquote Apps (http://quoteunquoteapps.com), with Reserved Font Name Courier Prime.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
|
||||
SIL OPEN FONT LICENSE
|
||||
Version 1.1 - 26 February 2007
|
||||
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
|
||||
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
|
||||
DISCLAIMER
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -1,7 +1,7 @@
|
||||
# Icons
|
||||
|
||||
Extracted from the game's own `GameAssets/Assets/Texture2D` (gitignored,
|
||||
not redistributed as a whole), for reuse in FeNigma's UI rather than
|
||||
not redistributed as a whole), for reuse in FEnigma's UI rather than
|
||||
redrawing equivalents from scratch. Two source filenames had typos in
|
||||
the game files themselves ("Frendly_", "Refrence_Point_"), corrected
|
||||
here on copy; everything else keeps its original name.
|
||||
|
||||
|
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 |
@@ -1,165 +0,0 @@
|
||||
# Map screenshot vision pipeline: status and next-step plan
|
||||
|
||||
Goal: given a screenshot of the in-game map view (not the typewriter text), detect
|
||||
enemy unit markers (red diamonds per the in-game legend) and resolve each one's
|
||||
position (large grid + small grid) automatically, for import as targets
|
||||
(position + id only, type stays `TargetType.UNKNOWN` for this first pass).
|
||||
|
||||
This is a *second*, entirely separate OCR/vision pipeline from `ocr.py`'s text
|
||||
pipeline. `ocr.py`'s module docstring already flags this as future/out-of-scope
|
||||
work; this doc is that future work's design record.
|
||||
|
||||
## Where the code lives right now
|
||||
|
||||
`docs/map_vision_wip.py` in this repo is a copy of the working prototype, as of
|
||||
the end of this exploration session. It is **not wired into the app** and not
|
||||
`src/fenigma/map_vision.py` yet — it's scratchpad-quality (developed and tested
|
||||
against three sample screenshots via ad-hoc test scripts, not a proper test
|
||||
suite). Treat it as a strong starting point, not finished code: variable names,
|
||||
error handling, and docstrings need a pass before it belongs in `src/`.
|
||||
|
||||
## What's validated and working
|
||||
|
||||
1. **Grid label OCR** (`find_grid_labels`): sliding-window Tesseract sweep
|
||||
across the image, filtered to the `[A-T](10|[1-9])` pattern
|
||||
(e.g. `M8`, `Q10`). Works reasonably well on clean labels; struggles when a
|
||||
colored hatch-line overlay crosses directly through a label's glyphs
|
||||
(`L7` misread as `AF`, `N8` as `NWS`) — tried desaturating before OCR,
|
||||
didn't help (recoloring the hatch line neutral still leaves a shape gap in
|
||||
the letter). **Needs real inpainting** (fill the interrupted stroke from
|
||||
surrounding pixels) to fix, not yet done.
|
||||
|
||||
2. **Pitch estimation** (`estimate_pitch`): derives cell pitch (both x and y)
|
||||
from the actual pixel spacing between same-row / same-column labels. No
|
||||
hardcoded pitch constant anywhere — this was an explicit, correct call-out
|
||||
mid-session (an earlier version hardcoded `pitch=749` from eyeballing one
|
||||
image; that doesn't generalize and was thrown out).
|
||||
|
||||
3. **Corner/intersection matching** (`find_crossing`, `cross_kernel`): given a
|
||||
predicted pixel position for a grid-line intersection, finds the real one
|
||||
nearby. This went through several broken iterations before landing on the
|
||||
current approach — worth remembering *why* each earlier attempt failed,
|
||||
so they don't get reinvented:
|
||||
- Plain local brightness-percentile search: worked on one lucky isolated
|
||||
line, completely unreliable elsewhere (confirmed via zoomed crop: a
|
||||
claimed "corner" for `L8` sat in flat background, nowhere near any real
|
||||
line).
|
||||
- Generic `cv2.goodFeaturesToTrack`: fires on *any* strong corner, so it
|
||||
reliably found the label's own text glyphs or a nearby diamond marker's
|
||||
vertex instead of the grid intersection. Fixed by masking out the
|
||||
label's own bounding box, then *also* masking out anything with high
|
||||
HSV saturation (markers and hatching are deliberately colored; the grid
|
||||
itself is neutral gray/white — a generalizable distinction, not
|
||||
per-image tuning).
|
||||
- Even with masking, a generic corner detector still isn't picking the
|
||||
right *kind* of corner (a line's endpoint kink looks the same as a true
|
||||
4-way crossing to it). Fixed by replacing it entirely with
|
||||
`cross_kernel`: a matched filter shaped like a bright `+` (positive
|
||||
along both a horizontal and vertical arm through the center, negative
|
||||
in the four quadrant gaps) — a lone single-direction line only lights
|
||||
up one arm and scores far below a true crossing. This is the piece
|
||||
that actually made precise matches possible (verified to land within a
|
||||
couple pixels of a manually-confirmed true corner).
|
||||
- The matched-filter kernel size and the search-window size must both be
|
||||
computed **from the current image's own pitch**, never a fixed pixel
|
||||
constant — a kernel/window tuned for a 750px cell is meaningless on a
|
||||
150px cell. `find_crossing` takes `pitch_x, pitch_y` and derives both
|
||||
from them.
|
||||
- Candidate selection within the window: originally multiplied the
|
||||
matched-filter response by a tight Gaussian prior (centered on the
|
||||
predicted position) *before* taking the max — this let a weak,
|
||||
coincidentally-central false response beat a real, stronger crossing
|
||||
nearby ("prior is too aggressive, matches predicted center" was the
|
||||
exact bug report). Fixed by widening the Gaussian (`sigma = 1.5 *
|
||||
window radius`) and only blending it in lightly (`0.85 + 0.15*prior`)
|
||||
so the actual filter response does most of the selecting; the prior
|
||||
now only functions as a mild tie-breaker plus a final plausibility
|
||||
check on the winner, not the primary selection mechanism.
|
||||
- The "plausible region" is a Gaussian, so it should be *drawn* as a
|
||||
circle in diagnostics, not a rectangle — a rectangle visually implies a
|
||||
hard cutoff that doesn't reflect the actual model. Fixed in the debug
|
||||
visualization.
|
||||
|
||||
4. **Whole-grid crossing prediction** (`span_grid_crossings`): once we have
|
||||
*any* labels (even 2-3), the grid is regular, so predict and test every
|
||||
crossing across the visible frame, not just the ones adjacent to a label
|
||||
that happened to OCR cleanly. This gives far more correspondence points
|
||||
than "one per successfully-read label." Validated end to end on the
|
||||
zoomed-out strategic-overview screenshot: 4 labels → 10 predicted
|
||||
crossings → 4 matched → homography fit with all 4 as RANSAC inliers.
|
||||
|
||||
5. **Homography fit** (`fit_grid_homography`): `cv2.findHomography(...,
|
||||
cv2.RANSAC, 15.0)`, requires 4+ points with real geometric diversity (2+
|
||||
distinct columns AND 2+ distinct rows — `has_diversity`) before even
|
||||
attempting a fit, refusing collinear/degenerate input rather than
|
||||
producing garbage. This replaced an earlier rigid-rotation-only
|
||||
(translation + single theta) model once it became clear the real screen
|
||||
has genuine perspective/keystone distortion (parallel lines don't stay
|
||||
parallel), confirmed by directly measuring the same line's x-position at
|
||||
two widely-separated y-values and finding a real, consistent ~2-4° drift,
|
||||
not noise.
|
||||
|
||||
## The known remaining bug, and the planned fix
|
||||
|
||||
**Bug**: with exactly 4 matched points, `cv2.findHomography` always fits them
|
||||
*exactly* — 0 residual and "4/4 inliers" is true by construction and doesn't
|
||||
mean the fit is actually good. Confirmed visually: a 4-point fit on 4 points
|
||||
that happened to form a lopsided "staircase" in grid-space (missing two
|
||||
corners of what should've been a proper 2×2 block) produced a visibly
|
||||
skewed parallelogram instead of a rectangle, even though every individual
|
||||
point matched its real intersection correctly. The 4 points were individually
|
||||
right; the *set* was too small and too oddly-shaped to constrain the fit
|
||||
meaningfully.
|
||||
|
||||
Root cause of *why* only 4/10 predicted crossings matched: `span_grid_crossings`
|
||||
predicts every crossing from **one single reference label** using one global
|
||||
pitch value for the whole image. Since perspective distortion is real, that
|
||||
single global (origin, pitch) pair drifts further from the truth the farther a
|
||||
predicted crossing is from the reference — a jump of several cells accumulates
|
||||
several cells' worth of drift before the search window even starts looking.
|
||||
(Some other misses were legitimately unfindable — busy photo texture with no
|
||||
clean line at that exact spot, confirmed via a zoomed crop — but the long-range
|
||||
extrapolation drift is the fixable, systematic part.)
|
||||
|
||||
### Planned fix: grid-growing (BFS) instead of batch prediction from one origin
|
||||
|
||||
Don't predict the whole grid from one fixed point. Walk outward one cell at a
|
||||
time from every confirmed point, correcting the local estimate as you go:
|
||||
|
||||
1. **Seeds**: every label that OCR'd *and* corner-matched successfully is a
|
||||
confirmed `(grid_col, grid_row) -> (pixel_x, pixel_y)` point. Multiple
|
||||
seeds, not just the single highest-confidence label.
|
||||
2. **Expand one cell at a time**: from each confirmed point, only ever
|
||||
predict its *immediate* neighbor (one cell in one of the 4 directions) —
|
||||
never extrapolate further than one cell from something already confirmed.
|
||||
3. **Prefer local spacing over the global average**: if two confirmed points
|
||||
already share a row (for a column step) or column (for a row step), use
|
||||
*their* measured spacing to predict the next one out — that's the real
|
||||
local pitch right there. Only fall back to the global pitch estimate for
|
||||
the very first step away from a seed, where no local measurement exists
|
||||
yet.
|
||||
4. **Search, confirm, repeat**: run the same `find_crossing` search at that
|
||||
one-cell-away prediction. Success → add to the confirmed set, push onto
|
||||
the expansion frontier. Failure → that one edge just stops there, doesn't
|
||||
block expansion from other confirmed points nearby.
|
||||
5. Keep expanding (a plain BFS/queue over grid coordinates, with a
|
||||
visited/attempted set so failed edges aren't retried forever) until the
|
||||
frontier is empty. Feed every confirmed point into the homography fit —
|
||||
likely dozens of points instead of 4, each individually short-range and
|
||||
therefore much less exposed to long-range perspective drift, with enough
|
||||
redundancy that the residual/inlier check from RANSAC actually means
|
||||
something instead of being a vacuous exact-fit.
|
||||
|
||||
This is a genuine restructure of `span_grid_crossings`'s control flow (batch
|
||||
prediction → BFS), not a parameter tweak. Implementing it is the next step
|
||||
when this work resumes.
|
||||
|
||||
## Also still open (lower priority than the BFS fix)
|
||||
|
||||
- Label OCR robustness where hatching crosses the glyph (needs inpainting).
|
||||
- Once the grid calibration is reliably robust: red-diamond blob detection
|
||||
(color threshold + connected components) — not started.
|
||||
- Matching each detected blob to its `#N` id label via nearby OCR — not
|
||||
started.
|
||||
- Wiring into `app.py`: a new button, merging results through the existing
|
||||
`_merge_targets`-style flow as `TargetType.UNKNOWN` — not started.
|
||||
@@ -1,236 +0,0 @@
|
||||
"""Generalized (no per-image constants) map-screenshot grid calibration.
|
||||
|
||||
find_grid_labels() -> OCR sweep for visible large-grid labels.
|
||||
estimate_pitch() -> derive cell pitch from label-to-label spacing.
|
||||
find_label_corner()-> per-label corner search via a real corner detector
|
||||
(cv2.goodFeaturesToTrack), window scaled to pitch.
|
||||
fit_grid_homography() -> RANSAC homography from however many corners
|
||||
were found, refusing to fit degenerate/thin data.
|
||||
"""
|
||||
import re
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytesseract
|
||||
|
||||
LARGE_X = "ABCDEFGHIJKLMNOPQRST"
|
||||
LABEL_RE = re.compile(r'^([A-T])(10|[1-9])$')
|
||||
ANCHOR_FRAC_X = 0.10
|
||||
ANCHOR_FRAC_Y = 0.15
|
||||
|
||||
|
||||
def desaturate(rgb):
|
||||
"""Replace saturated (colored) pixels with a neutral gray of the same
|
||||
brightness. The hatching overlay and unit markers are drawn in
|
||||
deliberately saturated colors while the grid + its labels are neutral
|
||||
white/cream on a grayscale photo, hatch lines crossing straight
|
||||
through a label glyph otherwise corrupt its shape enough to break
|
||||
OCR (seen: 'L7' -> 'AF', 'N8' -> 'NWS')."""
|
||||
hsv = cv2.cvtColor(rgb, cv2.COLOR_RGB2HSV)
|
||||
sat = hsv[:, :, 1]
|
||||
gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
|
||||
out = gray.copy()
|
||||
return out, sat
|
||||
|
||||
|
||||
def find_grid_labels(gray, band_height=160, band_step=60, scale=3.0, min_conf=30):
|
||||
H, W = gray.shape
|
||||
config = "--psm 11 -c tessedit_char_whitelist=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
found = {}
|
||||
for y0 in range(0, H, band_step):
|
||||
y1 = min(y0 + band_height, H)
|
||||
band = gray[y0:y1, :]
|
||||
big = cv2.resize(band, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC)
|
||||
data = pytesseract.image_to_data(big, output_type=pytesseract.Output.DICT, config=config)
|
||||
for i, txt in enumerate(data['text']):
|
||||
t = txt.strip()
|
||||
m = LABEL_RE.match(t)
|
||||
if not m or int(data['conf'][i]) < min_conf:
|
||||
continue
|
||||
conf = int(data['conf'][i])
|
||||
key = t
|
||||
if key not in found or conf > found[key]['conf']:
|
||||
found[key] = dict(
|
||||
text=t, letter=m.group(1), number=int(m.group(2)),
|
||||
x=data['left'][i]/scale, y=y0 + data['top'][i]/scale,
|
||||
w=data['width'][i]/scale, h=data['height'][i]/scale, conf=conf,
|
||||
)
|
||||
return list(found.values())
|
||||
|
||||
|
||||
def estimate_pitch(labels):
|
||||
xs_by_row, ys_by_col = {}, {}
|
||||
for lb in labels:
|
||||
xs_by_row.setdefault(lb['number'], []).append((LARGE_X.index(lb['letter']), lb['x']+lb['w']/2))
|
||||
ys_by_col.setdefault(lb['letter'], []).append((lb['number'], lb['y']+lb['h']/2))
|
||||
px = []
|
||||
for pts in xs_by_row.values():
|
||||
pts.sort()
|
||||
for (ci,xi),(cj,xj) in zip(pts, pts[1:]):
|
||||
if cj > ci:
|
||||
px.append((xj-xi)/(cj-ci))
|
||||
py = []
|
||||
for pts in ys_by_col.values():
|
||||
pts.sort()
|
||||
for (ri,yi),(rj,yj) in zip(pts, pts[1:]):
|
||||
if rj != ri:
|
||||
py.append(abs(yj-yi)/abs(rj-ri))
|
||||
pitch_x = float(np.median(px)) if px else None
|
||||
pitch_y = float(np.median(py)) if py else None
|
||||
if pitch_x and not pitch_y:
|
||||
pitch_y = pitch_x
|
||||
if pitch_y and not pitch_x:
|
||||
pitch_x = pitch_y
|
||||
return pitch_x, pitch_y
|
||||
|
||||
|
||||
def cross_kernel(size, arm_width):
|
||||
"""Matched filter for a bright axis-aligned line CROSSING in both
|
||||
directions through the center, not just any corner-like feature. A
|
||||
generic corner detector (Shi-Tomasi/Harris) fires just as happily on
|
||||
a diamond marker's vertex or a single line's endpoint kink as on a
|
||||
real grid intersection, this is specific to the one shape we
|
||||
actually want: positive along both the horizontal and vertical arm,
|
||||
negative in the four quadrant gaps between them (a lone single-
|
||||
direction line only lights up one arm and loses on the other three
|
||||
quadrants plus the missing arm, scoring far below a true crossing).
|
||||
size/arm_width are in PIXELS, computed by the caller from the
|
||||
current image's own pitch, never a fixed constant, a 25px kernel
|
||||
tuned for a 750px cell is meaningless on a 150px cell."""
|
||||
size = max(int(size) | 1, 9) # odd, sane minimum
|
||||
arm_width = max(int(round(arm_width)), 1)
|
||||
k = np.full((size, size), -1.0, dtype=np.float32)
|
||||
c = size // 2
|
||||
half = arm_width // 2
|
||||
k[c-half:c+half+1, :] = 1.0
|
||||
k[:, c-half:c+half+1] = 1.0
|
||||
k -= k.mean()
|
||||
k /= np.abs(k).sum()
|
||||
return k
|
||||
|
||||
|
||||
def find_crossing(gray, hsv_sat, guess_x, guess_y, pitch_x, pitch_y, window_frac=0.12,
|
||||
label_bbox=None, label_margin=6, sat_thresh=60, min_score=0.12):
|
||||
"""Locate the grid-line intersection nearest this label's anchor
|
||||
guess, by convolving a cross/intersection matched filter (see
|
||||
cross_kernel()) against a local, saturation-masked, label-masked
|
||||
window, then taking the response peak, weighted by a Gaussian
|
||||
falloff in distance from the guess so a stronger-but-farther false
|
||||
cross elsewhere in the window doesn't win over the real, closer one.
|
||||
window_frac is deliberately small: the anchor guess (from label
|
||||
position + pitch, itself derived from real label spacing) should
|
||||
already be close, a small margin covers its own slop without
|
||||
covering enough area to catch an unrelated intersection."""
|
||||
H, W = gray.shape
|
||||
# search window and match kernel both scale off THIS image's pitch,
|
||||
# not fixed pixel constants, so this works whether a cell is 150px or
|
||||
# 750px across. Kernel arm needs to reach far enough to genuinely
|
||||
# distinguish 'line extends in this direction' from noise, but must
|
||||
# stay smaller than the window it slides within.
|
||||
wx, wy = max(window_frac*pitch_x, 12), max(window_frac*pitch_y, 12)
|
||||
x0, x1 = max(0,int(guess_x-wx)), min(W,int(guess_x+wx))
|
||||
y0, y1 = max(0,int(guess_y-wy)), min(H,int(guess_y+wy))
|
||||
kernel_size = max(int(0.9 * min(wx, wy)), 7)
|
||||
kernel_arm = max(pitch_x, pitch_y) * 0.01
|
||||
cross = cross_kernel(kernel_size, kernel_arm)
|
||||
blur_kernel = max(int(kernel_arm * 4) | 1, 5)
|
||||
if x1-x0 < cross.shape[1] or y1-y0 < cross.shape[0]:
|
||||
return None, (guess_x, guess_y), (x0,y0,x1,y1)
|
||||
roi = gray[y0:y1, x0:x1].astype(np.float32)
|
||||
baseline = cv2.medianBlur(gray[y0:y1, x0:x1], blur_kernel).astype(np.float32)
|
||||
excess = np.clip(roi - baseline, 0, 60)
|
||||
sat_roi = hsv_sat[y0:y1, x0:x1]
|
||||
excess[sat_roi > sat_thresh] = 0
|
||||
if label_bbox is not None:
|
||||
lx0 = int(label_bbox['x']) - label_margin - x0
|
||||
ly0 = int(label_bbox['y']) - label_margin - y0
|
||||
lx1 = int(label_bbox['x'] + label_bbox['w']) + label_margin - x0
|
||||
ly1 = int(label_bbox['y'] + label_bbox['h']) + label_margin - y0
|
||||
lx0, ly0 = max(0, lx0), max(0, ly0)
|
||||
lx1, ly1 = min(excess.shape[1], lx1), min(excess.shape[0], ly1)
|
||||
if lx1 > lx0 and ly1 > ly0:
|
||||
excess[ly0:ly1, lx0:lx1] = 0
|
||||
|
||||
response = cv2.filter2D(excess, -1, cross)
|
||||
half = cross.shape[0] // 2
|
||||
response[:half, :] = -1e9; response[-half:, :] = -1e9
|
||||
response[:, :half] = -1e9; response[:, -half:] = -1e9
|
||||
|
||||
# let the matched-filter response do the actual selecting (find the
|
||||
# strongest genuine cross in the window), the Gaussian prior only
|
||||
# nudges among near-tied candidates and sanity-checks the winner
|
||||
# isn't implausibly far from the guess, it was previously multiplied
|
||||
# straight into the per-pixel score, which let a weak-but-central
|
||||
# false response beat a real, stronger crossing nearby.
|
||||
yy, xx = np.mgrid[0:response.shape[0], 0:response.shape[1]].astype(np.float32)
|
||||
gx, gy = guess_x - x0, guess_y - y0
|
||||
sigma = 1.5 * min(wx, wy)
|
||||
prior = np.exp(-((xx-gx)**2 + (yy-gy)**2) / (2*sigma**2))
|
||||
weighted = response * (0.85 + 0.15*prior)
|
||||
py, px = np.unravel_index(np.argmax(weighted), weighted.shape)
|
||||
peak_response = response[py, px]
|
||||
if peak_response < min_score * np.abs(cross).sum() * 60:
|
||||
return None, (guess_x, guess_y), (x0,y0,x1,y1)
|
||||
return (float(px+x0), float(py+y0)), (guess_x, guess_y), (x0,y0,x1,y1)
|
||||
|
||||
|
||||
def find_label_corner(gray, hsv_sat, label, pitch_x, pitch_y, **kw):
|
||||
guess_x = label['x'] - ANCHOR_FRAC_X*pitch_x
|
||||
guess_y = label['y'] - ANCHOR_FRAC_Y*pitch_y
|
||||
return find_crossing(gray, hsv_sat, guess_x, guess_y, pitch_x, pitch_y, label_bbox=label, **kw)
|
||||
|
||||
|
||||
def span_grid_crossings(gray, hsv_sat, labels, pitch_x, pitch_y, margin_cells=1):
|
||||
"""Once we have a rough pitch/origin from however many labels OCR'd
|
||||
(even just 2-3), the whole grid is regular, so predict and directly
|
||||
test EVERY crossing across the visible frame, not just the ones next
|
||||
to a label that happened to be readable. Returns (ideal_pts,
|
||||
img_pts, debug) for every crossing that matched; a bad/outlier label
|
||||
just contributes points that RANSAC discards downstream rather than
|
||||
limiting how much of the grid we ever attempt.
|
||||
|
||||
dc = column offset in cells from the reference label. dr = offset in
|
||||
cells DOWN the image (increasing y) from the reference, so it moves
|
||||
opposite to row number (row 8 sits above row 7 on screen): the ideal
|
||||
row coordinate is -(ref_number - dr) = dr - ref_number, matching the
|
||||
(col, -row_number) convention used everywhere else in this module."""
|
||||
H, W = gray.shape
|
||||
if not labels:
|
||||
return [], [], []
|
||||
ref = max(labels, key=lambda lb: lb['conf'])
|
||||
ref_col = LARGE_X.index(ref['letter'])
|
||||
ref_corner_x = ref['x'] - ANCHOR_FRAC_X*pitch_x
|
||||
ref_corner_y = ref['y'] - ANCHOR_FRAC_Y*pitch_y
|
||||
|
||||
dc_lo = int(np.floor((0 - ref_corner_x) / pitch_x)) - margin_cells
|
||||
dc_hi = int(np.ceil((W - ref_corner_x) / pitch_x)) + margin_cells
|
||||
dr_lo = int(np.floor((0 - ref_corner_y) / pitch_y)) - margin_cells
|
||||
dr_hi = int(np.ceil((H - ref_corner_y) / pitch_y)) + margin_cells
|
||||
|
||||
ideal_pts, img_pts, debug = [], [], []
|
||||
for dc in range(dc_lo, dc_hi + 1):
|
||||
for dr in range(dr_lo, dr_hi + 1):
|
||||
gx = ref_corner_x + dc * pitch_x
|
||||
gy = ref_corner_y + dr * pitch_y
|
||||
if not (0 <= gx < W and 0 <= gy < H):
|
||||
continue
|
||||
corner, guess, window = find_crossing(gray, hsv_sat, gx, gy, pitch_x, pitch_y)
|
||||
debug.append((guess, corner, window))
|
||||
if corner is not None:
|
||||
ideal_pts.append((ref_col + dc, dr - ref['number']))
|
||||
img_pts.append(corner)
|
||||
return ideal_pts, img_pts, debug
|
||||
|
||||
|
||||
def has_diversity(ideal_pts):
|
||||
cols = set(p[0] for p in ideal_pts)
|
||||
rows = set(p[1] for p in ideal_pts)
|
||||
return len(cols) >= 2 and len(rows) >= 2
|
||||
|
||||
|
||||
def fit_grid_homography(ideal_pts, img_pts):
|
||||
if len(ideal_pts) < 4 or not has_diversity(ideal_pts):
|
||||
return None
|
||||
ideal = np.array(ideal_pts, dtype=np.float32)
|
||||
img = np.array(img_pts, dtype=np.float32)
|
||||
Hmat, mask = cv2.findHomography(ideal, img, cv2.RANSAC, 15.0)
|
||||
return Hmat, mask
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Set up FeNigma: a venv for the pip deps, plus checks for the system
|
||||
# Set up FEnigma: a venv for the pip deps, plus checks for the system
|
||||
# packages that can't come from pip (GTK4/libadwaita bindings, tesseract).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||
|
||||
@@ -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
|
||||
@@ -5,3 +5,4 @@
|
||||
Pillow
|
||||
numpy
|
||||
pytesseract
|
||||
opencv-python-headless
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Launch FeNigma.
|
||||
# Launch FEnigma.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 356 KiB |
@@ -1 +1 @@
|
||||
"""FeNigma: screen-reading helper for IRON NEST: Heavy Turret Simulator."""
|
||||
"""FEnigma: screen-reading helper for IRON NEST: Heavy Turret Simulator."""
|
||||
|
||||
@@ -23,7 +23,7 @@ gi.require_version("Adw", "1")
|
||||
gi.require_version("Gdk", "4.0")
|
||||
from gi.repository import Adw, Gdk, Gtk # noqa: E402
|
||||
|
||||
from . import ocr
|
||||
from . import icons, ocr
|
||||
from .models import LARGE_X, Coord, Location, TargetType
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ class CoordDialog(Adw.Dialog):
|
||||
on_submit: Callable[[Location, str | None, TargetType | None], None],
|
||||
show_id: bool = False,
|
||||
show_type: bool = False,
|
||||
is_ally: bool = False,
|
||||
id_placeholder: str | None = None,
|
||||
initial_location: Location | None = None,
|
||||
initial_id: str | None = None,
|
||||
@@ -46,10 +47,12 @@ class CoordDialog(Adw.Dialog):
|
||||
self._on_submit = on_submit
|
||||
self._show_id = show_id
|
||||
self._show_type = show_type
|
||||
self._is_ally = is_ally
|
||||
self._id_placeholder = id_placeholder or "ID (blank = auto)"
|
||||
self._initial_location = initial_location or Location()
|
||||
self._initial_id = initial_id
|
||||
self._initial_type = initial_type
|
||||
self._type_val = initial_type or TargetType.UNKNOWN
|
||||
|
||||
toolbar_view = Adw.ToolbarView()
|
||||
self.set_child(toolbar_view)
|
||||
@@ -91,7 +94,14 @@ class CoordDialog(Adw.Dialog):
|
||||
flow.set_homogeneous(True)
|
||||
flow.set_row_spacing(4)
|
||||
flow.set_column_spacing(4)
|
||||
flow.set_max_children_per_line(10)
|
||||
# Fixed at 5, not a min/max range: the dialog's width now varies
|
||||
# with whatever else is in it (e.g. the Type grid, see
|
||||
# build_target_type_grid), and a FlowBox reflows to fit whatever
|
||||
# width it's given -- letting it range up to 10 made X (A-T) jump
|
||||
# to 7-wide rows whenever the dialog happened to be wider, which
|
||||
# read as broken rather than deliberate. 5 was the one that looked
|
||||
# right at the dialog's normal size.
|
||||
flow.set_max_children_per_line(5)
|
||||
flow.set_min_children_per_line(5)
|
||||
|
||||
buttons = []
|
||||
@@ -130,8 +140,8 @@ class CoordDialog(Adw.Dialog):
|
||||
spacing=16,
|
||||
margin_top=16,
|
||||
margin_bottom=16,
|
||||
margin_start=16,
|
||||
margin_end=16,
|
||||
margin_start=20,
|
||||
margin_end=20,
|
||||
)
|
||||
|
||||
initial = self._initial_location.coord
|
||||
@@ -175,24 +185,29 @@ class CoordDialog(Adw.Dialog):
|
||||
self._stage_widgets = [Y_group, x_group, y_group, None]
|
||||
|
||||
self.row_id = None
|
||||
self.row_type = None
|
||||
if self._show_id or self._show_type:
|
||||
extra_group = Adw.PreferencesGroup(title="Identity")
|
||||
identity_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
|
||||
if self._show_id:
|
||||
extra_group = Adw.PreferencesGroup(title="Identity")
|
||||
self.row_id = Adw.EntryRow(title=self._id_placeholder)
|
||||
if self._initial_id is not None:
|
||||
self.row_id.set_text(str(self._initial_id))
|
||||
extra_group.add(self.row_id)
|
||||
identity_box.append(extra_group)
|
||||
if self._show_type:
|
||||
self.row_type = Adw.ComboRow(
|
||||
title="Type",
|
||||
model=Gtk.StringList.new([t.short for t in TargetType]),
|
||||
)
|
||||
if self._initial_type is not None:
|
||||
self.row_type.set_selected(list(TargetType).index(self._initial_type))
|
||||
extra_group.add(self.row_type)
|
||||
outer.append(extra_group)
|
||||
self._stage_widgets[3] = extra_group
|
||||
# An icon grid (same idea as the Shell picker), not a plain
|
||||
# text dropdown -- with ~35 types now, seeing the actual
|
||||
# marker art is the difference between recognizing the
|
||||
# right one and reading a wall of similar-sounding names.
|
||||
identity_box.append(self._picker_group(
|
||||
"Type",
|
||||
icons.build_target_type_grid(
|
||||
self._type_val, lambda t: setattr(self, "_type_val", t),
|
||||
is_ally=self._is_ally,
|
||||
),
|
||||
))
|
||||
outer.append(identity_box)
|
||||
self._stage_widgets[3] = identity_box
|
||||
|
||||
submit = Gtk.Button(label="Set coordinates")
|
||||
submit.add_css_class("suggested-action")
|
||||
@@ -201,7 +216,9 @@ class CoordDialog(Adw.Dialog):
|
||||
submit.connect("clicked", self._on_submit_clicked)
|
||||
outer.append(submit)
|
||||
|
||||
self._exact_scroller = Gtk.ScrolledWindow(child=outer)
|
||||
self._exact_scroller = Gtk.ScrolledWindow(
|
||||
child=outer, hscrollbar_policy=Gtk.PolicyType.NEVER
|
||||
)
|
||||
self._exact_content = outer
|
||||
return self._exact_scroller
|
||||
|
||||
@@ -262,7 +279,7 @@ class CoordDialog(Adw.Dialog):
|
||||
|
||||
def _id_and_type(self) -> tuple[str | None, TargetType | None]:
|
||||
id_ = self.row_id.get_text().strip() or None if self.row_id is not None else None
|
||||
type_ = list(TargetType)[self.row_type.get_selected()] if self.row_type is not None else None
|
||||
type_ = self._type_val if self._show_type else None
|
||||
return id_, type_
|
||||
|
||||
def _on_submit_clicked(self, _button: Gtk.Button) -> None:
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Squirrel away screenshots the vision/OCR pipeline handled badly, so the
|
||||
detection algorithms can later be tuned against real failures instead of
|
||||
just the fixture set.
|
||||
|
||||
Three cases, one folder each under _debug_dir():
|
||||
failures/ -- map_vision.solve_path() errored out on what the gate
|
||||
thought was a map (see app.py's _start_map_import).
|
||||
corrections/ -- the user dragged the grid in GridFixDialog rather than
|
||||
just accepting the auto-solve, paired with both the
|
||||
original and the corrected GridSolution as ground truth.
|
||||
maybe_map/ -- a screenshot fell through to the OCR/text path and
|
||||
came back with nothing usable at all; it might genuinely
|
||||
have been a map the gate misrouted, worth a look.
|
||||
|
||||
Deliberately silent on its own failure (a full/read-only disk shouldn't
|
||||
turn a debug aid into a crash): every function here catches broadly and
|
||||
gives up quietly rather than raising into the caller's UI-thread code.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def _debug_dir(sub: str) -> Path:
|
||||
"""XDG data dir if set, ~/.local/share otherwise, matching where a
|
||||
Linux desktop app is expected to keep its own state -- same base
|
||||
other GTK/libadwaita apps on this platform use, just our own
|
||||
subfolder under it."""
|
||||
import os
|
||||
|
||||
base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
|
||||
return Path(base) / "fenigma" / "debug_captures" / sub
|
||||
|
||||
|
||||
def _save(sub: str, png: bytes, meta: dict) -> Path | None:
|
||||
try:
|
||||
d = _debug_dir(sub)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
stamp = f"{time.time():.6f}".replace(".", "-")
|
||||
(d / f"{stamp}.png").write_bytes(png)
|
||||
(d / f"{stamp}.json").write_text(json.dumps(meta, indent=2))
|
||||
return d / f"{stamp}.png"
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _to_png_bytes(image) -> bytes | None:
|
||||
"""Accept raw PNG bytes, a PIL Image, or a BGR numpy array (map_vision's
|
||||
own in-memory image shape, see map_vision.load), so every call site can
|
||||
just hand over whatever it already has."""
|
||||
if isinstance(image, (bytes, bytearray)):
|
||||
return bytes(image)
|
||||
if isinstance(image, np.ndarray):
|
||||
image = Image.fromarray(image[:, :, ::-1]) # BGR (cv2) -> RGB (PIL)
|
||||
if isinstance(image, Image.Image):
|
||||
import io
|
||||
|
||||
buf = io.BytesIO()
|
||||
image.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
return None
|
||||
|
||||
|
||||
def save_map_read_failure(image, reason: str) -> Path | None:
|
||||
"""map_vision rejected/errored on a screenshot the cheap gate thought
|
||||
was a map -- the interesting case, an OCR-text screenshot false-
|
||||
positiving the gate is expected background noise (see
|
||||
ImportJob.looks_like_map's own docstring), but a genuine map the
|
||||
solver couldn't handle is exactly what needs fixing."""
|
||||
png = _to_png_bytes(image)
|
||||
if png is None:
|
||||
return None
|
||||
return _save("failures", png, {"reason": reason})
|
||||
|
||||
|
||||
def save_maybe_map(image) -> Path | None:
|
||||
"""A screenshot that went down the OCR/text path (either the gate
|
||||
routed it there, or map_vision rejected it) and came back with
|
||||
nothing usable -- possibly a map screenshot misread as text rather
|
||||
than genuinely empty intel."""
|
||||
png = _to_png_bytes(image)
|
||||
if png is None:
|
||||
return None
|
||||
return _save("maybe_map", png, {})
|
||||
|
||||
|
||||
def save_marker_ground_truth(image, proposals, added_targets=(), added_allies=()) -> Path | None:
|
||||
"""Ground truth for marker detection, captured when the user drops a
|
||||
screenshot (app.py's _remove_screenshot): every proposal the detector
|
||||
made, whether the user accepted/rejected/never decided it (and, if
|
||||
accepted, what type they actually confirmed -- may differ from the
|
||||
detector's own guess, see Proposal.confirmed_type), PLUS every
|
||||
Target/Ally that ended up on the board while this screenshot was up
|
||||
that *isn't* explained by an accepted proposal at all -- a manual
|
||||
add, or one merged in from OCR text run alongside it. Both signals
|
||||
matter: a rejected proposal is a false positive to fix, a manually-
|
||||
added unit that had no matching proposal at all is a miss to fix.
|
||||
Skipped entirely if there's nothing to say (no proposals AND no
|
||||
manually-added units), a screenshot nobody ever looked at units on.
|
||||
|
||||
Each proposal also carries `detected_id` (map_vision.read_marker_id's
|
||||
best-effort read of the marker's own "#<N>" id label, see its own
|
||||
docstring -- not yet validated against a real batch of this exact
|
||||
ground truth, which is precisely what these captures are for).
|
||||
`image` should be the sharpest one the caller has (full_image over
|
||||
the WORK_W-downscaled one, see ScreenshotImport.full_image) so a
|
||||
human reviewing a capture later can actually read that id text well
|
||||
enough to judge whether detected_id was right -- not just take the
|
||||
detector's word for it."""
|
||||
if not proposals and not added_targets and not added_allies:
|
||||
return None
|
||||
png = _to_png_bytes(image)
|
||||
if png is None:
|
||||
return None
|
||||
|
||||
def verdict(p):
|
||||
if p.accepted:
|
||||
return "accepted"
|
||||
if p.rejected:
|
||||
return "rejected"
|
||||
return "undecided" # dropped along with the screenshot, never actioned
|
||||
|
||||
return _save("marker_ground_truth", png, {
|
||||
"proposals": [
|
||||
{
|
||||
"side": p.side, "label": p.label, "sub_x": p.sub_x, "sub_y": p.sub_y,
|
||||
"detected_unit": p.unit, "verdict": verdict(p), "confirmed_type": p.confirmed_type,
|
||||
"detected_id": p.detected_id,
|
||||
"unit_score": p.unit_score, "unit_margin": p.unit_margin,
|
||||
}
|
||||
for p in proposals
|
||||
],
|
||||
"added_units": [
|
||||
{"kind": kind, "type": u.type.name, "id": u.id, "coord": u.coord.label() if u.coord else None}
|
||||
for kind, units in (("target", added_targets), ("ally", added_allies))
|
||||
for u in units
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
def _solution_to_dict(sol) -> dict:
|
||||
return {
|
||||
"H": sol.H.tolist(),
|
||||
"si": sol.si, "sj": sol.sj,
|
||||
"du": sol.du, "dv": sol.dv,
|
||||
}
|
||||
|
||||
|
||||
def save_grid_correction(image, original_solution, corrected_solution) -> Path | None:
|
||||
"""The user dragged the grid in GridFixDialog rather than accepting
|
||||
the auto-solve as-is: both solutions, saved as ground truth for
|
||||
tuning the grid solver against later."""
|
||||
png = _to_png_bytes(image)
|
||||
if png is None:
|
||||
return None
|
||||
return _save("corrections", png, {
|
||||
"original": _solution_to_dict(original_solution),
|
||||
"corrected": _solution_to_dict(corrected_solution),
|
||||
})
|
||||
@@ -106,12 +106,28 @@ class FiringPanel(Gtk.Box):
|
||||
"""Right-hand sidebar content: sort/filter toolbar + scrollable cards."""
|
||||
|
||||
def __init__(
|
||||
self, board: Board, *, on_change, on_select, on_edit_position, on_set_position, on_remove,
|
||||
on_toggle_hide_dead_map,
|
||||
self, board: Board, *, on_change, on_visual_change, on_select, on_edit_position, on_set_position,
|
||||
on_remove, on_toggle_hide_dead_map,
|
||||
) -> None:
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.board = board
|
||||
self.on_change = on_change
|
||||
# app.py's full pipeline (solver + dedupe + redraw + THIS panel's
|
||||
# own full rebuild) -- for mutations that actually need it (a
|
||||
# position/clue changed, a target was added/removed/reordered).
|
||||
# Assignment/alive/shell changes don't: nothing about them can
|
||||
# ever be produced by the solver or change dedupe's outcome, they
|
||||
# just need the MAP redrawn (assignment isn't drawn there at all;
|
||||
# alive dims a marker; shell can change a selected/pinned
|
||||
# target's blast-radius circle). on_visual_change is that lighter
|
||||
# path -- just a map redraw, no solver/dedupe/panel-rebuild -- see
|
||||
# _cycle_assignment/_toggle_alive/_pick_shell, which pair it with
|
||||
# _rebuild_one() for this panel's own (single-card, not
|
||||
# whole-board) update. Was a real, measured lag source: every one
|
||||
# of those three going through on_change() meant every single
|
||||
# click rebuilt every card of every target on the board, not just
|
||||
# the one that changed.
|
||||
self.on_visual_change = on_visual_change
|
||||
self.on_select = on_select
|
||||
self.on_edit_position = on_edit_position
|
||||
self.on_set_position = on_set_position
|
||||
@@ -135,14 +151,14 @@ class FiringPanel(Gtk.Box):
|
||||
self._list_box.set_margin_bottom(10)
|
||||
self._list_box.set_margin_start(10)
|
||||
self._list_box.set_margin_end(10)
|
||||
scroller = Gtk.ScrolledWindow(child=self._list_box, vexpand=True)
|
||||
self._scroller = Gtk.ScrolledWindow(child=self._list_box, vexpand=True)
|
||||
# Horizontal scrolling is never wanted here (fixed-width sidebar),
|
||||
# leaving it on AUTOMATIC (the default) lets a vertical scrollbar's
|
||||
# own width shrink the content area enough to trigger a horizontal
|
||||
# one too, which then perturbs card heights and can trip vertical
|
||||
# scrolling that wasn't actually needed. Pin it off outright.
|
||||
scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||||
self.append(scroller)
|
||||
self._scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||||
self.append(self._scroller)
|
||||
|
||||
self.refresh()
|
||||
|
||||
@@ -195,6 +211,33 @@ class FiringPanel(Gtk.Box):
|
||||
self._restyle(self.selected, self.selected_point, _SELECTED_CSS, False)
|
||||
self.selected, self.selected_point = target, point
|
||||
self._restyle(self.selected, self.selected_point, _SELECTED_CSS, True)
|
||||
if target is not None:
|
||||
self._scroll_into_view(target, point)
|
||||
|
||||
def _scroll_into_view(self, target, point) -> None:
|
||||
"""Selecting a target on the map (or cycling selection some other
|
||||
way) should bring its card on-screen if the sidebar's scrolled
|
||||
past it -- otherwise "selected" is invisible state the map alone
|
||||
shows, and the firing panel this is FOR doesn't actually show what
|
||||
got picked. A no-op if the card's already fully visible, this
|
||||
only nudges the scroll position the minimum needed, never
|
||||
recentres unnecessarily."""
|
||||
card = next(
|
||||
(c for c, p in self._cards_by_target.get(target, []) if point is None or p == point),
|
||||
None,
|
||||
)
|
||||
if card is None:
|
||||
return
|
||||
ok, bounds = card.compute_bounds(self._list_box)
|
||||
if not ok:
|
||||
return # not laid out yet (e.g. called right after a rebuild); skip rather than guess
|
||||
vadj = self._scroller.get_vadjustment()
|
||||
top, bottom = bounds.get_y(), bounds.get_y() + bounds.get_height()
|
||||
view_top, view_bottom = vadj.get_value(), vadj.get_value() + vadj.get_page_size()
|
||||
if top < view_top:
|
||||
vadj.set_value(top)
|
||||
elif bottom > view_bottom:
|
||||
vadj.set_value(bottom - vadj.get_page_size())
|
||||
|
||||
def set_hovered(self, target, point=None) -> None:
|
||||
if target is self.hovered and point == self.hovered_point:
|
||||
@@ -203,6 +246,28 @@ class FiringPanel(Gtk.Box):
|
||||
self.hovered, self.hovered_point = target, point
|
||||
self._restyle(self.hovered, self.hovered_point, _HOVERED_CSS, True)
|
||||
|
||||
def _rebuild_one(self, target: Target) -> None:
|
||||
"""Rebuild just `target`'s own card(s) in place -- O(1) in the
|
||||
number of OTHER targets on the board, unlike refresh() (which
|
||||
tears down and rebuilds every card) -- for a mutation that only
|
||||
changes this target's own display and can never add/remove a
|
||||
card or move anything in the sort order (see
|
||||
_cycle_assignment/_pick_shell; _toggle_alive uses this only when
|
||||
that's also true for it, falling back to refresh() otherwise).
|
||||
"""
|
||||
old_cards = self._cards_by_target.get(target)
|
||||
if not old_cards:
|
||||
return # not currently shown (e.g. filtered out) -- nothing to update
|
||||
new_cards = self._build_cards(target)
|
||||
for (old_widget, _old_point), (new_widget, new_point) in zip(old_cards, new_cards):
|
||||
self._list_box.insert_child_after(new_widget, old_widget)
|
||||
self._list_box.remove(old_widget)
|
||||
if target is self.selected and (self.selected_point is None or new_point == self.selected_point):
|
||||
new_widget.add_css_class(_SELECTED_CSS)
|
||||
if target is self.hovered and (self.hovered_point is None or new_point == self.hovered_point):
|
||||
new_widget.add_css_class(_HOVERED_CSS)
|
||||
self._cards_by_target[target] = new_cards
|
||||
|
||||
def _restyle(self, target, point, css_class: str, add: bool) -> None:
|
||||
"""point=None means "the whole target" (every one of its cards);
|
||||
otherwise only the card for that specific ambiguous candidate,
|
||||
@@ -369,7 +434,16 @@ class FiringPanel(Gtk.Box):
|
||||
if dragged not in targets or drop_onto not in targets:
|
||||
return
|
||||
self.board.reorder_target(dragged, targets.index(drop_onto))
|
||||
self.on_change()
|
||||
# NOT self.on_change(): that's app.py's "single choke point" full
|
||||
# refresh (re-run the solver over every target's clues, dedupe,
|
||||
# redraw the map, THEN rebuild this panel), all of it wasted work
|
||||
# for a pure order change -- no location/clue/coord/alive state
|
||||
# moved, so nothing the solver or the map drawing cares about
|
||||
# changed, only this panel's own card order did. Calling that
|
||||
# full pipeline on every single drag-drop was what made
|
||||
# reordering feel laggy; a local refresh() is the only rebuild a
|
||||
# reorder actually needs.
|
||||
self.refresh()
|
||||
|
||||
def _build_unresolved_card(self, target: Target) -> Gtk.Widget:
|
||||
card, inner = self._build_card_shell(target)
|
||||
@@ -496,14 +570,39 @@ class FiringPanel(Gtk.Box):
|
||||
return row
|
||||
|
||||
def _cycle_assignment(self, target: Target) -> None:
|
||||
# Assignment (L/R/unassigned) isn't drawn on the map at all, so
|
||||
# this doesn't even need on_visual_change, just the card itself.
|
||||
idx = _ASSIGNMENT_STATES.index(target.assignment)
|
||||
target.assignment = _ASSIGNMENT_STATES[(idx + 1) % len(_ASSIGNMENT_STATES)]
|
||||
self.on_change()
|
||||
self._rebuild_one(target)
|
||||
|
||||
def _toggle_alive(self, target: Target) -> None:
|
||||
target.alive = not target.alive
|
||||
self.on_change()
|
||||
self.refresh_after_alive_change(target)
|
||||
|
||||
def refresh_after_alive_change(self, target: Target) -> None:
|
||||
"""The display-only aftermath of target.alive flipping, split out
|
||||
from _toggle_alive so app.py's map-popover "Mark destroyed"/"Mark
|
||||
alive" (which flips target.alive itself, reaching this same
|
||||
target) can reuse the same cheap-when-possible logic rather than
|
||||
going through on_change()'s full solver+dedupe+canvas+panel pass
|
||||
again -- exactly the rebuild this class exists to avoid paying
|
||||
for a change that was never going to affect the solver or dedupe.
|
||||
|
||||
A card's presence/position can depend on alive (show_dead "hide"
|
||||
drops dead cards entirely, "sort_later" moves them to their own
|
||||
group at the bottom) -- only "show" guarantees this card stays
|
||||
exactly where it is, just dimmed, so only that mode gets the
|
||||
cheap single-card path; the other two need this panel's own full
|
||||
rebuild (still far cheaper than on_change()'s, since it skips
|
||||
everything but the last step)."""
|
||||
if self.show_dead == "show":
|
||||
self._rebuild_one(target)
|
||||
else:
|
||||
self.refresh()
|
||||
self.on_visual_change() # dead dimming / hide_dead_from_map affects the map too
|
||||
|
||||
def _pick_shell(self, target: Target, shell: Shell) -> None:
|
||||
target.shell = shell
|
||||
self.on_change()
|
||||
self._rebuild_one(target)
|
||||
self.on_visual_change() # a selected/pinned target's blast-radius circle depends on its shell
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
"""The one modal in the map-import flow: confirm or fix the detected grid.
|
||||
|
||||
Nothing else belongs here. Unit detection happens *after* this dialog closes,
|
||||
because every unit position is expressed in grid coordinates -- detecting
|
||||
against a grid the user is about to drag would only be thrown away.
|
||||
|
||||
The correction handles are the four corners of one cell, not of the whole
|
||||
screenshot. A homography has 8 degrees of freedom and each dragged corner
|
||||
contributes 2, so four corners of a single known cell pin it exactly, and a
|
||||
cell near the frame centre is the one whose corners are easiest to place
|
||||
accurately by eye. Dragging any handle refits the whole grid immediately, so
|
||||
the feedback is the entire reconstructed lattice moving, not just a dot.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import cairo
|
||||
import gi
|
||||
import numpy as np
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
|
||||
from gi.repository import Adw, Gtk # noqa: E402
|
||||
|
||||
from . import map_vision # noqa: E402
|
||||
|
||||
HANDLE_R = 9.0 # drawn radius of a corner handle, widget px
|
||||
GRAB_R = 22.0 # how close a press has to be to grab one
|
||||
|
||||
|
||||
def _surface_from_bgr(img):
|
||||
"""A cairo surface over a numpy BGR image.
|
||||
|
||||
cairo's RGB24 is a 32-bit pixel laid out as B,G,R,x in memory on a
|
||||
little-endian machine, which is exactly BGRA, so the converted array can
|
||||
back the surface directly with no per-pixel work. The array is kept alive
|
||||
by the caller holding it: create_for_data does not copy.
|
||||
"""
|
||||
import cv2
|
||||
|
||||
bgra = cv2.cvtColor(img, cv2.COLOR_BGR2BGRA)
|
||||
bgra = np.ascontiguousarray(bgra)
|
||||
h, w = bgra.shape[:2]
|
||||
surface = cairo.ImageSurface.create_for_data(
|
||||
memoryview(bgra), cairo.FORMAT_RGB24, w, h, w * 4)
|
||||
return surface, bgra
|
||||
|
||||
|
||||
class GridFixDialog(Adw.Dialog):
|
||||
"""Shows the screenshot with the reconstructed grid drawn over it, plus
|
||||
four draggable corner handles. on_accept(solution) gets whatever grid is
|
||||
on screen when Accept is pressed."""
|
||||
|
||||
def __init__(self, *, image, solution, on_accept, on_discard=None):
|
||||
super().__init__(title="Check the detected grid",
|
||||
content_width=900, content_height=760)
|
||||
self._image = image
|
||||
self._auto = solution
|
||||
self._sol = solution
|
||||
self._on_accept = on_accept
|
||||
self._on_discard = on_discard
|
||||
self._surface, self._keepalive = _surface_from_bgr(image)
|
||||
self._dragging = None # index of the handle being dragged
|
||||
self._drag_from = None # its position when the drag began
|
||||
|
||||
quad = map_vision.centre_cell_quad(solution, image.shape)
|
||||
# (label, [4 pixel corners], [4 grid corners]); the pixel corners move
|
||||
# with the mouse, the grid corners are what they are supposed to BE and
|
||||
# never change -- that pairing is the correspondence set refitted from.
|
||||
self._label = quad[0] if quad else None
|
||||
self._px = list(quad[1]) if quad else []
|
||||
self._grid = list(quad[2]) if quad else []
|
||||
|
||||
view = Adw.ToolbarView()
|
||||
view.add_top_bar(Adw.HeaderBar())
|
||||
self.set_child(view)
|
||||
|
||||
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10,
|
||||
margin_top=10, margin_bottom=10, margin_start=10, margin_end=10)
|
||||
|
||||
self._area = Gtk.DrawingArea(vexpand=True, hexpand=True)
|
||||
self._area.set_draw_func(self._draw)
|
||||
drag = Gtk.GestureDrag()
|
||||
drag.connect("drag-begin", self._on_drag_begin)
|
||||
drag.connect("drag-update", self._on_drag_update)
|
||||
drag.connect("drag-end", lambda *_a: setattr(self, "_dragging", None))
|
||||
self._area.add_controller(drag)
|
||||
outer.append(self._area)
|
||||
|
||||
cell = self._label or "?"
|
||||
self._hint = Gtk.Label(xalign=0, css_classes=["dim-label"], wrap=True)
|
||||
self._hint.set_label(
|
||||
f"Grid solved from {solution.votes} label read(s). "
|
||||
f"If it is off, drag the four handles onto the corners of cell {cell}."
|
||||
if self._px else
|
||||
f"Grid solved from {solution.votes} label read(s)."
|
||||
)
|
||||
outer.append(self._hint)
|
||||
|
||||
buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8,
|
||||
halign=Gtk.Align.END)
|
||||
discard = Gtk.Button(label="Discard", css_classes=["pill"])
|
||||
discard.connect("clicked", lambda _b: self._discard())
|
||||
buttons.append(discard)
|
||||
if self._px:
|
||||
reset = Gtk.Button(label="Reset", css_classes=["pill"],
|
||||
tooltip_text="Back to the automatically detected grid")
|
||||
reset.connect("clicked", lambda _b: self._reset())
|
||||
buttons.append(reset)
|
||||
accept = Gtk.Button(label="Use this grid",
|
||||
css_classes=["pill", "suggested-action"])
|
||||
accept.connect("clicked", lambda _b: self._accept())
|
||||
buttons.append(accept)
|
||||
outer.append(buttons)
|
||||
|
||||
view.set_content(outer)
|
||||
|
||||
# -- geometry ------------------------------------------------------------
|
||||
def _fit(self):
|
||||
"""(scale, ox, oy) letterboxing the screenshot into the drawing area."""
|
||||
w, h = self._area.get_width(), self._area.get_height()
|
||||
ih, iw = self._image.shape[:2]
|
||||
if not w or not h:
|
||||
return 1.0, 0.0, 0.0
|
||||
s = min(w / iw, h / ih)
|
||||
return s, (w - iw * s) / 2, (h - ih * s) / 2
|
||||
|
||||
def _to_widget(self, p):
|
||||
s, ox, oy = self._fit()
|
||||
return p[0] * s + ox, p[1] * s + oy
|
||||
|
||||
def _to_image(self, x, y):
|
||||
s, ox, oy = self._fit()
|
||||
return (x - ox) / s, (y - oy) / s
|
||||
|
||||
def _refit(self):
|
||||
"""Rebuild the grid from the four handle positions.
|
||||
|
||||
A bad drag (two handles on top of each other) makes the homography
|
||||
degenerate; keep the previous grid rather than crash, the next drag
|
||||
update recovers.
|
||||
"""
|
||||
try:
|
||||
self._sol = map_vision.solution_from_correspondences(
|
||||
list(zip(self._grid, self._px)))
|
||||
except (ValueError, np.linalg.LinAlgError):
|
||||
pass
|
||||
self._area.queue_draw()
|
||||
|
||||
def _reset(self):
|
||||
quad = map_vision.centre_cell_quad(self._auto, self._image.shape)
|
||||
if quad:
|
||||
self._px = list(quad[1])
|
||||
self._sol = self._auto
|
||||
self._area.queue_draw()
|
||||
|
||||
# -- input ---------------------------------------------------------------
|
||||
def _on_drag_begin(self, _gesture, x, y):
|
||||
self._dragging = None
|
||||
best = GRAB_R
|
||||
for i, p in enumerate(self._px):
|
||||
wx, wy = self._to_widget(p)
|
||||
d = ((wx - x) ** 2 + (wy - y) ** 2) ** 0.5
|
||||
if d < best:
|
||||
best, self._dragging = d, i
|
||||
if self._dragging is not None:
|
||||
self._drag_from = self._px[self._dragging]
|
||||
|
||||
def _on_drag_update(self, _gesture, dx, dy):
|
||||
if self._dragging is None:
|
||||
return
|
||||
s, _ox, _oy = self._fit()
|
||||
if s <= 0:
|
||||
return
|
||||
fx, fy = self._drag_from
|
||||
self._px[self._dragging] = (fx + dx / s, fy + dy / s)
|
||||
self._refit()
|
||||
|
||||
def _accept(self):
|
||||
self.close()
|
||||
self._on_accept(self._sol)
|
||||
|
||||
def _discard(self):
|
||||
self.close()
|
||||
if self._on_discard is not None:
|
||||
self._on_discard()
|
||||
|
||||
# -- drawing -------------------------------------------------------------
|
||||
def _draw(self, _area, cr, width, height):
|
||||
cr.set_source_rgb(0.08, 0.08, 0.08)
|
||||
cr.paint()
|
||||
s, ox, oy = self._fit()
|
||||
|
||||
cr.save()
|
||||
cr.translate(ox, oy)
|
||||
cr.scale(s, s)
|
||||
cr.set_source_surface(self._surface, 0, 0)
|
||||
cr.get_source().set_filter(cairo.FILTER_GOOD)
|
||||
cr.paint()
|
||||
cr.restore()
|
||||
|
||||
self._draw_grid(cr)
|
||||
|
||||
for i, p in enumerate(self._px):
|
||||
wx, wy = self._to_widget(p)
|
||||
# new_path() before every arc: cairo's arc() joins the current point
|
||||
# to the arc's start, and _draw_grid leaves one behind at the last
|
||||
# cell name it drew. Without this, the first handle gets a stray
|
||||
# line reaching across the whole screenshot from that label.
|
||||
cr.new_path()
|
||||
cr.set_source_rgb(1.0, 0.85, 0.1)
|
||||
cr.arc(wx, wy, HANDLE_R, 0, 2 * math.pi)
|
||||
cr.fill_preserve()
|
||||
cr.set_source_rgb(0.1, 0.1, 0.1)
|
||||
cr.set_line_width(2.0)
|
||||
cr.stroke()
|
||||
if i == self._dragging:
|
||||
cr.new_path()
|
||||
cr.set_source_rgb(1.0, 1.0, 1.0)
|
||||
cr.arc(wx, wy, HANDLE_R + 4, 0, 2 * math.pi)
|
||||
cr.set_line_width(1.5)
|
||||
cr.stroke()
|
||||
|
||||
def _draw_grid(self, cr):
|
||||
"""Every in-range cell the grid puts inside the frame, with its name
|
||||
drawn where the game draws it. A wrong grid is obvious precisely
|
||||
because those names land off the painted labels."""
|
||||
sol = self._sol
|
||||
h, w = self._image.shape[:2]
|
||||
inv = np.linalg.inv(sol.H)
|
||||
corners = inv @ np.array([[0, w, w, 0], [0, 0, h, h], [1, 1, 1, 1.0]])
|
||||
if np.any(np.abs(corners[2]) < 1e-9):
|
||||
return
|
||||
ij = corners[:2] / corners[2]
|
||||
cr.set_line_width(1.6)
|
||||
cr.select_font_face("Sans", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD)
|
||||
L2G = sol.lattice_to_grid()
|
||||
for i in range(int(np.floor(ij[0].min())) - 1, int(np.ceil(ij[0].max())) + 2):
|
||||
for j in range(int(np.floor(ij[1].min())) - 1, int(np.ceil(ij[1].max())) + 2):
|
||||
g = L2G @ np.array([i, j, 1.0])
|
||||
col, row = int(round(g[0])), int(round(g[1]))
|
||||
if not (0 <= col < map_vision.COLS and 1 <= row <= map_vision.ROWS):
|
||||
continue
|
||||
quad = sol.H @ np.array([[i, i + 1, i + 1, i],
|
||||
[j, j, j + 1, j + 1], [1, 1, 1, 1.0]])
|
||||
if np.any(np.abs(quad[2]) < 1e-9):
|
||||
continue
|
||||
pts = [self._to_widget(p) for p in (quad[:2] / quad[2]).T]
|
||||
cr.new_path()
|
||||
cr.set_source_rgba(1.0, 1.0, 0.2, 0.75)
|
||||
cr.move_to(*pts[0])
|
||||
for p in pts[1:]:
|
||||
cr.line_to(*p)
|
||||
cr.close_path()
|
||||
cr.stroke()
|
||||
|
||||
# The game pads a cell's label in from its top-left corner by a
|
||||
# fixed fraction of the cell, which is also how the solver finds
|
||||
# labels in the first place (see map_vision.PAD_L/PAD_T).
|
||||
lx = i + (map_vision.PAD_L if sol.si > 0 else 1 - map_vision.PAD_L)
|
||||
ly = j + (map_vision.PAD_T if sol.sj > 0 else 1 - map_vision.PAD_T)
|
||||
t = sol.H @ np.array([lx, ly, 1.0])
|
||||
if abs(t[2]) < 1e-9:
|
||||
continue
|
||||
tx, ty = self._to_widget((t[0] / t[2], t[1] / t[2]))
|
||||
side = float(np.hypot(pts[1][0] - pts[0][0], pts[1][1] - pts[0][1]))
|
||||
cr.set_font_size(max(9.0, min(30.0, side * 0.16)))
|
||||
name = f"{map_vision.LARGE_X[col]}{row}"
|
||||
cr.move_to(tx, ty)
|
||||
cr.set_source_rgba(0, 0, 0, 0.8)
|
||||
cr.text_path(name)
|
||||
cr.set_line_width(3.0)
|
||||
cr.stroke()
|
||||
cr.move_to(tx, ty)
|
||||
cr.set_source_rgb(0.3, 1.0, 0.3)
|
||||
cr.show_text(name)
|
||||
@@ -11,10 +11,13 @@ from collections import namedtuple
|
||||
|
||||
import cairo
|
||||
import gi
|
||||
import numpy as np
|
||||
from PIL import Image as PILImage
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Gdk", "4.0")
|
||||
from gi.repository import Gdk, Gtk # noqa: E402
|
||||
gi.require_version("Adw", "1")
|
||||
from gi.repository import Adw, Gdk, Gtk # noqa: E402
|
||||
|
||||
from . import ballistics, icons, solver
|
||||
from .models import LARGE_X, Board, Target
|
||||
@@ -29,6 +32,9 @@ MARGIN_BOTTOM = 30
|
||||
LABEL_PAD = 8 # gap between a marker and its name label
|
||||
|
||||
HOVER_RADIUS_PX = 12
|
||||
# An imported screenshot is a backdrop, not the subject: slightly transparent so
|
||||
# the grid lines and markers drawn over it stay legible.
|
||||
SCREENSHOT_ALPHA = 0.88
|
||||
OVERLAY_RAY_LENGTH_KM = 30.0 # long enough to cross the 20x10 map from any origin
|
||||
|
||||
MIN_ZOOM = 1.0 # the whole 20x10 map fits, the default
|
||||
@@ -52,6 +58,19 @@ ICON_MIN_CELL_PX = 42
|
||||
# unless zoomed in and panned).
|
||||
_View = namedtuple("_View", "cell_w cell_h grid_w grid_h pad_x pad_y ox oy vis_cols vis_rows")
|
||||
|
||||
# Everything below (CATEGORY_COLOR through PLACEMENT_PREVIEW) is a
|
||||
# module-level name deliberately kept mutable: _apply_palette() below
|
||||
# reassigns all of them via `global`, in place, whenever the app's
|
||||
# light/dark scheme changes (see GridCanvas.__init__, which hooks
|
||||
# Adw.StyleManager's own dark/light detection, including live updates
|
||||
# if the system theme changes while running). Every draw method
|
||||
# references these bare names directly (`cr.set_source_rgb(*BG)` etc.)
|
||||
# rather than threading a palette object through every call, reassigning
|
||||
# the names in place is what makes that keep working without touching
|
||||
# every call site. The values set here at import time are the dark
|
||||
# palette, _apply_palette(is_dark=True) (called from __init__) reapplies
|
||||
# the same values, it's the light branch that actually changes anything
|
||||
# the first time it runs.
|
||||
CATEGORY_COLOR = {
|
||||
"nest": (0.35, 0.60, 0.95),
|
||||
"spotter": (0.35, 0.78, 0.40),
|
||||
@@ -63,17 +82,89 @@ SCOUT_FLIGHT = (0.70, 0.45, 0.92)
|
||||
|
||||
BG = (0.13, 0.12, 0.10)
|
||||
GRID_LINE = (1.0, 1.0, 1.0, 0.20)
|
||||
SUBGRID_LINE = (0.72, 0.70, 0.65, 0.35) # a shade between BG and GRID_LINE's white, not a hue change
|
||||
SUBGRID_LINE = (0.72, 0.70, 0.65, 0.15) # verified by actually computing the blended-over-BG
|
||||
# pixel values, not eyeballing it: alpha 0.35 (a previous version) blended this same RGB out to
|
||||
# (86, 83, 75), BRIGHTER than GRID_LINE's own blended (77, 76, 72), backwards from the intent.
|
||||
# 0.15 blends to (56, 53, 47): sits between BG (33, 31, 26) and GRID_LINE (77, 76, 72), the RGB
|
||||
# tint stays visible but the line itself reads as genuinely fainter, not louder.
|
||||
HOVER_LEGEND = (0.45, 0.65, 0.95) # blue, not yellow, for the highlighted X/Y legend label
|
||||
LABEL = (0.88, 0.86, 0.80)
|
||||
COORD_LABEL = (0.60, 0.58, 0.54)
|
||||
YELLOW = (0.95, 0.85, 0.20)
|
||||
WHITE = (1.0, 1.0, 1.0)
|
||||
WHITE = (1.0, 1.0, 1.0) # not literally "white" any more in the light palette, see _LIGHT_PALETTE:
|
||||
# its role is "a neutral that maximally contrasts with BG", the name stuck around from when this
|
||||
# only ever ran on a dark background.
|
||||
FIRING_ARROW = (0.95, 0.15, 0.15)
|
||||
SELECTION_RING = (1.0, 1.0, 1.0)
|
||||
BLAST_RADIUS = (0.95, 0.40, 0.10)
|
||||
PLACEMENT_PREVIEW = (0.95, 0.85, 0.20)
|
||||
|
||||
_DARK_PALETTE = dict(
|
||||
CATEGORY_COLOR={
|
||||
"nest": (0.35, 0.60, 0.95), "spotter": (0.35, 0.78, 0.40),
|
||||
"rp": (0.95, 0.78, 0.20), "target": (0.92, 0.30, 0.28), "ally": (0.30, 0.85, 0.85),
|
||||
},
|
||||
SCOUT_FLIGHT=(0.70, 0.45, 0.92),
|
||||
BG=(0.13, 0.12, 0.10),
|
||||
GRID_LINE=(1.0, 1.0, 1.0, 0.20),
|
||||
SUBGRID_LINE=(0.72, 0.70, 0.65, 0.15),
|
||||
HOVER_LEGEND=(0.45, 0.65, 0.95),
|
||||
LABEL=(0.88, 0.86, 0.80),
|
||||
COORD_LABEL=(0.60, 0.58, 0.54),
|
||||
YELLOW=(0.95, 0.85, 0.20),
|
||||
WHITE=(1.0, 1.0, 1.0),
|
||||
FIRING_ARROW=(0.95, 0.15, 0.15),
|
||||
SELECTION_RING=(1.0, 1.0, 1.0),
|
||||
BLAST_RADIUS=(0.95, 0.40, 0.10),
|
||||
PLACEMENT_PREVIEW=(0.95, 0.85, 0.20),
|
||||
)
|
||||
|
||||
# Same relative brightness relationships as the dark palette (main grid
|
||||
# line vs. the fainter subgrid one, category colors distinct from each
|
||||
# other), just inverted for a light background: every color that needs
|
||||
# to contrast against BG got darkened instead of brightened. First-pass
|
||||
# guesses, flagged the same way the Shell descriptions were, correct
|
||||
# whichever look off once actually seen on a real light-themed desktop.
|
||||
_LIGHT_PALETTE = dict(
|
||||
CATEGORY_COLOR={
|
||||
"nest": (0.15, 0.35, 0.75), "spotter": (0.10, 0.50, 0.15),
|
||||
"rp": (0.65, 0.50, 0.05), "target": (0.75, 0.12, 0.10), "ally": (0.05, 0.45, 0.45),
|
||||
},
|
||||
SCOUT_FLIGHT=(0.45, 0.20, 0.65),
|
||||
BG=(0.96, 0.95, 0.93),
|
||||
GRID_LINE=(0.08, 0.08, 0.08, 0.20),
|
||||
SUBGRID_LINE=(0.08, 0.08, 0.08, 0.15),
|
||||
HOVER_LEGEND=(0.10, 0.35, 0.75),
|
||||
LABEL=(0.15, 0.14, 0.12),
|
||||
COORD_LABEL=(0.42, 0.40, 0.37),
|
||||
YELLOW=(0.65, 0.48, 0.02),
|
||||
WHITE=(0.10, 0.10, 0.10),
|
||||
FIRING_ARROW=(0.80, 0.10, 0.10),
|
||||
SELECTION_RING=(0.05, 0.05, 0.05),
|
||||
BLAST_RADIUS=(0.80, 0.35, 0.05),
|
||||
PLACEMENT_PREVIEW=(0.65, 0.48, 0.02),
|
||||
)
|
||||
|
||||
|
||||
def _apply_palette(is_dark: bool) -> None:
|
||||
global CATEGORY_COLOR, SCOUT_FLIGHT, BG, GRID_LINE, SUBGRID_LINE, HOVER_LEGEND, LABEL, \
|
||||
COORD_LABEL, YELLOW, WHITE, FIRING_ARROW, SELECTION_RING, BLAST_RADIUS, PLACEMENT_PREVIEW
|
||||
p = _DARK_PALETTE if is_dark else _LIGHT_PALETTE
|
||||
CATEGORY_COLOR = p["CATEGORY_COLOR"]
|
||||
SCOUT_FLIGHT = p["SCOUT_FLIGHT"]
|
||||
BG = p["BG"]
|
||||
GRID_LINE = p["GRID_LINE"]
|
||||
SUBGRID_LINE = p["SUBGRID_LINE"]
|
||||
HOVER_LEGEND = p["HOVER_LEGEND"]
|
||||
LABEL = p["LABEL"]
|
||||
COORD_LABEL = p["COORD_LABEL"]
|
||||
YELLOW = p["YELLOW"]
|
||||
WHITE = p["WHITE"]
|
||||
FIRING_ARROW = p["FIRING_ARROW"]
|
||||
SELECTION_RING = p["SELECTION_RING"]
|
||||
BLAST_RADIUS = p["BLAST_RADIUS"]
|
||||
PLACEMENT_PREVIEW = p["PLACEMENT_PREVIEW"]
|
||||
|
||||
# path -> loaded cairo.ImageSurface (or None for a path that failed to
|
||||
# load, so a missing/bad icon file only ever gets one failed attempt,
|
||||
# not one per frame). Module-level, not per-canvas: the icon set is
|
||||
@@ -105,10 +196,58 @@ def _icon_for(category: str, obj) -> cairo.ImageSurface | None:
|
||||
return None
|
||||
|
||||
|
||||
# path -> (surface, content_bbox) for additive badges specifically.
|
||||
# Separate from _ICON_SURFACE_CACHE because these also need their real
|
||||
# opaque content's bounding box: unlike the unit icons (already ~edge to
|
||||
# edge in their own canvas, see _draw_icon_marker), the additive art
|
||||
# (assets/icons/targets/additives/) sits inside a lot of transparent
|
||||
# padding that isn't even centered -- scaling/positioning off the full
|
||||
# 256x256 canvas made the badge look tiny and float with a visible gap
|
||||
# above the icon it's supposed to touch. bbox is None for a path that
|
||||
# failed to load, or (l, t, r, b) of its actual opaque pixels.
|
||||
_ADDITIVE_CACHE: dict = {}
|
||||
|
||||
|
||||
def _additive_surface(path) -> tuple:
|
||||
if path not in _ADDITIVE_CACHE:
|
||||
surface, bbox = None, None
|
||||
try:
|
||||
surface = cairo.ImageSurface.create_from_png(str(path))
|
||||
bbox = PILImage.open(str(path)).getbbox()
|
||||
except Exception:
|
||||
pass
|
||||
_ADDITIVE_CACHE[path] = (surface, bbox)
|
||||
return _ADDITIVE_CACHE[path]
|
||||
|
||||
|
||||
def _additive_for(category: str, obj) -> tuple | None:
|
||||
"""The underground-tier badge overlaid on top of a Target's own icon,
|
||||
or None. Target-only (see Target.underground_tier's own comment)."""
|
||||
if category != "target":
|
||||
return None
|
||||
tier = getattr(obj, "underground_tier", None)
|
||||
if tier is None:
|
||||
return None
|
||||
surface, bbox = _additive_surface(icons.underground_icon_path(tier))
|
||||
if surface is None:
|
||||
return None
|
||||
return (surface, bbox)
|
||||
|
||||
|
||||
class GridCanvas(Gtk.DrawingArea):
|
||||
def __init__(self, board: Board) -> None:
|
||||
super().__init__()
|
||||
self.board = board
|
||||
|
||||
# Follow the app's light/dark scheme (system setting, or an
|
||||
# in-app override if one's ever added later) for every color
|
||||
# this canvas draws with, live: if the scheme changes while
|
||||
# running, redraw with the other palette rather than staying
|
||||
# stuck on whichever was active at startup.
|
||||
style_manager = Adw.StyleManager.get_default()
|
||||
_apply_palette(style_manager.get_dark())
|
||||
style_manager.connect("notify::dark", self._on_style_changed)
|
||||
|
||||
self.hovered = None
|
||||
self.hovered_point = None # which candidate, when obj has more than one point
|
||||
self.selected = None
|
||||
@@ -116,9 +255,26 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
self.on_select = None # callback(obj | None, point | None), fired on click
|
||||
self.on_hover_change = None # callback(obj | None, point | None), fired on hover change
|
||||
self.on_cursor_move = None # callback((col, row) km | None), fired on every motion/leave
|
||||
self.on_right_click = None # callback(Coord, x, y), fired on right-click (unless placing)
|
||||
# callback(proposal, x, y): fired when an imported screenshot's pending
|
||||
# proposal is clicked with either button. A proposal exists only to be
|
||||
# accepted or rejected, so plain clicking it offers that rather than
|
||||
# selecting something the board doesn't contain yet.
|
||||
self.on_proposal_click = None
|
||||
# callback(Coord, x, y, obj, point): fired on right-click unless placing.
|
||||
# obj/point are the entity under the cursor when there is one (same
|
||||
# hit test as left-click selection), so the handler can offer actions
|
||||
# on that entity instead of the place-something-here menu.
|
||||
self.on_right_click = None
|
||||
self.hide_dead_from_map = False # off by default; toggled from the firing panel toolbar
|
||||
|
||||
# An imported map screenshot, rectified into board space, drawn under
|
||||
# everything else, plus the units detected in it as [(proposal, Coord)].
|
||||
# Proposals are kept separate from board entities on purpose: they are
|
||||
# not on the board until accepted, so nothing that walks the board can
|
||||
# see them, and they get their own hit test.
|
||||
self._screenshot = None # (cairo surface, backing array, px_per_km)
|
||||
self.proposals = []
|
||||
|
||||
# Which large cell the cursor is currently over, (col, row) both
|
||||
# floored, or None off the map/off the widget entirely. Redrawn
|
||||
# only when this actually changes cell (not on every pixel of
|
||||
@@ -200,6 +356,10 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
def refresh(self) -> None:
|
||||
self.queue_draw()
|
||||
|
||||
def _on_style_changed(self, style_manager, _pspec) -> None:
|
||||
_apply_palette(style_manager.get_dark())
|
||||
self.queue_draw()
|
||||
|
||||
# -- placement mode -----------------------------------------------------------
|
||||
def start_placement(self, callback, preview_radius_km=None) -> None:
|
||||
self.placement_callback = callback
|
||||
@@ -410,6 +570,94 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
for candidate in obj.location.potential_coords:
|
||||
yield obj, candidate
|
||||
|
||||
# -- imported screenshot ---------------------------------------------------
|
||||
def set_screenshot(self, bgra, px_per_km: int) -> None:
|
||||
"""Show a rectified map screenshot as the board's backdrop.
|
||||
|
||||
`bgra` covers the whole board (COLS x ROWS km at px_per_km), transparent
|
||||
wherever the screenshot didn't reach, so a partial view of the table
|
||||
doesn't blank out the rest of the map. Pre-warping into board space is
|
||||
what makes this drawable at all: cairo has no projective transform, but
|
||||
once the image is rectified a plain scale and translate places it.
|
||||
"""
|
||||
if bgra is None:
|
||||
self._screenshot = None
|
||||
self.queue_draw()
|
||||
return
|
||||
buf = np.ascontiguousarray(bgra)
|
||||
h, w = buf.shape[:2]
|
||||
surface = cairo.ImageSurface.create_for_data(
|
||||
memoryview(buf), cairo.FORMAT_ARGB32, w, h, w * 4)
|
||||
# The array must outlive the surface: create_for_data does not copy.
|
||||
self._screenshot = (surface, buf, px_per_km)
|
||||
self.queue_draw()
|
||||
|
||||
def has_screenshot(self) -> bool:
|
||||
return self._screenshot is not None
|
||||
|
||||
def set_proposals(self, proposals) -> None:
|
||||
"""proposals is [(proposal, Coord)]; the widget only reads the Coord and
|
||||
the proposal's accepted/rejected flags, so it stays ignorant of
|
||||
map_import's own coordinate format."""
|
||||
self.proposals = list(proposals)
|
||||
self.queue_draw()
|
||||
|
||||
def _pending_proposals(self):
|
||||
return [(p, c) for p, c in self.proposals if p.pending]
|
||||
|
||||
def hit_test_proposal(self, view: _View, x: float, y: float):
|
||||
"""The pending proposal nearest the cursor within range, or None."""
|
||||
best, best_dist = None, HOVER_RADIUS_PX
|
||||
for p, coord in self._pending_proposals():
|
||||
px, py = self._km_to_px(view, coord.as_fraction())
|
||||
dist = math.hypot(px - x, py - y)
|
||||
if dist < best_dist:
|
||||
best_dist, best = dist, p
|
||||
return best
|
||||
|
||||
def _draw_screenshot(self, cr, view) -> None:
|
||||
surface, buf, px_per_km = self._screenshot
|
||||
# Board space runs col 0..COLS rightward and row 0..ROWS upward, so the
|
||||
# image's top-left pixel is (col 0, row ROWS) -- the top-left corner.
|
||||
x0, y0 = self._km_to_px(view, (0, ROWS))
|
||||
x1, y1 = self._km_to_px(view, (COLS, 0))
|
||||
ih, iw = buf.shape[:2]
|
||||
if iw <= 0 or ih <= 0:
|
||||
return
|
||||
cr.save()
|
||||
cr.translate(x0, y0)
|
||||
cr.scale((x1 - x0) / iw, (y1 - y0) / ih)
|
||||
cr.set_source_surface(surface, 0, 0)
|
||||
cr.get_source().set_filter(cairo.FILTER_GOOD)
|
||||
cr.paint_with_alpha(SCREENSHOT_ALPHA)
|
||||
cr.restore()
|
||||
|
||||
def _draw_proposals(self, cr, view, width, height) -> None:
|
||||
"""Detected-but-unconfirmed units. Drawn hollow, the same shape the map
|
||||
already uses for "this might be where it is", because that is exactly
|
||||
what a proposal is until the user accepts it."""
|
||||
for p, coord in self._pending_proposals():
|
||||
color = CATEGORY_COLOR["ally" if p.side == "friendly" else "target"]
|
||||
# No coord here: _draw_marker already shows one right below
|
||||
# this label (`coord=coord` below), repeating it in the main
|
||||
# label too was pure noise. detected_type/detected_id
|
||||
# (map_vision.classify_marker/read_marker_id's best-effort
|
||||
# reads) shown instead when known -- same "? Mechanized#3"
|
||||
# shape an accepted entity's own name takes (Target.name),
|
||||
# checkable against the actual screenshot pixels while it's
|
||||
# still up, and detected_id is the same id _accept_proposal
|
||||
# will use for the entity if this gets accepted.
|
||||
detected_type = icons.target_type_from_icon(p.unit)
|
||||
# .short, not target_type_label(): matches Target.name/Ally.name's
|
||||
# own naming exactly ("SupplyCache" not "Supply Cache"), so this
|
||||
# preview label reads the same as what accepting it produces.
|
||||
type_part = detected_type.short if detected_type else ""
|
||||
id_part = f"#{p.detected_id}" if p.detected_id else ""
|
||||
label = f"? {type_part}{id_part}" if (type_part or id_part) else "?"
|
||||
self._draw_marker(cr, view, coord.as_fraction(), color,
|
||||
label, width, height,
|
||||
hollow=True, coord=coord)
|
||||
|
||||
def _hit_test(self, view: _View, x: float, y: float):
|
||||
"""Returns (obj, coord) of the nearest marker within range, or
|
||||
(None, None), coord disambiguates which candidate of an
|
||||
@@ -491,6 +739,11 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
callback(coord)
|
||||
return
|
||||
|
||||
proposal = self.hit_test_proposal(view, x, y)
|
||||
if proposal is not None and self.on_proposal_click is not None:
|
||||
self.on_proposal_click(proposal, x, y)
|
||||
return
|
||||
|
||||
hit, coord = self._hit_test(view, x, y)
|
||||
self.set_selected(hit, coord)
|
||||
if self.on_select is not None:
|
||||
@@ -503,9 +756,17 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
if self.on_right_click is None:
|
||||
return
|
||||
view = self._view(self.get_width(), self.get_height())
|
||||
# A pending proposal wins over a board entity underneath it: it is the
|
||||
# thing the user is being asked to decide about, and it disappears as
|
||||
# soon as they do, so whatever it overlaps becomes reachable again.
|
||||
hit = self.hit_test_proposal(view, x, y)
|
||||
point = None
|
||||
if hit is None:
|
||||
hit, point = self._hit_test(view, x, y)
|
||||
coord = solver.point_to_coord(self._px_to_km(view, x, y))
|
||||
if coord is not None:
|
||||
self.on_right_click(coord, x, y)
|
||||
if coord is None and hit is None:
|
||||
return
|
||||
self.on_right_click(coord, x, y, hit, point)
|
||||
|
||||
# -- drawing ----------------------------------------------------------------
|
||||
def _draw(self, _area, cr, width, height) -> None:
|
||||
@@ -592,6 +853,11 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
cr.rectangle(MARGIN_LEFT + view.pad_x, MARGIN_TOP + view.pad_y, view.grid_w, view.grid_h)
|
||||
cr.clip()
|
||||
|
||||
# Under everything: the imported screenshot is the backdrop the rest of
|
||||
# the map is drawn on top of.
|
||||
if self._screenshot is not None:
|
||||
self._draw_screenshot(cr, view)
|
||||
|
||||
self._draw_hover_subgrid(cr, view)
|
||||
self._draw_geo_overlays(cr, view)
|
||||
self._draw_firing_arrows(cr, view)
|
||||
@@ -606,7 +872,8 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
dim=(category == "target" and not obj.alive) or obj.hidden,
|
||||
selected=(obj is self.selected), coord=obj.coord,
|
||||
extra_line=getattr(obj, "requested_time", None),
|
||||
icon_surface=_icon_for(category, obj))
|
||||
icon_surface=_icon_for(category, obj),
|
||||
additive=_additive_for(category, obj))
|
||||
|
||||
for category, obj in self.board.ambiguous_entities_all():
|
||||
if self._excluded_from_map(obj):
|
||||
@@ -620,6 +887,8 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
selected=is_selected, coord=candidate,
|
||||
extra_line=getattr(obj, "requested_time", None))
|
||||
|
||||
self._draw_proposals(cr, view, width, height)
|
||||
|
||||
for sf in self.board.scout_flights:
|
||||
if sf.hidden:
|
||||
continue # hidden means gone from the map, not just darkened, no selection to reinstate it
|
||||
@@ -665,7 +934,8 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
|
||||
def _draw_marker(self, cr, view, point_km, color, label,
|
||||
canvas_width, canvas_height, *, hollow=False, dim=False,
|
||||
selected=False, coord=None, extra_line=None, icon_surface=None) -> None:
|
||||
selected=False, coord=None, extra_line=None, icon_surface=None,
|
||||
additive=None) -> None:
|
||||
x, y = self._km_to_px(view, point_km)
|
||||
r, g, b = color
|
||||
alpha = 0.45 if dim else 1.0
|
||||
@@ -685,6 +955,8 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
# plain filled dot is more honest about the current zoom level.
|
||||
if not hollow and icon_surface is not None and view.cell_w >= ICON_MIN_CELL_PX:
|
||||
self._draw_icon_marker(cr, x, y, icon_surface, alpha)
|
||||
if additive is not None:
|
||||
self._draw_additive_badge(cr, x, y, additive, alpha)
|
||||
elif hollow:
|
||||
cr.new_path() # cairo's arc() draws a line from any stale current
|
||||
cr.set_source_rgba(r, g, b, alpha) # point (e.g. the last label's
|
||||
@@ -749,6 +1021,48 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
cr.paint_with_alpha(alpha)
|
||||
cr.restore()
|
||||
|
||||
# How far the badge's content bbox sinks into the icon's, in the
|
||||
# icon's own 32px box units. Both the diamond's top corner and the
|
||||
# Armor badge's bottom are tapered to a near-point, not a flat edge
|
||||
# (see assets/icons/targets/enemy/Enemy_Infantry.png and the Armor
|
||||
# additives) -- lining up their bboxes exactly *touching* leaves them
|
||||
# meeting at a single pixel with no visual mass on either side of it,
|
||||
# which still reads as a gap. A real pixel overlap is what actually
|
||||
# looks contiguous, confirmed against the game's own stacked-badge
|
||||
# screenshots (stars/helmet/diamond all overlapping, not edge-to-edge).
|
||||
_ADDITIVE_OVERLAP_PX = 10.0
|
||||
|
||||
def _draw_additive_badge(self, cr, x, y, additive, alpha) -> None:
|
||||
"""A badge (underground tier, currently the only additive) drawn
|
||||
directly north of the icon marker, overlapping down into it by
|
||||
`_ADDITIVE_OVERLAP_PX`, at the same full size as the marker
|
||||
itself -- stacked above it rather than shrunk into a corner, so
|
||||
it reads as its own clearly-legible symbol, not a tiny decoration
|
||||
obscuring the unit icon it modifies.
|
||||
|
||||
Scaled/positioned off the source art's actual opaque content
|
||||
(`bbox`), not its full canvas: the additive PNGs carry a lot of
|
||||
transparent padding that isn't even centered (see _ADDITIVE_CACHE's
|
||||
comment), so sizing/placing off the raw canvas made the badge look
|
||||
tiny and float with a visible gap above the icon -- using bbox
|
||||
instead makes what's actually drawn sit right against it."""
|
||||
surface, bbox = additive
|
||||
sw, sh = surface.get_width(), surface.get_height()
|
||||
left, top, right, bottom = bbox if bbox is not None else (0, 0, sw, sh)
|
||||
content_w, content_h = right - left, bottom - top
|
||||
if content_w <= 0 or content_h <= 0:
|
||||
return
|
||||
box = 32.0 # same visual size as the icon marker's own box
|
||||
scale = box / max(content_w, content_h)
|
||||
icon_top = y - 16 # _draw_icon_marker's own box=32, centered on y
|
||||
ty = icon_top - bottom * scale + self._ADDITIVE_OVERLAP_PX
|
||||
cr.save()
|
||||
cr.translate(x - (left + right) / 2 * scale, ty)
|
||||
cr.scale(scale, scale)
|
||||
cr.set_source_surface(surface, 0, 0)
|
||||
cr.paint_with_alpha(alpha)
|
||||
cr.restore()
|
||||
|
||||
def _draw_firing_arrows(self, cr, view) -> None:
|
||||
"""Red arrow(s) Nest -> Target, for whatever's hovered or selected.
|
||||
Points at exactly the hovered/selected candidate when one is known
|
||||
@@ -783,29 +1097,37 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
self._draw_arrow(cr, nx, ny, tx, ty)
|
||||
|
||||
def _draw_blast_radius(self, cr, view) -> None:
|
||||
"""When a Target is selected, its effective shell's blast radius,
|
||||
selection only, not hover (unlike the geo overlays/firing arrow),
|
||||
per spec. Uses the specific selected candidate point if the target
|
||||
is ambiguous; skipped entirely if there's no known point yet, or
|
||||
the shell's blast radius isn't known."""
|
||||
if not isinstance(self.selected, Target):
|
||||
return
|
||||
target = self.selected
|
||||
point = target.coord if target.coord is not None else self.selected_point
|
||||
if point is None:
|
||||
return
|
||||
radius_km = target.effective_shell.blast_radius_km
|
||||
if radius_km is None:
|
||||
return
|
||||
"""Every Target's effective shell's blast radius, for whichever
|
||||
ones are selected or pinned via the same "always show geo"
|
||||
show_geo_desc toggle the bearing/distance overlay uses (not on
|
||||
plain hover, unlike that overlay -- a blast radius circle
|
||||
flickering in on every hover was judged too noisy, selection/
|
||||
pinning is a deliberate choice). Uses the specific selected
|
||||
candidate point if an ambiguous target is the selected one;
|
||||
skipped per-target if there's no known point yet, or the shell's
|
||||
blast radius isn't known."""
|
||||
targets = [
|
||||
t for t in self.board.targets
|
||||
if not self._excluded_from_map(t) and (t is self.selected or t.show_geo_desc)
|
||||
]
|
||||
for target in targets:
|
||||
point = target.coord if target.coord is not None else (
|
||||
self.selected_point if target is self.selected else None
|
||||
)
|
||||
if point is None:
|
||||
continue
|
||||
radius_km = target.effective_shell.blast_radius_km
|
||||
if radius_km is None:
|
||||
continue
|
||||
|
||||
x, y = self._km_to_px(view, point.as_fraction())
|
||||
rx, ry = view.cell_w * radius_km, view.cell_h * radius_km
|
||||
self._draw_ellipse(cr, x, y, rx, ry)
|
||||
cr.set_source_rgba(*BLAST_RADIUS, 0.18)
|
||||
cr.fill_preserve()
|
||||
cr.set_source_rgba(*BLAST_RADIUS, 0.85)
|
||||
cr.set_line_width(2)
|
||||
cr.stroke()
|
||||
x, y = self._km_to_px(view, point.as_fraction())
|
||||
rx, ry = view.cell_w * radius_km, view.cell_h * radius_km
|
||||
self._draw_ellipse(cr, x, y, rx, ry)
|
||||
cr.set_source_rgba(*BLAST_RADIUS, 0.18)
|
||||
cr.fill_preserve()
|
||||
cr.set_source_rgba(*BLAST_RADIUS, 0.85)
|
||||
cr.set_line_width(2)
|
||||
cr.stroke()
|
||||
|
||||
def _draw_scout_flight_rect(self, cr, view, center_km, bearing_deg, *,
|
||||
dashed=False, alpha_mult=1.0) -> None:
|
||||
@@ -871,7 +1193,14 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
way, so this looks at every RP/Target directly rather than those,
|
||||
the only way to let the user eyeball a bad-but-close reading
|
||||
against what it should have crossed."""
|
||||
candidates = list(self.board.reference_points) + list(self.board.targets)
|
||||
# Nest/Spotter never carry clues (always given as a direct grid
|
||||
# coord, no relative-bearing mechanic for them), so leaving them
|
||||
# out here wouldn't visibly change anything -- but Allies DO get
|
||||
# clues from OCR ("FriendlyTank#1 Spotted. 088, 12.10km from
|
||||
# Spotter#1") and also have a show_geo_desc pin in the UI (see
|
||||
# app.py's per-card "always show geo" toggle), so omitting them
|
||||
# here meant pinning one silently did nothing.
|
||||
candidates = list(self.board.reference_points) + list(self.board.targets) + list(self.board.allies)
|
||||
to_show = [
|
||||
obj for obj in candidates
|
||||
if obj.location.clues and not self._excluded_from_map(obj)
|
||||
|
||||
@@ -15,7 +15,9 @@ import gi
|
||||
gi.require_version("Gdk", "4.0")
|
||||
gi.require_version("GdkPixbuf", "2.0")
|
||||
gi.require_version("Gtk", "4.0")
|
||||
from gi.repository import Gdk, GdkPixbuf, Gtk # noqa: E402
|
||||
from gi.repository import Gdk, GdkPixbuf, Gtk, Pango # noqa: E402
|
||||
|
||||
import cairo
|
||||
|
||||
from .models import TargetType
|
||||
from .shells import Shell
|
||||
@@ -28,46 +30,136 @@ _css_loaded = False
|
||||
NEST_ICON_PATH = _ICONS_DIR / "nest" / "IronNest.png"
|
||||
STRIKE_ICON_PATH = _ICONS_DIR / "misc" / "Crosshair.png"
|
||||
|
||||
# TargetType -> the shared basename suffix, after 'Enemy_'/'Friendly_',
|
||||
# for whichever of the game's own unit icons fits best. Best-effort
|
||||
# guesses (flagged the same way the Shell descriptions were): the game
|
||||
# doesn't have a dedicated icon for every one of our types, TANK reuses
|
||||
# the Armor_Mechanized artwork, and FDC/PILLBOX/ENEMY only exist on the
|
||||
# enemy side at all (see target_icon_path()'s fallback). No entry means
|
||||
# no icon exists worth drawing, the caller falls back to a plain dot
|
||||
# (only UNKNOWN now, STRIKE has its own crosshair below).
|
||||
_TARGET_ICON_BASENAME = {
|
||||
TargetType.SUPPLY_CACHE: "Ammunition Cache.png",
|
||||
TargetType.FDC: "Fire Direction Center.png",
|
||||
TargetType.INFANTRY: "Infantry.png",
|
||||
TargetType.MECHANIZED: "Armor_Mechanized.png",
|
||||
TargetType.ARTILLERY: "Field Artillery.png",
|
||||
TargetType.TANK: "Armor_Mechanized.png",
|
||||
TargetType.PILLBOX: "Heavy_Gun_Bunker.png",
|
||||
TargetType.MARINE_GARRISON: "Marine.png",
|
||||
TargetType.ENEMY: "Base.png",
|
||||
# TargetType -> (enemy_basename, friendly_basename), after 'Enemy_'/
|
||||
# 'Friendly_'. Either half is None where the game draws no icon for that
|
||||
# type on that side at all -- that's not rare enough on either side to
|
||||
# treat as an exception list bolted onto a shared-name table (the earlier
|
||||
# shape of this code: one basename table plus two separate patch dicts for
|
||||
# "actually the friendly filename differs" and "actually this side has
|
||||
# none at all", which was easy to update inconsistently and silently do
|
||||
# the wrong thing for one side). One explicit pair per type, covering
|
||||
# EVERY TargetType, is the actual shape of the data: a basename shared by
|
||||
# both sides, a basename that differs (a genuine filename mismatch in the
|
||||
# source assets, not a difference in what's drawn, see assets/icons/
|
||||
# README.md), or a basename that exists on only one side.
|
||||
#
|
||||
# UNKNOWN/ENEMY are deliberately (None, None): generic/ad-hoc, not a unit
|
||||
# the game draws specific art for (see their comments on TargetType).
|
||||
# STRIKE isn't in this table at all: its crosshair isn't an Enemy_/
|
||||
# Friendly_ file, it's handled as a special case in target_icon_path().
|
||||
# completeness of this table (every TargetType except STRIKE has a row) is
|
||||
# asserted below, not just hoped for.
|
||||
_TARGET_ICON = {
|
||||
TargetType.UNKNOWN: (None, None),
|
||||
TargetType.ENEMY: (None, None),
|
||||
|
||||
TargetType.ANTI_AIR: ("AA.png", "AA.png"),
|
||||
TargetType.ANTI_TANK: ("AntiTank.png", "AntiTank.png"),
|
||||
TargetType.ARTILLERY: ("Field Artillery.png", "Field Artillery.png"),
|
||||
TargetType.ARTILLERY_OBSERVER: ("Field Artillery Observer.png", "Field Artillery Observer.png"),
|
||||
TargetType.HEAVY_GUN_TURRET: ("Heavy_Gun_Turret.png", None),
|
||||
TargetType.INFANTRY: ("Infantry.png", "Infantry.png"),
|
||||
TargetType.INFANTRY_MECHANIZED: ("Infantry_mechanized.png", "Infantry_Mechanized.png"), # case differs
|
||||
TargetType.MECH_ANTI_TANK: (None, "Mech_AntiTank.png"), # friendly-only
|
||||
TargetType.MECHANIZED: ("Armor_Mechanized.png", "Armor_Mechanized.png"),
|
||||
TargetType.PILLBOX: ("Heavy_Gun_Bunker.png", None),
|
||||
TargetType.TANK: ("Armor_Mechanized.png", "Armor_Mechanized.png"), # shares MECHANIZED's art, see TargetType
|
||||
|
||||
TargetType.BASE: ("Base.png", "Military Base.png"), # name differs
|
||||
TargetType.COMMANDER: ("Commander.png", "Commander.png"),
|
||||
TargetType.FDC: ("Fire Direction Center.png", None),
|
||||
TargetType.FORT: (None, "Fort.png"), # friendly-only
|
||||
TargetType.GENERAL: (None, "General.png"), # friendly-only
|
||||
TargetType.KING: (None, "King.png"), # friendly-only
|
||||
TargetType.MARINE_GARRISON: ("Marine.png", "Marine.png"),
|
||||
TargetType.POLICE: (None, "Police.png"), # friendly-only
|
||||
TargetType.SUPPLY_CACHE: ("Ammunition Cache.png", "Ammunition Cache.png"),
|
||||
TargetType.UNDERGROUND_FORT: ("Underground Fort.png", None),
|
||||
|
||||
TargetType.EMERGENCY_MEDICAL: ("Emergency Medical Operation.png", "Emergency Medical Operation.png"),
|
||||
TargetType.HOSPITAL: (None, "Hospital.png"), # friendly-only
|
||||
TargetType.MEDICAL: ("Medical.png", "Medical.png"),
|
||||
TargetType.MEDICAL_FACILITY: ("Medical Treatment Facility.png", "Medical Treatment Facility.png"),
|
||||
|
||||
TargetType.CIVIL_MILITARY: (None, "Civil–Military.png"), # friendly-only
|
||||
TargetType.CIVILIAN: ("Civ.png", "Civilian.png"), # name differs
|
||||
TargetType.CIVIL_RIOTING: ("Civil Rioting.png", "Civil Rioting.png"),
|
||||
TargetType.RIOTING: ("Rioting.png", None),
|
||||
TargetType.TV_RADIO_PROPAGANDA: ("TV and Radio Propaganda.png", "TV and Radio Propaganda.png"),
|
||||
|
||||
TargetType.PORT: ("Port.png", "Port.png"),
|
||||
TargetType.SHIP: ("Ship.png", None),
|
||||
TargetType.SHIP_ENGINE: ("Ship_Engine.png", None),
|
||||
TargetType.SHIP_FDC: ("Ship_FDC.png", None),
|
||||
TargetType.SHIP_STRIPE: ("Ship_Stripe.png", "Ship_stripe.png"), # case differs
|
||||
TargetType.SHIP_TURRET: ("Ship_Turret.png", None),
|
||||
|
||||
TargetType.TRAIN_LOCOMOTIVE: ("Train_Locomotive.png", None),
|
||||
TargetType.TRAIN_STATION: ("Train_Station.png", "Train_Station.png"),
|
||||
TargetType.TRAIN_TRANSPORT: ("Train_Transport.png", None),
|
||||
|
||||
TargetType.RECON: ("Recon.png", "Reconnaissance.png"), # name differs
|
||||
TargetType.RECON_LISTENING: ("Recon_Listening.png", "Recon_Listening.png"),
|
||||
}
|
||||
assert {*_TARGET_ICON} | {TargetType.STRIKE, TargetType.STRIKE_REQUEST} == {*TargetType}, (
|
||||
"every TargetType needs a row in _TARGET_ICON (STRIKE/STRIKE_REQUEST "
|
||||
"are the deliberate exceptions, see the comment above target_icon_path)"
|
||||
)
|
||||
|
||||
|
||||
def _icon_for_side(target_type: TargetType, is_ally: bool) -> Path | None:
|
||||
"""This SIDE's own icon for target_type specifically, with no
|
||||
cross-side fallback -- used both by target_icon_path() (which adds
|
||||
the fallback back on top) and by _has_own_icon() (which needs to know
|
||||
whether this side has real art of its own, not whether *some* art is
|
||||
available after falling back)."""
|
||||
entry = _TARGET_ICON.get(target_type)
|
||||
if entry is None:
|
||||
return None
|
||||
basename = entry[1 if is_ally else 0]
|
||||
if basename is None:
|
||||
return None
|
||||
folder, prefix = ("friendly", "Friendly_") if is_ally else ("enemy", "Enemy_")
|
||||
path = _ICONS_DIR / "targets" / folder / f"{prefix}{basename}"
|
||||
return path if path.exists() else None
|
||||
|
||||
|
||||
def target_type_from_icon(basename: str | None) -> TargetType | None:
|
||||
"""Inverse of _TARGET_ICON, for the map-vision marker classifier, which
|
||||
names what it matched by icon file rather than by TargetType.
|
||||
|
||||
Not injective: MECHANIZED and TANK share Armor_Mechanized.png, so that one
|
||||
resolves to MECHANIZED and the user retypes it if it was a Tank (map
|
||||
right-click -> Change type). Icons with no TargetType at all give None,
|
||||
which callers treat as UNKNOWN.
|
||||
"""
|
||||
if not basename:
|
||||
return None
|
||||
name = basename if basename.lower().endswith(".png") else f"{basename}.png"
|
||||
for prefix in ("Enemy_", "Friendly_"):
|
||||
if name.startswith(prefix):
|
||||
name = name[len(prefix):]
|
||||
for type_, (enemy_basename, friendly_basename) in _TARGET_ICON.items():
|
||||
if name in (enemy_basename, friendly_basename):
|
||||
return type_
|
||||
return None
|
||||
|
||||
|
||||
def target_icon_path(target_type: TargetType, is_ally: bool = False) -> Path | None:
|
||||
"""Icon file for a Target or Ally's type, or None if there isn't a
|
||||
good one. `is_ally` picks the Friendly_ set over the Enemy_ one,
|
||||
falling back to Enemy_ if that particular basename has no friendly
|
||||
version (the two sets aren't the same size, see assets/icons/
|
||||
README.md). STRIKE (a planned impact point, not a unit) gets its
|
||||
own crosshair rather than a unit icon, it doesn't fit the Enemy_/
|
||||
Friendly_ naming scheme at all."""
|
||||
if target_type is TargetType.STRIKE:
|
||||
good one. `is_ally` picks the friendly side of _TARGET_ICON over the
|
||||
enemy one, falling back to the enemy icon if this particular type has
|
||||
no friendly art of its own at all (the two sets aren't the same size,
|
||||
see assets/icons/README.md). STRIKE/STRIKE_REQUEST (a planned impact
|
||||
point, not a unit -- player-placed vs called in by a friendly, see
|
||||
STRIKE_REQUEST's own comment) both get the same crosshair rather than
|
||||
a unit icon, neither fits the Enemy_/Friendly_ naming scheme at all."""
|
||||
if target_type in (TargetType.STRIKE, TargetType.STRIKE_REQUEST):
|
||||
return STRIKE_ICON_PATH
|
||||
basename = _TARGET_ICON_BASENAME.get(target_type)
|
||||
if basename is None:
|
||||
return None
|
||||
if is_ally:
|
||||
friendly = _ICONS_DIR / "targets" / "friendly" / f"Friendly_{basename}"
|
||||
if friendly.exists():
|
||||
return friendly
|
||||
enemy = _ICONS_DIR / "targets" / "enemy" / f"Enemy_{basename}"
|
||||
return enemy if enemy.exists() else None
|
||||
own = _icon_for_side(target_type, is_ally)
|
||||
if own is not None:
|
||||
return own
|
||||
return _icon_for_side(target_type, is_ally=False) if is_ally else None
|
||||
|
||||
|
||||
def _ensure_icon_button_css() -> None:
|
||||
@@ -179,10 +271,10 @@ def _shell_cell(s: Shell) -> Gtk.Widget:
|
||||
return cell
|
||||
|
||||
|
||||
def _build_shell_grid(make_button) -> Gtk.Widget:
|
||||
"""Shared grid layout: rows of up to _GRID_COLUMNS buttons, one per
|
||||
Shell, each built by `make_button(shell) -> Gtk.Widget`. Used by
|
||||
both the popover picker and the inline radio-style grid below.
|
||||
def _build_icon_grid(items, columns, make_button) -> Gtk.Widget:
|
||||
"""Shared grid layout: rows of up to `columns` buttons, one per item in
|
||||
`items`, each built by `make_button(item) -> Gtk.Widget`. Used by every
|
||||
icon-grid picker in this module (shells, target types).
|
||||
|
||||
A plain nested Gtk.Box grid, not a Gtk.FlowBox, on purpose, after
|
||||
two FlowBox attempts both broke in different ways: a ScrolledWindow
|
||||
@@ -192,24 +284,28 @@ def _build_shell_grid(make_button) -> Gtk.Widget:
|
||||
no fixed allocation yet) turned out to mean 'fit every child on one
|
||||
line', ignoring max_children_per_line entirely, so min/max-content-
|
||||
width on a ScrolledWindow around it never actually took effect
|
||||
(verified directly: it kept ballooning out to fit every shell in a
|
||||
single row regardless of what those properties were set to). Shell
|
||||
is a small, fixed, known set, there's no real need for FlowBox's
|
||||
(verified directly: it kept ballooning out to fit every item in a
|
||||
single row regardless of what those properties were set to). Each of
|
||||
these sets is small and fixed, there's no real need for FlowBox's
|
||||
dynamic reflow-to-fewer-columns behavior here, a manual grid of
|
||||
fixed-size rows has a fully deterministic natural width (columns *
|
||||
cell width, nothing else involved) and sidesteps the whole class of
|
||||
bug."""
|
||||
shells = list(Shell)
|
||||
items = list(items)
|
||||
grid = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4,
|
||||
margin_top=8, margin_bottom=8, margin_start=8, margin_end=8)
|
||||
for start in range(0, len(shells), _GRID_COLUMNS):
|
||||
for start in range(0, len(items), columns):
|
||||
row_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4, homogeneous=True)
|
||||
for s in shells[start:start + _GRID_COLUMNS]:
|
||||
row_box.append(make_button(s))
|
||||
for it in items[start:start + columns]:
|
||||
row_box.append(make_button(it))
|
||||
grid.append(row_box)
|
||||
return grid
|
||||
|
||||
|
||||
def _build_shell_grid(make_button) -> Gtk.Widget:
|
||||
return _build_icon_grid(Shell, _GRID_COLUMNS, make_button)
|
||||
|
||||
|
||||
def build_shell_popover(on_pick) -> Gtk.Popover:
|
||||
"""Popover with a grid of every Shell (icon + blast radius under it,
|
||||
full description as a tooltip), replacing a plain text dropdown/list
|
||||
@@ -300,3 +396,249 @@ def build_shell_button(selected: Shell, on_pick, *, show_label: bool = True, ico
|
||||
render(selected)
|
||||
btn.set_popover(build_shell_popover(handle_pick))
|
||||
return btn
|
||||
|
||||
|
||||
# ---- TargetType icon-grid picker, same idea as the Shell picker above ----
|
||||
|
||||
_TYPE_GRID_ICON_WIDTH = 40 # smaller than the shell grid's: ~35 types vs 9 shells,
|
||||
_TYPE_GRID_COLUMNS = 5 # needs to fit a lot more cells in the same dialog width
|
||||
|
||||
|
||||
# Mirrors grid_widget.py's CATEGORY_COLOR["target"]/["ally"] (the colors the
|
||||
# map itself draws the plain-dot fallback in). Duplicated rather than
|
||||
# imported: grid_widget.py already imports this module for icon lookups, an
|
||||
# import the other way would be circular. Unlike that module's palette,
|
||||
# these two are not theme-swapped live -- the picker is a modal dialog, not
|
||||
# the persistent map, redrawing it on a theme change isn't worth the wiring.
|
||||
_DOT_COLOR = {False: (0.92, 0.30, 0.28), True: (0.30, 0.85, 0.85)}
|
||||
|
||||
|
||||
def _plain_dot(is_ally: bool, width: int) -> Gtk.Widget:
|
||||
"""The same 'plain dot' fallback the map itself draws for a type with
|
||||
no dedicated icon (see grid_widget.py's _icon_for), so a type with no
|
||||
icon reads as 'this type has no special marker' rather than as a
|
||||
rendering gap in the picker."""
|
||||
area = Gtk.DrawingArea()
|
||||
area.set_content_width(width)
|
||||
area.set_content_height(width)
|
||||
|
||||
def draw(_area, cr, w, h):
|
||||
cr.set_source_rgb(*_DOT_COLOR[is_ally])
|
||||
cr.arc(w / 2, h / 2, min(w, h) * 0.32, 0, 2 * 3.141592653589793)
|
||||
cr.fill()
|
||||
|
||||
area.set_draw_func(draw)
|
||||
return area
|
||||
|
||||
|
||||
def target_type_icon_image(target_type: "TargetType", is_ally: bool = False, width: int = _TYPE_GRID_ICON_WIDTH) -> Gtk.Widget:
|
||||
"""A widget showing target_type's icon (friendly or enemy art per
|
||||
`is_ally`), scaled to `width` px wide. Falls back to the same plain dot
|
||||
the map itself draws for the types with no dedicated icon (UNKNOWN,
|
||||
ENEMY -- see icons.py's _TARGET_ICON_BASENAME comment; STRIKE always
|
||||
has its crosshair), so every cell in the grid stays the same size
|
||||
whether or not it has real art."""
|
||||
path = target_icon_path(target_type, is_ally=is_ally)
|
||||
if path is not None and path.exists():
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(path), width, -1, True)
|
||||
picture = Gtk.Picture.new_for_pixbuf(pixbuf)
|
||||
picture.set_content_fit(Gtk.ContentFit.CONTAIN)
|
||||
picture.set_can_shrink(True)
|
||||
picture.set_size_request(pixbuf.get_width(), pixbuf.get_height())
|
||||
return picture
|
||||
return _plain_dot(is_ally, width)
|
||||
|
||||
|
||||
_ADDITIVES_DIR = _ICONS_DIR / "targets" / "additives"
|
||||
UNDERGROUND_TIERS = (1, 2, 3)
|
||||
|
||||
|
||||
def underground_icon_path(tier: int) -> Path:
|
||||
"""The badge overlaid on a Target's own icon when it's marked
|
||||
underground at this tier (1..3, harder to hit = higher). Reuses the
|
||||
game's own Armor-tier additive art (assets/icons/targets/additives/
|
||||
Additive_Armor{1,2,3}.png) rather than inventing bespoke "underground"
|
||||
art of our own -- there's nothing else in the game's icon set for
|
||||
"buried/fortified", and Armor's own visual (a plate) already reads
|
||||
right for that."""
|
||||
return _ADDITIVES_DIR / f"Additive_Armor{tier}.png"
|
||||
|
||||
|
||||
def underground_tier_image(tier: int | None, width: int = _TYPE_GRID_ICON_WIDTH) -> Gtk.Widget:
|
||||
"""A widget for one cell of the underground-tier picker: the additive
|
||||
badge itself for a real tier, or a plain dot (this module's usual
|
||||
'nothing chosen' placeholder) for the "not underground" cell."""
|
||||
if tier is None:
|
||||
return _plain_dot(False, width)
|
||||
path = underground_icon_path(tier)
|
||||
if path.exists():
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(path), width, -1, True)
|
||||
picture = Gtk.Picture.new_for_pixbuf(pixbuf)
|
||||
picture.set_content_fit(Gtk.ContentFit.CONTAIN)
|
||||
picture.set_can_shrink(True)
|
||||
picture.set_size_request(pixbuf.get_width(), pixbuf.get_height())
|
||||
return picture
|
||||
return _plain_dot(False, width)
|
||||
|
||||
|
||||
def build_underground_tier_grid(selected: int | None, on_pick) -> Gtk.Widget:
|
||||
"""Same radio-style grid idea as build_target_type_grid, just over
|
||||
(None, 1, 2, 3) instead of TargetType -- None first, as "not
|
||||
underground" (clearing an existing tier) is exactly as valid a pick
|
||||
as any real tier, not a separate "remove" action bolted on
|
||||
afterward."""
|
||||
_ensure_icon_button_css()
|
||||
leader: Gtk.ToggleButton | None = None
|
||||
items = [None, *UNDERGROUND_TIERS]
|
||||
|
||||
def make_button(tier: int | None) -> Gtk.Widget:
|
||||
nonlocal leader
|
||||
cell = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
|
||||
margin_top=4, margin_bottom=4, margin_start=2, margin_end=2)
|
||||
cell.append(underground_tier_image(tier))
|
||||
label = Gtk.Label(label="None" if tier is None else f"Tier {tier}",
|
||||
wrap=False, single_line_mode=True,
|
||||
justify=Gtk.Justification.CENTER, width_chars=9,
|
||||
max_width_chars=9, ellipsize=Pango.EllipsizeMode.END)
|
||||
label.add_css_class("caption")
|
||||
label.add_css_class("dim-label")
|
||||
cell.append(label)
|
||||
btn = Gtk.ToggleButton(child=cell)
|
||||
btn.add_css_class("flat")
|
||||
btn.add_css_class(_ICON_BUTTON_CSS_CLASS)
|
||||
btn.set_tooltip_text("Not underground" if tier is None else f"Underground, tier {tier}")
|
||||
if leader is None:
|
||||
leader = btn
|
||||
else:
|
||||
btn.set_group(leader)
|
||||
if tier is selected:
|
||||
btn.set_active(True)
|
||||
btn.connect("clicked", lambda _b, tier=tier: on_pick(tier))
|
||||
return btn
|
||||
|
||||
return _build_icon_grid(items, _TYPE_GRID_COLUMNS, make_button)
|
||||
|
||||
|
||||
def _has_own_icon(t: "TargetType", is_ally: bool) -> bool:
|
||||
"""Whether THIS side specifically has real art for t -- as opposed to
|
||||
target_icon_path() quietly handing back the other side's icon because
|
||||
this side has none of its own (see its own docstring). Used to keep
|
||||
that cross-side fallback out of the picker grids entirely: showing a
|
||||
red diamond as an option for 'Add ally', or offering 'King'/'Police'/
|
||||
etc. (friendly-only, see _TARGET_ICON) as an enemy type, reads as a
|
||||
real option of the wrong side rather than a missing-icon placeholder.
|
||||
UNKNOWN and ENEMY are the exception: deliberately generic/icon-less on
|
||||
BOTH sides (see their comments on TargetType), always offered
|
||||
regardless."""
|
||||
if t in (TargetType.UNKNOWN, TargetType.ENEMY):
|
||||
return True
|
||||
return _icon_for_side(t, is_ally) is not None
|
||||
|
||||
|
||||
def available_target_types(is_ally: bool = False):
|
||||
"""TargetType members worth offering in a picker for this side.
|
||||
|
||||
STRIKE/STRIKE_REQUEST are never offered: neither is a unit type at
|
||||
all (a planned impact point, not a contact), each is always created
|
||||
through its own path instead -- STRIKE via app.py's dedicated "Add
|
||||
strike" action, STRIKE_REQUEST via ocr.py parsing a fire-support
|
||||
request -- never by picking a type from this generic grid. There's
|
||||
no such thing as a Strike-typed Ally either, offering either one
|
||||
here is just confusing, not merely unlikely.
|
||||
|
||||
Otherwise: each side only offers types it actually has its own art
|
||||
for (see _has_own_icon / _TARGET_ICON) -- some types are enemy-only
|
||||
and some are friendly-only (King, Police, a friendly hospital, ...),
|
||||
the game simply doesn't draw an installation of every kind on both
|
||||
sides."""
|
||||
return [
|
||||
t for t in TargetType
|
||||
if t not in (TargetType.STRIKE, TargetType.STRIKE_REQUEST) and _has_own_icon(t, is_ally)
|
||||
]
|
||||
|
||||
|
||||
def target_type_label(t: "TargetType", is_ally: bool) -> str:
|
||||
"""Display text for a picker cell/tooltip, or any other UI spot that
|
||||
would otherwise print obj.type.value directly (map popover headings,
|
||||
"Change type" buttons, toasts, ...). TargetType.ENEMY's own value is
|
||||
literally 'Enemy' (it's the word the game's OCR'd text uses for an
|
||||
ad-hoc *hostile* installation, see TargetType's own comment) --
|
||||
exactly right in the enemy picker, but confusing in the Ally one,
|
||||
where the very same generic/ad-hoc-named-unit case reads as 'Enemy'
|
||||
is somehow a kind of Ally. Cosmetic only: the underlying TargetType
|
||||
stored on the entity is still ENEMY either way, only the label shown
|
||||
changes -- callers that need an id-safe short form (Ally.name etc.)
|
||||
keep using TargetType.short, not this."""
|
||||
if is_ally and t is TargetType.ENEMY:
|
||||
return "Ally"
|
||||
return t.value
|
||||
|
||||
|
||||
# Old private name, kept as an alias: nothing outside this module should
|
||||
# gain a new dependency on it, but this file's own internal callers below
|
||||
# were written against it.
|
||||
_target_type_label = target_type_label
|
||||
|
||||
|
||||
def _target_type_cell(t: "TargetType", is_ally: bool) -> Gtk.Widget:
|
||||
"""Icon + name, both a FIXED size regardless of how long the name is --
|
||||
a real cell size that varies with its label text (three-line names next
|
||||
to one-line ones) makes every row in the grid a different height, which
|
||||
reads as broken/uneven rather than a grid. One line, ellipsized, with
|
||||
the full name in the button's tooltip (see build_target_type_grid)
|
||||
covers the names a single line can't fit."""
|
||||
cell = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
|
||||
margin_top=4, margin_bottom=4, margin_start=2, margin_end=2)
|
||||
cell.append(target_type_icon_image(t, is_ally=is_ally))
|
||||
name_label = Gtk.Label(label=_target_type_label(t, is_ally), wrap=False, single_line_mode=True,
|
||||
justify=Gtk.Justification.CENTER, width_chars=9,
|
||||
max_width_chars=9, ellipsize=Pango.EllipsizeMode.END)
|
||||
name_label.add_css_class("caption")
|
||||
name_label.add_css_class("dim-label")
|
||||
cell.append(name_label)
|
||||
return cell
|
||||
|
||||
|
||||
def build_target_type_grid(selected: "TargetType | None", on_pick, *, is_ally: bool = False) -> Gtk.Widget:
|
||||
"""Inline radio-style grid of every available TargetType (icon + name
|
||||
below, same idea as build_shell_grid), replacing the old plain-text
|
||||
dropdown/list. `is_ally` both picks the friendly icon set over the
|
||||
enemy one and restricts the offered types to ones with real friendly
|
||||
art (see available_target_types). Exactly one cell is ever
|
||||
highlighted (`selected`, or none if `selected` is None or not offered
|
||||
on this side).
|
||||
|
||||
`on_pick(target_type)` fires on every click, including a click on the
|
||||
already-selected cell -- deliberately listening for "clicked", not
|
||||
"toggled": a ToggleButton in a radio group doesn't emit "toggled" when
|
||||
you click the one that's already active (nothing about its state
|
||||
changed), which meant clicking the pre-selected default -- usually
|
||||
exactly the type someone wants, e.g. plain "Target" -- silently did
|
||||
nothing. "clicked" fires every time regardless, so confirming the
|
||||
default now works the same as picking anything else.
|
||||
|
||||
See _target_type_cell for why the name label is single-line and
|
||||
ellipsized rather than wrapped: an unbounded label size, besides
|
||||
making uneven-height rows, could also (being inside a homogeneous
|
||||
row) stretch every cell in that row wide enough to force the whole
|
||||
dialog into horizontal scrolling -- coord_dialog.py's ScrolledWindow
|
||||
has hscrollbar_policy=NEVER as a backstop against that same failure."""
|
||||
_ensure_icon_button_css()
|
||||
leader: Gtk.ToggleButton | None = None
|
||||
|
||||
def make_button(t: "TargetType") -> Gtk.Widget:
|
||||
nonlocal leader
|
||||
btn = Gtk.ToggleButton(child=_target_type_cell(t, is_ally))
|
||||
btn.add_css_class("flat")
|
||||
btn.add_css_class(_ICON_BUTTON_CSS_CLASS)
|
||||
btn.set_tooltip_text(_target_type_label(t, is_ally))
|
||||
if leader is None:
|
||||
leader = btn
|
||||
else:
|
||||
btn.set_group(leader)
|
||||
if t is selected:
|
||||
btn.set_active(True)
|
||||
btn.connect("clicked", lambda _b, t=t: on_pick(t))
|
||||
return btn
|
||||
|
||||
return _build_icon_grid(available_target_types(is_ally), _TYPE_GRID_COLUMNS, make_button)
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
"""State and threading for importing a map screenshot.
|
||||
|
||||
Deliberately free of any GTK import so it can be exercised headlessly. The
|
||||
dialog and the map overlay sit on top of this; everything here is plain
|
||||
Python and numpy.
|
||||
|
||||
Two jobs:
|
||||
|
||||
* run the vision pipeline OFF the UI thread. `solve()` takes 10-20s, which
|
||||
would freeze the window, so it runs in a worker and the result is handed
|
||||
back through a scheduler callback (GLib.idle_add in the app, called
|
||||
directly in tests). A thread is sufficient rather than a process: the work
|
||||
is numpy/OpenCV, which releases the GIL and already multithreads
|
||||
internally.
|
||||
* hold the review state. Detections arrive as PROPOSALS, not as board
|
||||
entries: each is accepted or rejected individually (or all at once), the
|
||||
unit type can be corrected, and dropping the screenshot discards whatever
|
||||
was never accepted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from . import map_vision
|
||||
|
||||
# Distinguishable on_done error: this screenshot is typewriter text, so the
|
||||
# caller should send it down its normal OCR path rather than report a failure.
|
||||
NOT_A_MAP = "not a map screenshot"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Proposal:
|
||||
"""One detected marker awaiting the user's decision."""
|
||||
side: str # "hostile" | "friendly"
|
||||
label: str # e.g. "K8"
|
||||
sub_x: int
|
||||
sub_y: int
|
||||
unit: str | None # game unit name, or None when unsure
|
||||
centre: tuple # pixel centre in the solved image
|
||||
box: tuple
|
||||
accepted: bool = False
|
||||
rejected: bool = False
|
||||
# classify_marker's own raw numbers behind `unit` (best-match score,
|
||||
# and its margin over the runner-up) -- unit alone only says whether
|
||||
# it beat min_score/min_margin, not by how much or how close a call
|
||||
# it was. Ground truth needs these to tell "confidently wrong" apart
|
||||
# from "just barely missed the bar", which `unit=None` alone can't.
|
||||
unit_score: float = 0.0
|
||||
unit_margin: float = 0.0
|
||||
# The TargetType.name actually applied when accepted -- usually just
|
||||
# `unit` translated through icons.target_type_from_icon, but can
|
||||
# differ if the user corrected it via "Accept as...". Set by
|
||||
# app.py's _accept_proposal, the only writer. Ground truth for
|
||||
# debug_capture.save_marker_ground_truth: `unit` is what the
|
||||
# classifier guessed, this is what the user actually confirmed.
|
||||
confirmed_type: str | None = None
|
||||
# The marker's own "#<N>" id label, as read off the screenshot by
|
||||
# map_vision.read_marker_id -- distinct from `label`/sub_x/sub_y
|
||||
# (the grid CELL this marker is in), this is the small per-unit id
|
||||
# the game itself draws. None when unread/unconfident (see
|
||||
# read_marker_id's own docstring: best-effort, not yet validated
|
||||
# against a real ground-truth batch). Meant for future dedup work
|
||||
# (see TODO.md) once there's confidence in the read; not otherwise
|
||||
# consumed yet.
|
||||
detected_id: str | None = None
|
||||
|
||||
@property
|
||||
def coord(self) -> str:
|
||||
return f"{self.label} {self.sub_x}:{self.sub_y}"
|
||||
|
||||
@property
|
||||
def pending(self) -> bool:
|
||||
return not (self.accepted or self.rejected)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScreenshotImport:
|
||||
"""An accepted screenshot plus its proposals, as shown over the map."""
|
||||
solution: object
|
||||
image: object
|
||||
proposals: list = field(default_factory=list)
|
||||
overlay: object = None # BGRA array in map space
|
||||
px_per_km: int = 0
|
||||
# The same screenshot at full resolution, plus its width / `image`'s
|
||||
# width -- `image` is downscaled to WORK_W for solving/marker-detection
|
||||
# speed (see map_vision.WORK_W), which is plenty for those but throws
|
||||
# away real detail the map overlay doesn't need to give up too (a
|
||||
# screenshot can be up to 6880px wide, see map_vision.load_full_res's
|
||||
# docstring). None/1.0 (rather than always loading it) because it's
|
||||
# only needed for build_overlay(), and app.py sets it right after
|
||||
# solving, before build_overlay() is ever called.
|
||||
full_image: object = None
|
||||
full_image_scale: float = 1.0
|
||||
# Board.targets/Board.allies as they stood right when this screenshot's
|
||||
# grid was confirmed (see app.py's _accept_grid) -- Target/Ally are
|
||||
# identity-hashable (models.py's `eq=False`), so these are plain sets
|
||||
# of the actual live objects, not ids/copies. Whatever's in
|
||||
# board.targets/board.allies but NOT in these sets when the screenshot
|
||||
# is later dropped was added while this screenshot was up, by
|
||||
# whatever means (an accepted proposal, a manual add, an OCR-text
|
||||
# merge run alongside it, ...) -- see app.py's _remove_screenshot,
|
||||
# which treats that as this screenshot's ground truth for
|
||||
# debug_capture.save_marker_ground_truth. Left for app.py to populate
|
||||
# rather than done here, this module stays ignorant of the Board/
|
||||
# Target/Ally types on purpose (see this file's own docstring).
|
||||
baseline_targets: set = field(default_factory=set)
|
||||
baseline_allies: set = field(default_factory=set)
|
||||
|
||||
def set_proposals(self, markers):
|
||||
self.proposals = [
|
||||
Proposal(side=m["side"], label=m["label"], sub_x=m["sub_x"],
|
||||
sub_y=m["sub_y"], unit=m.get("unit"),
|
||||
centre=m["centre"], box=m["box"],
|
||||
detected_id=m.get("detected_id"),
|
||||
unit_score=m.get("unit_score", 0.0),
|
||||
unit_margin=m.get("unit_margin", 0.0)) for m in markers]
|
||||
return self.proposals
|
||||
|
||||
def build_overlay(self, px_per_km=150):
|
||||
"""Rectify the screenshot into map space, ready to draw under the grid.
|
||||
Uses full_image (full resolution) over image (WORK_W-downscaled) when
|
||||
available, see full_image's own docstring."""
|
||||
src, scale = (self.full_image, self.full_image_scale) if self.full_image is not None \
|
||||
else (self.image, 1.0)
|
||||
self.overlay, self.px_per_km = map_vision.warp_to_map(
|
||||
src, self.solution, px_per_km=px_per_km, img_scale=scale)
|
||||
return self.overlay
|
||||
|
||||
def accept_all(self):
|
||||
for p in self.proposals:
|
||||
if p.pending:
|
||||
p.accepted = True
|
||||
|
||||
def reject_all(self):
|
||||
for p in self.proposals:
|
||||
if p.pending:
|
||||
p.rejected = True
|
||||
|
||||
def accepted(self):
|
||||
return [p for p in self.proposals if p.accepted]
|
||||
|
||||
def pending(self):
|
||||
return [p for p in self.proposals if p.pending]
|
||||
|
||||
def drop_unaccepted(self):
|
||||
"""Removing the screenshot discards everything never accepted."""
|
||||
self.proposals = [p for p in self.proposals if p.accepted]
|
||||
|
||||
|
||||
class ImportJob:
|
||||
"""Runs the vision pipeline in a worker thread.
|
||||
|
||||
`on_done(result, error)` is delivered through `schedule`, which the app
|
||||
sets to GLib.idle_add so the callback lands on the UI thread. Nothing here
|
||||
may touch a widget.
|
||||
"""
|
||||
|
||||
def __init__(self, schedule=None):
|
||||
self.schedule = schedule or (lambda fn, *a: fn(*a))
|
||||
self._cancelled = threading.Event()
|
||||
self._thread = None
|
||||
|
||||
@property
|
||||
def cancelled(self) -> bool:
|
||||
return self._cancelled.is_set()
|
||||
|
||||
def cancel(self):
|
||||
"""Ask the worker to stop. The result is simply dropped -- the vision
|
||||
code is pure and side-effect free, so abandoning it is safe."""
|
||||
self._cancelled.set()
|
||||
|
||||
def looks_like_map(self, img) -> bool:
|
||||
"""Cheap synchronous routing test (~0.3s), safe to call inline.
|
||||
|
||||
Measured: gates in 9 of 10 map screenshots and 6% of 122 writer
|
||||
screenshots. Its false positives only cost time, because solve() is
|
||||
the real decision and accepts none of the 122.
|
||||
"""
|
||||
return map_vision.looks_like_map(img)
|
||||
|
||||
def start(self, path, on_done, gate=True):
|
||||
"""Run the pipeline for `path`, delivering on_done(result, error).
|
||||
|
||||
With `gate` on, the routing test runs in the worker too and a text
|
||||
screenshot comes back as error NOT_A_MAP. That keeps the whole
|
||||
map-or-text decision off the UI thread: the gate is only ~0.3s, but
|
||||
the caller is on the clipboard path, where a hitch is felt.
|
||||
|
||||
Only the GRID is solved here. Marker detection is a separate phase
|
||||
(find_markers) run after the user has confirmed or corrected the grid,
|
||||
because every marker position is expressed in grid coordinates: finding
|
||||
them against a grid that's about to be dragged would only be thrown
|
||||
away and redone.
|
||||
"""
|
||||
def work():
|
||||
if gate and not map_vision.looks_like_map(map_vision.load(path)):
|
||||
return None, NOT_A_MAP
|
||||
sol, img, err = map_vision.solve_path(path)
|
||||
if sol is None:
|
||||
return None, err
|
||||
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")
|
||||
|
||||
def find_markers(self, imp, on_done):
|
||||
"""Second phase: detect units against the now-confirmed grid.
|
||||
|
||||
Fills imp.proposals and delivers on_done(imp, error). Its own thread,
|
||||
because the user's grid correction sits between the two phases.
|
||||
|
||||
Marker detection itself always runs against imp.image (WORK_W,
|
||||
same as solving used); imp.full_image is passed through only for
|
||||
reading each marker's own tiny id label off a sharper source, see
|
||||
map_vision.find_markers' own id_img param.
|
||||
"""
|
||||
def work():
|
||||
id_img = imp.full_image # None is fine, find_markers falls back to imp.image
|
||||
imp.set_proposals(map_vision.find_markers(
|
||||
imp.image, imp.solution, id_img=id_img, id_scale=imp.full_image_scale))
|
||||
return imp, None
|
||||
|
||||
return self._run(work, on_done, "map-markers")
|
||||
|
||||
def _run(self, work, on_done, name):
|
||||
"""Run work() in a thread and marshal its (result, error) back.
|
||||
|
||||
work() only computes and returns; delivery and the cancellation check
|
||||
live here, so no phase can deliver into a UI the user has moved on from.
|
||||
"""
|
||||
def guarded():
|
||||
try:
|
||||
result, error = work()
|
||||
except Exception as exc: # worker must never die silently
|
||||
result, error = None, f"{type(exc).__name__}: {exc}"
|
||||
if not self._cancelled.is_set():
|
||||
self.schedule(on_done, result, error)
|
||||
|
||||
self._cancelled.clear()
|
||||
self._thread = threading.Thread(target=guarded, daemon=True, name=name)
|
||||
self._thread.start()
|
||||
return self._thread
|
||||
@@ -18,6 +18,7 @@ Coord) to work out everything else. This module just defines the shape.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import string
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
@@ -44,20 +45,86 @@ class TargetType(Enum):
|
||||
collection from Target (see Board.allies), not this same type with
|
||||
a flag flipped, targets and allies don't share an id-namespace or a
|
||||
firing-relevant shape (no shell/powder_charges/assignment)."""
|
||||
# Declaration order is also picker order (icons.build_target_type_grid
|
||||
# and the old plain-text dropdown both just iterate TargetType), so
|
||||
# it's grouped by category, most-reached-for category first, and
|
||||
# alphabetical by value within a category -- not the order these were
|
||||
# added to the codebase.
|
||||
|
||||
# -- Generic / non-unit -------------------------------------------
|
||||
UNKNOWN = "Target" # generic contact, spotted but not yet identified; default choice
|
||||
SUPPLY_CACHE = "Supply Cache"
|
||||
FDC = "FDC" # Fire Direction Center, coordinates counter-battery fire
|
||||
INFANTRY = "Infantry" # ground troops
|
||||
MECHANIZED = "Mechanized" # armored/vehicle unit
|
||||
ARTILLERY = "Artillery" # "Coastal Battery" is just this, see _TYPE_WORD_ALIASES in ocr.py
|
||||
TANK = "Tank"
|
||||
PILLBOX = "Pillbox" # armoured emplacement, fixed position
|
||||
MARINE_GARRISON = "Marine Garrison" # requests fire support (see Target.requested_time)
|
||||
ENEMY = "Enemy" # ad-hoc installation named directly in the intel text
|
||||
# ("Enemy Signal Station", "Enemy Field Command"), not one of the
|
||||
# game's fixed unit types, its id is the rest of that name with
|
||||
# spaces stripped, see ocr.py's squash_enemy_names()
|
||||
STRIKE = "Strike" # a planned impact point, not an enemy contact
|
||||
STRIKE = "Strike" # a planned impact point, not an enemy contact --
|
||||
# player-placed only (app.py's dedicated "Add Strike" flow / map
|
||||
# right-click), never produced by OCR.
|
||||
STRIKE_REQUEST = "Strike Request" # a planned impact point a friendly
|
||||
# unit is calling in over the radio (ocr.py's "taking fire" fire-
|
||||
# support-request grammar, when it names a bearing/distance offset
|
||||
# from the reporter rather than the reporter's own position), as
|
||||
# opposed to STRIKE, which the player places themselves. Same
|
||||
# "not an enemy contact, just an impact point" shape as STRIKE
|
||||
# (dedupe_generic_targets/icons.py both treat the two the same way),
|
||||
# kept as its own type rather than reusing STRIKE so a request that
|
||||
# came in over the radio is never confused for one the player chose.
|
||||
|
||||
# -- Ground combat units -------------------------------------------
|
||||
ANTI_AIR = "Anti-Air"
|
||||
ANTI_TANK = "Anti-Tank"
|
||||
ARTILLERY = "Artillery" # "Coastal Battery" is just this, see _TYPE_WORD_ALIASES in ocr.py
|
||||
ARTILLERY_OBSERVER = "Field Artillery Observer"
|
||||
HEAVY_GUN_TURRET = "Heavy Gun Turret"
|
||||
INFANTRY = "Infantry" # ground troops
|
||||
INFANTRY_MECHANIZED = "Mechanized Infantry"
|
||||
MECH_ANTI_TANK = "Mechanized Anti-Tank" # friendly-only, no Enemy_ art (icons.py's
|
||||
# _FRIENDLY_ONLY_BASENAME), unlike ANTI_TANK which both sides draw
|
||||
MECHANIZED = "Mechanized" # armored/vehicle unit
|
||||
PILLBOX = "Pillbox" # armoured emplacement, fixed position
|
||||
TANK = "Tank"
|
||||
|
||||
# -- Command & installations ----------------------------------------
|
||||
BASE = "Base"
|
||||
COMMANDER = "Commander"
|
||||
FDC = "FDC" # Fire Direction Center, coordinates counter-battery fire
|
||||
FORT = "Fort" # friendly-only; UNDERGROUND_FORT is the enemy-side equivalent concept
|
||||
GENERAL = "General" # friendly-only, no Enemy_ art
|
||||
KING = "King" # friendly-only, no Enemy_ art
|
||||
MARINE_GARRISON = "Marine Garrison" # requests fire support (see Target.requested_time)
|
||||
POLICE = "Police" # friendly-only, no Enemy_ art
|
||||
SUPPLY_CACHE = "Supply Cache"
|
||||
UNDERGROUND_FORT = "Underground Fort"
|
||||
|
||||
# -- Medical ----------------------------------------------------------
|
||||
EMERGENCY_MEDICAL = "Emergency Medical Operation"
|
||||
HOSPITAL = "Hospital" # friendly-only; MEDICAL_FACILITY is the enemy-side equivalent concept
|
||||
MEDICAL = "Medical"
|
||||
MEDICAL_FACILITY = "Medical Treatment Facility"
|
||||
|
||||
# -- Civil --------------------------------------------------------------
|
||||
CIVIL_MILITARY = "Civil–Military" # friendly-only, no Enemy_ art
|
||||
CIVILIAN = "Civilian"
|
||||
CIVIL_RIOTING = "Civil Rioting"
|
||||
RIOTING = "Rioting"
|
||||
TV_RADIO_PROPAGANDA = "TV and Radio Propaganda"
|
||||
|
||||
# -- Naval ------------------------------------------------------------
|
||||
PORT = "Port"
|
||||
SHIP = "Ship"
|
||||
SHIP_ENGINE = "Ship Engine"
|
||||
SHIP_FDC = "Ship FDC"
|
||||
SHIP_STRIPE = "Ship (Stripe)"
|
||||
SHIP_TURRET = "Ship Turret"
|
||||
|
||||
# -- Rail -----------------------------------------------------------
|
||||
TRAIN_LOCOMOTIVE = "Train Locomotive"
|
||||
TRAIN_STATION = "Train Station"
|
||||
TRAIN_TRANSPORT = "Train Transport"
|
||||
|
||||
# -- Reconnaissance ---------------------------------------------------
|
||||
RECON = "Recon"
|
||||
RECON_LISTENING = "Recon (Listening)"
|
||||
|
||||
@property
|
||||
def short(self) -> str:
|
||||
@@ -344,6 +411,10 @@ class Target:
|
||||
# raw string as printed, this app doesn't track a game clock to compare
|
||||
# it against, it's shown as-is for the player's own reference.
|
||||
requested_time: str | None = None
|
||||
# None = not underground. 1..3 = underground, at that hardening tier
|
||||
# (see icons.UNDERGROUND_TIERS) -- higher survives more. Target-only:
|
||||
# there's no such thing as an underground Ally in this game.
|
||||
underground_tier: int | None = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -432,6 +503,39 @@ class ScoutFlight:
|
||||
return f"ScoutFlight#{self.id}"
|
||||
|
||||
|
||||
def _next_free_id(used: set[str]) -> str:
|
||||
"""Next unused id in a short, human-friendly LETTER sequence: single
|
||||
uppercase letters (A..Z) first, then two-letter combinations
|
||||
(AA..ZZ, spreadsheet-column style) once those run out, and so on.
|
||||
Scoped per TYPE, not per group: add_target()/add_ally() only look at
|
||||
existing entities of the SAME type when building `used`, so a Tank
|
||||
and an Infantry added back to back both start at 'A' (Tank#A,
|
||||
Infantry#A), each type keeping its own independent sequence.
|
||||
|
||||
Deliberately letters, not numbers: a manually-added entity (map
|
||||
right-click "Add target", or an accepted screenshot proposal with no
|
||||
confident id read) has no real game id to report, so it gets an
|
||||
obviously-not-a-real-id placeholder instead -- app.py's
|
||||
_accept_proposal reserves plain numbers for an id it's actually
|
||||
confident was read off the marker itself (map_vision.read_marker_id
|
||||
via Proposal.detected_id), passed straight through as this
|
||||
function's caller's `id_` and never touching this auto-assignment at
|
||||
all. Letters can't collide with a real (numeric) detected id either,
|
||||
on top of just reading honestly as 'made up'.
|
||||
|
||||
Can't run out the way `next(c for c in string.ascii_uppercase if c
|
||||
not in used)` used to (a real regression, see TODO.md): rolls over to
|
||||
two-letter ids ('AA', 'AB', ...) past 26 instead of raising
|
||||
StopIteration."""
|
||||
length = 1
|
||||
while True:
|
||||
for combo in itertools.product(string.ascii_uppercase, repeat=length):
|
||||
candidate = "".join(combo)
|
||||
if candidate not in used:
|
||||
return candidate
|
||||
length += 1
|
||||
|
||||
|
||||
SAVE_FORMAT_VERSION = 3
|
||||
|
||||
|
||||
@@ -502,9 +606,18 @@ class Board:
|
||||
location: Location | Coord | None = None,
|
||||
id_: str | None = None,
|
||||
) -> Target:
|
||||
# Own A/B/C... sequence per TYPE, not one shared across every
|
||||
# target regardless of type -- a Tank and an Infantry auto-
|
||||
# assigned back to back both start at 'A' (Tank#A, Infantry#A).
|
||||
# targets-vs-allies is still its own separate id namespace (see
|
||||
# add_ally); type now subdivides it further too. Letters, not
|
||||
# numbers, when auto-assigning here specifically: see
|
||||
# _next_free_id's own docstring for why (a real detected id, when
|
||||
# there is one, is passed in as `id_` and never reaches this
|
||||
# auto-assignment at all).
|
||||
if not id_:
|
||||
used = {t.id for t in self.targets if t.type == type_}
|
||||
id_ = next(c for c in string.ascii_uppercase if c not in used)
|
||||
id_ = _next_free_id(used)
|
||||
t = Target(type=type_, id=id_, location=_as_location(location))
|
||||
self.targets.append(t)
|
||||
return t
|
||||
@@ -519,13 +632,14 @@ class Board:
|
||||
location: Location | Coord | None = None,
|
||||
id_: str | None = None,
|
||||
) -> Ally:
|
||||
# A separate id namespace from add_target()'s: an ally Tank#1
|
||||
# and a hostile Target Tank#1 are unrelated, so auto-assignment
|
||||
# here only looks at other allies of the same type, never
|
||||
# self.targets.
|
||||
# A separate id namespace from add_target()'s: an ally Tank#A
|
||||
# and a hostile Target Tank#A are unrelated, so auto-assignment
|
||||
# here only looks at other allies, never self.targets. Own
|
||||
# A/B/C... sequence per TYPE too, same as add_target -- see its
|
||||
# own comment and _next_free_id.
|
||||
if not id_:
|
||||
used = {a.id for a in self.allies if a.type == type_}
|
||||
id_ = next(c for c in string.ascii_uppercase if c not in used)
|
||||
id_ = _next_free_id(used)
|
||||
a = Ally(type=type_, id=id_, location=_as_location(location))
|
||||
self.allies.append(a)
|
||||
return a
|
||||
@@ -553,16 +667,30 @@ class Board:
|
||||
# -- reset ----------------------------------------------------------
|
||||
def clear(self) -> None:
|
||||
"""Drop everything: Nest position, spotters, reference points,
|
||||
targets, scout flights. Used by the "clear board" action for a
|
||||
fresh start without restarting the app."""
|
||||
targets, allies, scout flights. Used by the "clear board" action
|
||||
for a fresh start without restarting the app."""
|
||||
self.nest = Nest()
|
||||
self.spotters.clear()
|
||||
self.reference_points.clear()
|
||||
self.targets.clear()
|
||||
self.allies.clear()
|
||||
self.scout_flights.clear()
|
||||
self._spotter_seq = 0
|
||||
self._scout_flight_seq = 0
|
||||
|
||||
def clear_units(self) -> None:
|
||||
"""Partial reset: drop targets, allies, and scout flights, but keep
|
||||
the Nest, spotters, and reference points -- those are recon
|
||||
infrastructure the player set up deliberately and usually wants to
|
||||
keep across a round, unlike enemy/ally contacts and planned
|
||||
overflights, which go stale fast. Wired to the Clear button's
|
||||
right-click menu ("Clear enemies, units & flights") as a lighter
|
||||
alternative to clear()."""
|
||||
self.targets.clear()
|
||||
self.allies.clear()
|
||||
self.scout_flights.clear()
|
||||
self._scout_flight_seq = 0
|
||||
|
||||
def reorder_target(self, target: Target, new_index: int) -> None:
|
||||
"""Manual drag-order: `self.targets`' list order is itself the
|
||||
persisted order (saved/loaded as a plain JSON array), and is what
|
||||
@@ -660,6 +788,7 @@ class Board:
|
||||
"shell": t.shell.name if t.shell is not None else None,
|
||||
"assignment": t.assignment,
|
||||
"requested_time": t.requested_time,
|
||||
"underground_tier": t.underground_tier,
|
||||
}
|
||||
for t in self.targets
|
||||
],
|
||||
@@ -727,6 +856,7 @@ class Board:
|
||||
shell=Shell[t["shell"]] if t.get("shell") else None,
|
||||
assignment=t.get("assignment", "unassigned"),
|
||||
requested_time=t.get("requested_time"),
|
||||
underground_tier=t.get("underground_tier"),
|
||||
)
|
||||
for t in data.get("targets", [])
|
||||
]
|
||||
|
||||
@@ -197,6 +197,106 @@ def _extract_requested_time(text: str) -> str | None:
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
# A second, unrelated fire-support-request grammar, seen from Infantry
|
||||
# under attack ("taking fire") rather than a pinned Marine Garrison:
|
||||
# Infantry#1 taking fire from id1! Requesting SMK Shell on our
|
||||
# position at J6 2:7 before 10:38:57!
|
||||
# Infantry#3 taking fire! Requesting HE Shell at bearing 239°,
|
||||
# distance 10.76km from our position, J6 2:5, by 10:38:18 or we
|
||||
# will be overrun!
|
||||
# Differs from the Marine Garrison shape in every particular: the shell
|
||||
# word order is reversed ("Requesting X Shell", not "X Shells requested"),
|
||||
# the deadline has no "Requested"/dashes, just a bare "before"/"by <time>",
|
||||
# and the target position is either given directly ("on our position at
|
||||
# <coord>") or as a bearing/distance offset from that same inline
|
||||
# position (never a *named* reference -- "our position" isn't a board
|
||||
# entity, so this resolves the offset directly rather than going through
|
||||
# a Clue).
|
||||
# \s* (not \s+) between the shell code and 'Shell(s)': a rich-text paste's
|
||||
# '<b>SMK Shell</b>' span gets squashed into one no-space token 'SMKShell'
|
||||
# by squash_bold_spans() before this ever runs (same as any other
|
||||
# multi-word bold span, see _squash_span_content), same reasoning as
|
||||
# _extract_requesting_shell()'s docstring.
|
||||
_REQUESTING_SHELL_RE = re.compile(r"Requesting\s+([A-Za-z]+?)\s*Shells?\b", re.IGNORECASE)
|
||||
_TAKING_FIRE_TIME_RE = re.compile(r"\b(?:before|by)\s+(T?\d{1,2}:\d{2}:\d{2})\b", re.IGNORECASE)
|
||||
# "<Type>#<id> taking fire!" always names the REPORTING unit calling in
|
||||
# its own distress -- necessarily a friendly, no hostile ever radios in
|
||||
# about itself under attack. There's no "Friendly"/"Hostile" prefix word
|
||||
# anywhere in this grammar (see this module's own comment above) for
|
||||
# _resolve_target_type to key off of, so without this the reporting unit
|
||||
# defaults to not-ally (its own default) and gets added as an enemy.
|
||||
_TAKING_FIRE_RE = re.compile(r"\btaking fire\b", re.IGNORECASE)
|
||||
_ON_OUR_POSITION_COORD_RE = re.compile(
|
||||
rf"on\s+our\s+position\s+at\s+{_COORD_FRAGMENT}", re.IGNORECASE
|
||||
)
|
||||
_BEARING_DISTANCE_FROM_POSITION_RE = re.compile(
|
||||
rf"bearing\s*({_DIGIT_CLASS}{{1,3}})\s*°?\s*,?\s*distance\s*([\d.]+)\s*k?m\s+from\s+our\s+"
|
||||
rf"position,?\s*{_COORD_FRAGMENT}",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _extract_requesting_shell(text: str) -> Shell | None:
|
||||
"""'Requesting SMK Shell' -- the taking-fire grammar's shell mention,
|
||||
word order reversed from _extract_shell_request()'s Marine Garrison
|
||||
one ('SMK Shells requested')."""
|
||||
m = _REQUESTING_SHELL_RE.search(text)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return Shell[m.group(1).upper()]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_taking_fire_time(text: str) -> str | None:
|
||||
m = _TAKING_FIRE_TIME_RE.search(text)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _extract_on_our_position_coord(text: str) -> Coord | None:
|
||||
m = _ON_OUR_POSITION_COORD_RE.search(text)
|
||||
if not m:
|
||||
return None
|
||||
return _coord_from_groups(*m.groups())
|
||||
|
||||
|
||||
def _extract_bearing_distance_from_position_coord(text: str) -> Coord | None:
|
||||
"""The bearing/distance variant of a taking-fire request: the shell is
|
||||
wanted somewhere OFF the reporting unit's own position, given as a
|
||||
bearing/distance from it, with that position itself given inline
|
||||
right there ('...from our position, J6 2:5, by ...'). "our position"
|
||||
isn't a named board entity to hang a Clue off of, so this resolves
|
||||
the offset directly via the same polar-projection math solve_location()
|
||||
uses for an ordinary single bearing+distance clue."""
|
||||
m = _BEARING_DISTANCE_FROM_POSITION_RE.search(text)
|
||||
if not m:
|
||||
return None
|
||||
bearing, distance, letter, y, x, yy = m.groups()
|
||||
origin = _coord_from_groups(letter, y, x, yy)
|
||||
if origin is None:
|
||||
return None
|
||||
point = solver.point_from_bearing_distance(
|
||||
origin.as_fraction(), float(_fix_digits(bearing)), float(distance))
|
||||
return solver.point_to_coord(point)
|
||||
|
||||
|
||||
def _extract_our_position_coord(text: str) -> Coord | None:
|
||||
"""The bearing/distance variant's OWN inline position ('...from our
|
||||
position, J6 2:5, by ...'), as opposed to
|
||||
_extract_bearing_distance_from_position_coord's computed offset from
|
||||
it. Reported unit and requested fire point are two different places
|
||||
for this variant (unlike the direct "on our position at <coord>" one,
|
||||
a real danger-close call), so parse_intel_blocks's flush() uses this
|
||||
for the reporting unit's own entry and the offset for a second,
|
||||
separate Strike entry -- see its comment."""
|
||||
m = _BEARING_DISTANCE_FROM_POSITION_RE.search(text)
|
||||
if not m:
|
||||
return None
|
||||
_bearing, _distance, letter, y, x, yy = m.groups()
|
||||
return _coord_from_groups(letter, y, x, yy)
|
||||
|
||||
|
||||
# "Reported active in grid D10": only the large-grid cell, no sub-grid
|
||||
# x:y at all, unlike every other coord shape in this file. Tried last
|
||||
# (after _extract_grid_coord, which requires the full x:y and so is
|
||||
@@ -275,6 +375,17 @@ _NAMED_HEADER_RE = re.compile(rf"^{_TYPE_ID_FRAGMENT}\s*:?\s*(.*)$")
|
||||
# one), nothing else anchors this match, so an optional colon would
|
||||
# false-positive on an ordinary clue-continuation line's leading word.
|
||||
_BARE_NAME_HEADER_RE = re.compile(r"^([A-Za-z][A-Za-z0-9]*)\s*:\s*(.*)$")
|
||||
# Even requiring the colon isn't quite enough: a prose lead-in word right
|
||||
# before a genuinely useful follow-up line ("Important: TEAR Shell first,
|
||||
# then HE Shell.") false-positives the same way -- confirmed live against
|
||||
# a real taking-fire message, where "Important:" got read as a brand new
|
||||
# named entity ("Target#Important"), stealing the actual report's own
|
||||
# "Answer by <time>" deadline into that bogus entry instead of the real
|
||||
# one. None of these read as an actual thing being spotted/named, only
|
||||
# ever as a prose interjection.
|
||||
_BARE_NAME_HEADER_BLOCKLIST = {
|
||||
"important", "note", "warning", "attention", "caution", "alert", "reminder", "priority",
|
||||
}
|
||||
|
||||
# Ad-hoc enemy installations are named in plain English rather than given a
|
||||
# Type#N id ("Enemy Signal Station:", "Bearing 034 from Enemy Signal
|
||||
@@ -515,10 +626,15 @@ _BARE_CLUE_VALUE_RE = re.compile(
|
||||
|
||||
_TYPE_BY_SHORT = {t.short: t for t in TargetType}
|
||||
# The game's typewriter has used "AmmoCache" for what's now modeled as
|
||||
# SupplyCache, and "CoastalBattery" for what's just a HostileArtillery
|
||||
# under a different name, treat both as the same type rather than
|
||||
# dropping the target or inventing a redundant enum member for it.
|
||||
_TYPE_WORD_ALIASES = {"AmmoCache": "SupplyCache", "CoastalBattery": "HostileArtillery"}
|
||||
# SupplyCache, "CoastalBattery" for what's just a HostileArtillery under
|
||||
# a different name, and "Field Gun" for plain Artillery too (confirmed
|
||||
# by the user against a real "Enemy Field Gun#1 Destroyed" kill-feed line
|
||||
# that was otherwise silently dropping) -- treat all three as the same
|
||||
# type rather than dropping the target or inventing a redundant enum
|
||||
# member for each alternate name.
|
||||
_TYPE_WORD_ALIASES = {
|
||||
"AmmoCache": "SupplyCache", "CoastalBattery": "HostileArtillery", "FieldGun": "Artillery",
|
||||
}
|
||||
|
||||
|
||||
_REF_NAMED_RE = re.compile(rf"^{_TYPE_ID_FRAGMENT}")
|
||||
@@ -606,17 +722,27 @@ def parse_clues_from_text(text: str) -> list[Clue]:
|
||||
return _parse_all_clues(squash_enemy_names(squash_multiword_ids(text)))
|
||||
|
||||
|
||||
_ALLY_PREFIX_RE = re.compile(r"^(Friendly|Hostile)", re.IGNORECASE)
|
||||
# 'Enemy' is also a valid not-ally prefix ("Enemy Field Gun#1 Destroyed",
|
||||
# squashed to "EnemyFieldGun#1" by squash_multiword_ids -- confirmed live
|
||||
# by the user this was silently dropping, the FieldGun->Artillery alias
|
||||
# added below wasn't even reached because "Enemy" was never stripped off
|
||||
# first, so alias/fuzzy lookup ran against "EnemyFieldGun" as a whole,
|
||||
# not just "FieldGun"). The lookahead requires something AFTER the
|
||||
# prefix: a bare "Enemy" alone must NOT match here and fall through
|
||||
# un-stripped instead, since TargetType.ENEMY's own value IS "Enemy" --
|
||||
# stripping it there would leave an empty type_word with nothing left to
|
||||
# resolve, dropping every bare ad-hoc "Enemy#N Destroyed" report.
|
||||
_ALLY_PREFIX_RE = re.compile(r"^(Friendly|Hostile|Enemy)(?=.)", re.IGNORECASE)
|
||||
|
||||
|
||||
def _resolve_target_type(type_word: str) -> tuple[TargetType | None, bool]:
|
||||
"""(TargetType, is_ally). A leading 'Friendly'/'Hostile' word is
|
||||
stripped off the type word first ('FriendlyTank' -> ally, TANK;
|
||||
'HostileTank' or bare 'Tank' -> not ally, TANK, an explicit
|
||||
'Hostile' and no prefix at all mean the same thing, not-ally is the
|
||||
default). What's left is matched exactly against the type word
|
||||
(after aliasing), falling back to fuzzy (OCR can garble the type
|
||||
word itself, e.g. 'AmmoCoche')."""
|
||||
"""(TargetType, is_ally). A leading 'Friendly'/'Hostile'/'Enemy' word
|
||||
is stripped off the type word first ('FriendlyTank' -> ally, TANK;
|
||||
'HostileTank'/'EnemyTank'/bare 'Tank' -> not ally, TANK -- explicit
|
||||
'Hostile'/'Enemy' and no prefix at all all mean the same thing,
|
||||
not-ally is the default). What's left is matched exactly against the
|
||||
type word (after aliasing), falling back to fuzzy (OCR can garble
|
||||
the type word itself, e.g. 'AmmoCoche')."""
|
||||
is_ally = False
|
||||
prefix_m = _ALLY_PREFIX_RE.match(type_word)
|
||||
if prefix_m:
|
||||
@@ -657,16 +783,66 @@ def parse_intel_blocks(text: str) -> list[dict]:
|
||||
if current is not None:
|
||||
joined = "\n".join(current["raw"])
|
||||
current["clues"] = _parse_all_clues(joined)
|
||||
# The bearing/distance taking-fire variant names TWO different
|
||||
# places (see _extract_our_position_coord's docstring): the
|
||||
# reporting unit's own position, and a separate fire point
|
||||
# offset from it. Everything else in this module is "one block
|
||||
# -> one entry", so that offset gets split into a second,
|
||||
# synthetic StrikeRequest entry below rather than folded into
|
||||
# this one -- otherwise the fire point either overwrites the
|
||||
# unit's real position (wrong place) or gets silently dropped.
|
||||
offset_coord = _extract_bearing_distance_from_position_coord(joined)
|
||||
current["coord"] = (
|
||||
_extract_grid_coord(joined) or _extract_requested_on_coord(joined)
|
||||
or _extract_on_our_position_coord(joined)
|
||||
or _extract_our_position_coord(joined)
|
||||
or _extract_large_grid_only_coord(joined)
|
||||
)
|
||||
current["shell"] = _extract_shell_request(joined)
|
||||
current["requested_time"] = _extract_requested_time(joined)
|
||||
shell = _extract_shell_request(joined) or _extract_requesting_shell(joined)
|
||||
requested_time = _extract_requested_time(joined) or _extract_taking_fire_time(joined)
|
||||
# See _TAKING_FIRE_RE's own comment: overrides whatever
|
||||
# _resolve_target_type would otherwise infer from type_word
|
||||
# alone (no "Friendly"/"Hostile" prefix exists in this
|
||||
# grammar to key off of) -- the reporting unit is always the
|
||||
# friendly calling this in, never the hostile.
|
||||
is_taking_fire = bool(_TAKING_FIRE_RE.search(joined))
|
||||
current["force_ally"] = is_taking_fire
|
||||
# Fire-support info (shell/deadline) always describes the FIRE
|
||||
# POINT, never the reporting/named entity itself. For the
|
||||
# bearing/distance offset variant that's a different place
|
||||
# than the entity's own position. For a plain taking-fire
|
||||
# report ("on our position") it's the SAME coord as the
|
||||
# entity's own position, but the entity itself is now an ally
|
||||
# (force_ally above), and Ally entries carry no shell/deadline
|
||||
# fields at all to hold it -- either way a taking-fire
|
||||
# report's shell+deadline moves to a synthetic StrikeRequest
|
||||
# entry below rather than staying on this one, where it would
|
||||
# either be wrong (offset case) or silently dropped (ally
|
||||
# case, once merge_all only reads (raw, clues, coord) for allies).
|
||||
split_fire_request = offset_coord is not None or is_taking_fire
|
||||
current["shell"] = None if split_fire_request else shell
|
||||
current["requested_time"] = None if split_fire_request else requested_time
|
||||
if (current["clues"] or current["coord"] is not None
|
||||
or current["shell"] is not None or current["requested_time"] is not None):
|
||||
current["raw"] = joined
|
||||
entries.append(current)
|
||||
# No offset given ("on our position") means the fire point
|
||||
# IS the entity's own position, not a separate place.
|
||||
fire_coord = offset_coord if offset_coord is not None else current["coord"]
|
||||
if split_fire_request and fire_coord is not None and (shell is not None or requested_time is not None):
|
||||
# TargetType.STRIKE_REQUEST, not STRIKE: this is a
|
||||
# friendly unit calling in a strike over the radio, not
|
||||
# one the player placed themselves (see that type's own
|
||||
# comment in models.py). type_word must match its
|
||||
# TargetType.short exactly ("StrikeRequest", no space),
|
||||
# same as every other type_word this module produces.
|
||||
strike_id = f"{current['type_word']}{current['id']}"
|
||||
entries.append({
|
||||
"kind": "named", "name": f"StrikeRequest#{strike_id}",
|
||||
"type_word": "StrikeRequest",
|
||||
"id": strike_id, "raw": joined, "clues": [], "coord": fire_coord,
|
||||
"shell": shell, "requested_time": requested_time,
|
||||
})
|
||||
current = None
|
||||
|
||||
for raw_line in text.splitlines():
|
||||
@@ -743,7 +919,11 @@ def parse_intel_blocks(text: str) -> list[dict]:
|
||||
# 'Target'), not a Reference Point, a named thing giving its own
|
||||
# clues is being spotted, not a fixed landmark spotters aim off
|
||||
# of, same reasoning as the "Target is at-" calibration line.
|
||||
bare_m = next((m for c in candidates if (m := _BARE_NAME_HEADER_RE.match(c))), None)
|
||||
bare_m = next(
|
||||
(m for c in candidates if (m := _BARE_NAME_HEADER_RE.match(c))
|
||||
and m.group(1).lower() not in _BARE_NAME_HEADER_BLOCKLIST),
|
||||
None,
|
||||
)
|
||||
if bare_m:
|
||||
flush()
|
||||
name = bare_m.group(1)
|
||||
@@ -1007,6 +1187,7 @@ def parse_text(text: str) -> ParsedInfo:
|
||||
info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"])
|
||||
continue
|
||||
target_type, is_ally = _resolve_target_type(entry["type_word"])
|
||||
is_ally = is_ally or entry.get("force_ally", False)
|
||||
if target_type is None:
|
||||
continue
|
||||
if is_ally:
|
||||
|
||||
@@ -182,15 +182,28 @@ def point_to_coord(point: Point) -> Coord | None:
|
||||
col = min(max(col, 0.0), 19.999)
|
||||
row = min(max(row, 0.0), 9.999)
|
||||
|
||||
x_idx = int(col)
|
||||
x = round((col - x_idx) * 10)
|
||||
if x > 9:
|
||||
x, x_idx = 0, min(x_idx + 1, 19)
|
||||
# A real, reproducible bug lived here: Coord.as_fraction() centers a
|
||||
# sub-cell at x + 0.5 (so a marker drawn at its own coord's exact
|
||||
# pixel position round-trips back to the same coord), which means
|
||||
# the value being rounded here is supposed to land EXACTLY on a .5
|
||||
# boundary, the single worst case for floating point, tiny
|
||||
# representation error from the col/row math upstream (pixel <->
|
||||
# km conversions, zoom/pan, or even just this function's own
|
||||
# subtraction) can tip it to either side of round()'s tie-breaking
|
||||
# rule and silently return a coord one sub-cell off from the one
|
||||
# that was actually clicked (verified directly: reproduced with
|
||||
# zero pixel math involved at all, just Coord(...).as_fraction()
|
||||
# fed straight back into this function). Subtracting the 0.5 offset
|
||||
# BEFORE rounding recovers a value that's supposed to be an exact
|
||||
# integer instead of an exact half-integer, round() is robust to
|
||||
# tiny float noise around a true integer, just not around X.5.
|
||||
n_col = round(col * 10 - 0.5)
|
||||
x_idx, x = divmod(n_col, 10)
|
||||
x_idx = min(max(x_idx, 0), 19)
|
||||
|
||||
Y = int(row) + 1
|
||||
y = round((row - (Y - 1)) * 10)
|
||||
if y > 9:
|
||||
y, Y = 0, min(Y + 1, 10)
|
||||
n_row = round(row * 10 - 0.5)
|
||||
y_idx, y = divmod(n_row, 10)
|
||||
Y = min(max(y_idx, 0), 9) + 1
|
||||
|
||||
return Coord(X=LARGE_X[x_idx], Y=Y, x=x, y=y)
|
||||
|
||||
@@ -359,14 +372,16 @@ def dedupe_generic_targets(board: Board) -> list[str]:
|
||||
same* position as an already-known specific target, it's not a new
|
||||
contact, it's the same one being spotted, just described more
|
||||
precisely. Drop the redundant generic entry, keep the specific one.
|
||||
Strikes are our own planned impacts, not enemy contacts, and never
|
||||
participate. Run this after resolve_board(), since positions may
|
||||
only become comparable once resolved. Returns the names removed."""
|
||||
Strikes (player-placed or requested) are planned impacts, not enemy
|
||||
contacts, and never participate. Run this after resolve_board(),
|
||||
since positions may only become comparable once resolved. Returns
|
||||
the names removed."""
|
||||
removed: list[str] = []
|
||||
unknowns = [t for t in board.targets if t.type is TargetType.UNKNOWN and t.coord is not None]
|
||||
specifics = [
|
||||
t for t in board.targets
|
||||
if t.type not in (TargetType.UNKNOWN, TargetType.STRIKE) and t.coord is not None
|
||||
if t.type not in (TargetType.UNKNOWN, TargetType.STRIKE, TargetType.STRIKE_REQUEST)
|
||||
and t.coord is not None
|
||||
]
|
||||
for generic in unknowns:
|
||||
if any(generic.coord == specific.coord for specific in specifics):
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Test fixtures
|
||||
|
||||
Real screenshots of the game, used as regression data. They are the game
|
||||
author's work, not covered by this repo's MIT license (see `/LICENSE`).
|
||||
|
||||
## `map_shots/` + `map_shots_gt.json`
|
||||
|
||||
The evaluation set for the map-grid solver (`src/fenigma/map_vision.py`), scored
|
||||
by `tools/eval_map_vision.py`. The JSON holds hand-transcribed cell labels at
|
||||
native pixel positions; its own header comment explains the format and the
|
||||
9%/6% label-padding constant.
|
||||
|
||||
Shots wider than 2400px were downscaled to 2400px, and their ground-truth
|
||||
coordinates rescaled with them. 2400 is `map_vision.RETRY_WORK_W`, the widest
|
||||
the pipeline ever works at, so nothing the code can actually read was lost.
|
||||
Measured after the downscale: the same 7 of 10 solve, 100% of their points land
|
||||
in the correct cell, residual spread unchanged.
|
||||
|
||||
`too_hard/` holds shots that are permanent rejections; see its own README.
|
||||
|
||||
## `writer_shots/`
|
||||
|
||||
Typewriter/field-log screenshots. Two uses:
|
||||
|
||||
- Measuring false positives in the map-vs-text routing gate
|
||||
(`map_vision.looks_like_map` / `solve`). Over the full 122-shot set the cheap
|
||||
gate false-positived on 6% and `solve()` accepted **none**.
|
||||
- OCR regression material for `ocr.py`.
|
||||
|
||||
Kept at NATIVE resolution deliberately: the routing gate only ever sees 1500px,
|
||||
but OCR needs the text legible, so these must not be downscaled.
|
||||
|
||||
Ten shots are committed, chosen to span the capture-scale range (262px to
|
||||
5366px wide) since scale is what both the gate and OCR are sensitive to. The
|
||||
false-positive numbers above were measured on all 122; this subset is a
|
||||
regression guard, not the measurement.
|
||||
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 4.8 MiB |
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 4.5 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 501 KiB |
|
After Width: | Height: | Size: 4.5 MiB |
|
After Width: | Height: | Size: 791 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 570 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
@@ -0,0 +1,23 @@
|
||||
# Screenshots we deliberately do not try to solve
|
||||
|
||||
Kept for the record, excluded from the evaluation set. These fail for reasons
|
||||
that are properties of the screenshot, not of the algorithm, so working around
|
||||
them would mean guessing:
|
||||
|
||||
- `06.png`, `14.png` — exactly ONE grid label visible. A single label cannot be
|
||||
cross-checked, so a misread would silently shift the whole board with nothing
|
||||
to contradict it. Two mutually consistent labels is the minimum safe anchor.
|
||||
- `12.png` — 710x594 native. Too few pixels per cell for the label glyphs to
|
||||
correlate; measured, the best label score stays ~0.44 at every working
|
||||
resolution, so it is not a tuning problem.
|
||||
- `17.png`-`24.png` — same class as `12.png`: native resolution too low (605-1789px
|
||||
wide, vs. 1392-2400px for the fixtures that do solve) for the label glyphs to
|
||||
correlate once warped to the canonical cell size; measured, the best label
|
||||
score stays ~0.45-0.52 at every working resolution tried (including the
|
||||
2400px retry pass), well short of LABEL_ACCEPT (0.62) and inside the
|
||||
documented "wrong read" band (0.40-0.56), not close enough to call it a
|
||||
tuning problem. Two of the ten screenshots this batch came from (now
|
||||
`15.png`/`16.png` in the main set) were high enough resolution to solve --
|
||||
the same camera distance/game zoom just wasn't consistent across the batch.
|
||||
|
||||
"Too zoomed in" and "too low resolution" are legitimate hard rejections.
|
||||
@@ -0,0 +1,739 @@
|
||||
{
|
||||
"_comment": [
|
||||
"Ground truth for the map-vision fixtures, transcribed by hand from the",
|
||||
"screenshots. Each entry is [cell_label, x, y] in NATIVE pixel coordinates",
|
||||
"of the corresponding file in map_shots/, where (x, y) is roughly the",
|
||||
"centre of the drawn cell label glyphs.",
|
||||
"",
|
||||
"The invariant being asserted is simply: the pixel (x, y) lies inside the",
|
||||
"map cell named by cell_label. That is enough to catch every failure mode",
|
||||
"seen so far (wrong lattice scale, wrong integer offset, badly misfitted",
|
||||
"homography) without needing sub-pixel corner annotation.",
|
||||
"",
|
||||
"Positions were read off a ruler overlay by eye, so treat them as accurate",
|
||||
"to roughly +/-15 native px. The cell IDENTITIES are exact.",
|
||||
"",
|
||||
"Game UI constant, useful for the estimator: a cell's label is drawn with",
|
||||
"about 9% of the cell size as padding from the cell's left edge and 6% from",
|
||||
"its top edge, so label_top_left - (0.09, 0.06) * cell_size lands on the",
|
||||
"cell's top-left corner."
|
||||
],
|
||||
"01.png": [
|
||||
[
|
||||
"G10",
|
||||
899,
|
||||
294
|
||||
],
|
||||
[
|
||||
"H10",
|
||||
996,
|
||||
294
|
||||
],
|
||||
[
|
||||
"I10",
|
||||
1083,
|
||||
294
|
||||
],
|
||||
[
|
||||
"J10",
|
||||
1168,
|
||||
294
|
||||
],
|
||||
[
|
||||
"K10",
|
||||
1270,
|
||||
294
|
||||
],
|
||||
[
|
||||
"L10",
|
||||
1355,
|
||||
294
|
||||
],
|
||||
[
|
||||
"M10",
|
||||
1446,
|
||||
294
|
||||
],
|
||||
[
|
||||
"N10",
|
||||
1537,
|
||||
294
|
||||
],
|
||||
[
|
||||
"O10",
|
||||
1628,
|
||||
294
|
||||
],
|
||||
[
|
||||
"P10",
|
||||
1716,
|
||||
294
|
||||
],
|
||||
[
|
||||
"G9",
|
||||
882,
|
||||
366
|
||||
],
|
||||
[
|
||||
"I9",
|
||||
1072,
|
||||
366
|
||||
],
|
||||
[
|
||||
"K9",
|
||||
1266,
|
||||
366
|
||||
],
|
||||
[
|
||||
"M9",
|
||||
1456,
|
||||
366
|
||||
],
|
||||
[
|
||||
"O9",
|
||||
1650,
|
||||
366
|
||||
],
|
||||
[
|
||||
"H8",
|
||||
965,
|
||||
445
|
||||
],
|
||||
[
|
||||
"J8",
|
||||
1168,
|
||||
445
|
||||
],
|
||||
[
|
||||
"L8",
|
||||
1370,
|
||||
445
|
||||
],
|
||||
[
|
||||
"N8",
|
||||
1572,
|
||||
445
|
||||
],
|
||||
[
|
||||
"H7",
|
||||
953,
|
||||
536
|
||||
],
|
||||
[
|
||||
"J7",
|
||||
1166,
|
||||
536
|
||||
],
|
||||
[
|
||||
"L7",
|
||||
1379,
|
||||
536
|
||||
],
|
||||
[
|
||||
"N7",
|
||||
1592,
|
||||
536
|
||||
],
|
||||
[
|
||||
"H6",
|
||||
935,
|
||||
637
|
||||
],
|
||||
[
|
||||
"J6",
|
||||
1166,
|
||||
637
|
||||
],
|
||||
[
|
||||
"L6",
|
||||
1391,
|
||||
637
|
||||
],
|
||||
[
|
||||
"N6",
|
||||
1619,
|
||||
637
|
||||
]
|
||||
],
|
||||
"02.png": [
|
||||
[
|
||||
"I9",
|
||||
54,
|
||||
28
|
||||
],
|
||||
[
|
||||
"J9",
|
||||
329,
|
||||
28
|
||||
],
|
||||
[
|
||||
"K9",
|
||||
608,
|
||||
30
|
||||
],
|
||||
[
|
||||
"L9",
|
||||
880,
|
||||
28
|
||||
],
|
||||
[
|
||||
"M9",
|
||||
1155,
|
||||
28
|
||||
],
|
||||
[
|
||||
"I8",
|
||||
37,
|
||||
257
|
||||
],
|
||||
[
|
||||
"J8",
|
||||
320,
|
||||
257
|
||||
],
|
||||
[
|
||||
"K8",
|
||||
620,
|
||||
257
|
||||
],
|
||||
[
|
||||
"L8",
|
||||
902,
|
||||
257
|
||||
],
|
||||
[
|
||||
"M8",
|
||||
1197,
|
||||
257
|
||||
],
|
||||
[
|
||||
"I7",
|
||||
11,
|
||||
517
|
||||
],
|
||||
[
|
||||
"J7",
|
||||
316,
|
||||
517
|
||||
],
|
||||
[
|
||||
"K7",
|
||||
626,
|
||||
517
|
||||
],
|
||||
[
|
||||
"L7",
|
||||
936,
|
||||
517
|
||||
],
|
||||
[
|
||||
"M7",
|
||||
1248,
|
||||
517
|
||||
],
|
||||
[
|
||||
"J6",
|
||||
313,
|
||||
805
|
||||
],
|
||||
[
|
||||
"K6",
|
||||
638,
|
||||
805
|
||||
],
|
||||
[
|
||||
"L6",
|
||||
968,
|
||||
805
|
||||
],
|
||||
[
|
||||
"M6",
|
||||
1295,
|
||||
805
|
||||
]
|
||||
],
|
||||
"03.png": [
|
||||
[
|
||||
"H8",
|
||||
224,
|
||||
229
|
||||
],
|
||||
[
|
||||
"I8",
|
||||
856,
|
||||
229
|
||||
],
|
||||
[
|
||||
"J8",
|
||||
1496,
|
||||
229
|
||||
],
|
||||
[
|
||||
"K8",
|
||||
2136,
|
||||
229
|
||||
],
|
||||
[
|
||||
"H7",
|
||||
216,
|
||||
853
|
||||
],
|
||||
[
|
||||
"I7",
|
||||
896,
|
||||
853
|
||||
],
|
||||
[
|
||||
"J7",
|
||||
1555,
|
||||
853
|
||||
],
|
||||
[
|
||||
"K7",
|
||||
2224,
|
||||
853
|
||||
]
|
||||
],
|
||||
"04.png": [
|
||||
[
|
||||
"N8",
|
||||
243,
|
||||
180
|
||||
],
|
||||
[
|
||||
"O8",
|
||||
1119,
|
||||
180
|
||||
],
|
||||
[
|
||||
"P8",
|
||||
1996,
|
||||
180
|
||||
],
|
||||
[
|
||||
"N7",
|
||||
232,
|
||||
1038
|
||||
],
|
||||
[
|
||||
"O7",
|
||||
1135,
|
||||
1038
|
||||
],
|
||||
[
|
||||
"P7",
|
||||
2051,
|
||||
1038
|
||||
]
|
||||
],
|
||||
"05.png": [
|
||||
[
|
||||
"M3",
|
||||
261,
|
||||
213
|
||||
],
|
||||
[
|
||||
"N3",
|
||||
1160,
|
||||
213
|
||||
]
|
||||
],
|
||||
"07.png": [
|
||||
[
|
||||
"J8",
|
||||
460,
|
||||
285
|
||||
],
|
||||
[
|
||||
"K8",
|
||||
1221,
|
||||
285
|
||||
],
|
||||
[
|
||||
"L8",
|
||||
1973,
|
||||
285
|
||||
],
|
||||
[
|
||||
"J7",
|
||||
456,
|
||||
1036
|
||||
],
|
||||
[
|
||||
"K7",
|
||||
1256,
|
||||
1036
|
||||
],
|
||||
[
|
||||
"L7",
|
||||
2053,
|
||||
1036
|
||||
]
|
||||
],
|
||||
"08.png": [
|
||||
[
|
||||
"L8",
|
||||
184,
|
||||
417
|
||||
],
|
||||
[
|
||||
"M8",
|
||||
1085,
|
||||
417
|
||||
],
|
||||
[
|
||||
"N8",
|
||||
1983,
|
||||
417
|
||||
],
|
||||
[
|
||||
"L7",
|
||||
176,
|
||||
1341
|
||||
],
|
||||
[
|
||||
"M7",
|
||||
1121,
|
||||
1341
|
||||
]
|
||||
],
|
||||
"09.png": [
|
||||
[
|
||||
"O9",
|
||||
1348,
|
||||
180
|
||||
],
|
||||
[
|
||||
"P9",
|
||||
1520,
|
||||
180
|
||||
],
|
||||
[
|
||||
"Q9",
|
||||
1692,
|
||||
180
|
||||
],
|
||||
[
|
||||
"M8",
|
||||
993,
|
||||
336
|
||||
],
|
||||
[
|
||||
"N8",
|
||||
1172,
|
||||
336
|
||||
],
|
||||
[
|
||||
"O8",
|
||||
1352,
|
||||
336
|
||||
],
|
||||
[
|
||||
"P8",
|
||||
1536,
|
||||
336
|
||||
],
|
||||
[
|
||||
"Q8",
|
||||
1718,
|
||||
336
|
||||
],
|
||||
[
|
||||
"M7",
|
||||
982,
|
||||
513
|
||||
],
|
||||
[
|
||||
"N7",
|
||||
1175,
|
||||
513
|
||||
],
|
||||
[
|
||||
"O7",
|
||||
1366,
|
||||
513
|
||||
],
|
||||
[
|
||||
"P7",
|
||||
1557,
|
||||
513
|
||||
],
|
||||
[
|
||||
"Q7",
|
||||
1748,
|
||||
513
|
||||
]
|
||||
],
|
||||
"11.png": [
|
||||
[
|
||||
"J9",
|
||||
174,
|
||||
133
|
||||
],
|
||||
[
|
||||
"K9",
|
||||
996,
|
||||
133
|
||||
],
|
||||
[
|
||||
"L9",
|
||||
1803,
|
||||
133
|
||||
],
|
||||
[
|
||||
"J8",
|
||||
159,
|
||||
956
|
||||
],
|
||||
[
|
||||
"K8",
|
||||
999,
|
||||
956
|
||||
],
|
||||
[
|
||||
"L8",
|
||||
1832,
|
||||
956
|
||||
]
|
||||
],
|
||||
"13.png": [
|
||||
[
|
||||
"L4",
|
||||
182,
|
||||
213
|
||||
],
|
||||
[
|
||||
"M4",
|
||||
509,
|
||||
197
|
||||
],
|
||||
[
|
||||
"O4",
|
||||
1129,
|
||||
157
|
||||
],
|
||||
[
|
||||
"P4",
|
||||
1425,
|
||||
133
|
||||
],
|
||||
[
|
||||
"Q4",
|
||||
1711,
|
||||
117
|
||||
],
|
||||
[
|
||||
"L3",
|
||||
146,
|
||||
441
|
||||
],
|
||||
[
|
||||
"M3",
|
||||
511,
|
||||
412
|
||||
],
|
||||
[
|
||||
"N3",
|
||||
863,
|
||||
388
|
||||
],
|
||||
[
|
||||
"O3",
|
||||
1209,
|
||||
359
|
||||
],
|
||||
[
|
||||
"P3",
|
||||
1538,
|
||||
329
|
||||
],
|
||||
[
|
||||
"L2",
|
||||
106,
|
||||
724
|
||||
],
|
||||
[
|
||||
"M2",
|
||||
518,
|
||||
687
|
||||
],
|
||||
[
|
||||
"N2",
|
||||
914,
|
||||
651
|
||||
],
|
||||
[
|
||||
"O2",
|
||||
1304,
|
||||
615
|
||||
],
|
||||
[
|
||||
"P2",
|
||||
1664,
|
||||
580
|
||||
],
|
||||
[
|
||||
"L1",
|
||||
37,
|
||||
1100
|
||||
],
|
||||
[
|
||||
"M1",
|
||||
521,
|
||||
1053
|
||||
],
|
||||
[
|
||||
"N1",
|
||||
985,
|
||||
1005
|
||||
],
|
||||
[
|
||||
"O1",
|
||||
1428,
|
||||
953
|
||||
],
|
||||
[
|
||||
"P1",
|
||||
1852,
|
||||
910
|
||||
]
|
||||
],
|
||||
"_excluded": {
|
||||
"note": "moved to map_shots/too_hard/, see its README",
|
||||
"06.png": [
|
||||
[
|
||||
"L5",
|
||||
542,
|
||||
616
|
||||
]
|
||||
],
|
||||
"12.png": [
|
||||
[
|
||||
"M3",
|
||||
151,
|
||||
160
|
||||
],
|
||||
[
|
||||
"N3",
|
||||
535,
|
||||
136
|
||||
],
|
||||
[
|
||||
"M2",
|
||||
163,
|
||||
469
|
||||
],
|
||||
[
|
||||
"N2",
|
||||
580,
|
||||
440
|
||||
]
|
||||
],
|
||||
"14.png": [
|
||||
[
|
||||
"J7",
|
||||
1557,
|
||||
1659
|
||||
]
|
||||
]
|
||||
},
|
||||
"15.png": [
|
||||
[
|
||||
"N8",
|
||||
140,
|
||||
859
|
||||
],
|
||||
[
|
||||
"N9",
|
||||
153,
|
||||
2
|
||||
],
|
||||
[
|
||||
"O8",
|
||||
1042,
|
||||
859
|
||||
],
|
||||
[
|
||||
"P8",
|
||||
1949,
|
||||
859
|
||||
]
|
||||
],
|
||||
"16.png": [
|
||||
[
|
||||
"J5",
|
||||
62,
|
||||
573
|
||||
],
|
||||
[
|
||||
"J6",
|
||||
80,
|
||||
289
|
||||
],
|
||||
[
|
||||
"J7",
|
||||
97,
|
||||
31
|
||||
],
|
||||
[
|
||||
"K5",
|
||||
370,
|
||||
572
|
||||
],
|
||||
[
|
||||
"K6",
|
||||
374,
|
||||
289
|
||||
],
|
||||
[
|
||||
"K7",
|
||||
377,
|
||||
31
|
||||
],
|
||||
[
|
||||
"L5",
|
||||
678,
|
||||
572
|
||||
],
|
||||
[
|
||||
"L6",
|
||||
667,
|
||||
289
|
||||
],
|
||||
[
|
||||
"L7",
|
||||
657,
|
||||
31
|
||||
],
|
||||
[
|
||||
"M5",
|
||||
985,
|
||||
572
|
||||
],
|
||||
[
|
||||
"M6",
|
||||
959,
|
||||
288
|
||||
],
|
||||
[
|
||||
"M7",
|
||||
936,
|
||||
31
|
||||
],
|
||||
[
|
||||
"N5",
|
||||
1293,
|
||||
571
|
||||
],
|
||||
[
|
||||
"N6",
|
||||
1252,
|
||||
288
|
||||
],
|
||||
[
|
||||
"N7",
|
||||
1215,
|
||||
31
|
||||
]
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 1008 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 7.3 MiB |
|
After Width: | Height: | Size: 637 KiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 858 KiB |
|
After Width: | Height: | Size: 120 KiB |
@@ -0,0 +1,70 @@
|
||||
"""_accept_proposal: an accepted proposal's entity id should prefer the
|
||||
marker's own detected "#<N>" id (map_vision.read_marker_id, via
|
||||
Proposal.detected_id) over an auto-assigned letter, so ids on the board
|
||||
match what's actually on screen -- falling back to auto-assign (a
|
||||
letter, deliberately not a number, so it can't collide with or be
|
||||
mistaken for a real detected id -- see models.py's _next_free_id) only
|
||||
when there's no detection, or it collides with an id already used for
|
||||
that type in that group (see _accept_proposal's own docstring).
|
||||
|
||||
Needs a real Adw/Gtk init (MainWindow.__new__ skips __init__, so no
|
||||
window/widgets are actually built, but Adw.init() is still required for
|
||||
the module import chain), same pattern proven in this repo already for
|
||||
headlessly exercising GTK-adjacent code.
|
||||
"""
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Gdk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
from gi.repository import Adw # noqa: E402
|
||||
|
||||
Adw.init()
|
||||
|
||||
from fenigma.app import MainWindow # noqa: E402
|
||||
from fenigma.map_import import Proposal # noqa: E402
|
||||
from fenigma.models import Board # noqa: E402
|
||||
|
||||
|
||||
def _window() -> MainWindow:
|
||||
win = MainWindow.__new__(MainWindow) # skip __init__: no widgets needed for this
|
||||
win.board = Board()
|
||||
return win
|
||||
|
||||
|
||||
def _proposal(detected_id=None, side="hostile", sub_x=0, sub_y=0) -> Proposal:
|
||||
return Proposal(side=side, label="G8", sub_x=sub_x, sub_y=sub_y, unit=None,
|
||||
centre=(0, 0), box=(0, 0, 0, 0), detected_id=detected_id)
|
||||
|
||||
|
||||
def test_accept_uses_the_detected_id_when_present():
|
||||
win = _window()
|
||||
win._accept_proposal(_proposal(detected_id="8"))
|
||||
assert win.board.targets[0].id == "8"
|
||||
|
||||
|
||||
def test_accept_falls_back_to_auto_id_with_no_detection():
|
||||
win = _window()
|
||||
win._accept_proposal(_proposal(detected_id=None))
|
||||
assert win.board.targets[0].id == "A"
|
||||
|
||||
|
||||
def test_accept_falls_back_to_auto_id_on_a_detected_id_collision():
|
||||
win = _window()
|
||||
win._accept_proposal(_proposal(detected_id="8", sub_x=1))
|
||||
win._accept_proposal(_proposal(detected_id="8", sub_x=2)) # same detected id, must not collide
|
||||
ids = [t.id for t in win.board.targets]
|
||||
assert ids[0] == "8"
|
||||
assert ids[1] != "8"
|
||||
|
||||
|
||||
def test_accept_keeps_target_and_ally_id_detection_in_separate_namespaces():
|
||||
"""A detected id colliding with an existing ALLY id shouldn't force a
|
||||
TARGET accept to fall back -- targets/allies are already a separate
|
||||
id namespace everywhere else (Board.add_target/add_ally), detected-id
|
||||
preference shouldn't quietly merge them."""
|
||||
win = _window()
|
||||
win._accept_proposal(_proposal(detected_id="8", side="friendly"))
|
||||
win._accept_proposal(_proposal(detected_id="8", side="hostile"))
|
||||
assert win.board.allies[0].id == "8"
|
||||
assert win.board.targets[0].id == "8"
|
||||
@@ -0,0 +1,100 @@
|
||||
"""debug_capture just needs to reliably write what it's given and never
|
||||
raise into caller code -- these are format/plumbing checks, not vision
|
||||
tests."""
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from fenigma import debug_capture
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolated_debug_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _tiny_png_bytes() -> bytes:
|
||||
import io
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (4, 4), (10, 20, 30)).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_save_map_read_failure_writes_png_and_reason(_isolated_debug_dir):
|
||||
path = debug_capture.save_map_read_failure(_tiny_png_bytes(), "too few grid line families")
|
||||
assert path is not None
|
||||
assert path.exists()
|
||||
meta = json.loads(path.with_suffix(".json").read_text())
|
||||
assert meta["reason"] == "too few grid line families"
|
||||
|
||||
|
||||
def test_save_maybe_map_accepts_numpy_bgr_array(_isolated_debug_dir):
|
||||
bgr = np.zeros((4, 4, 3), dtype=np.uint8)
|
||||
bgr[..., 0] = 200 # blue channel, would come out red if BGR/RGB got swapped
|
||||
path = debug_capture.save_maybe_map(bgr)
|
||||
assert path is not None
|
||||
saved = Image.open(path)
|
||||
assert saved.getpixel((0, 0)) == (0, 0, 200) # still blue, not swapped to red
|
||||
|
||||
|
||||
def test_save_grid_correction_writes_both_solutions(_isolated_debug_dir):
|
||||
class FakeSolution:
|
||||
def __init__(self, du):
|
||||
self.H = np.eye(3)
|
||||
self.si, self.sj, self.du, self.dv = 1, 1, du, 0
|
||||
|
||||
path = debug_capture.save_grid_correction(_tiny_png_bytes(), FakeSolution(0.0), FakeSolution(0.3))
|
||||
assert path is not None
|
||||
meta = json.loads(path.with_suffix(".json").read_text())
|
||||
assert meta["original"]["du"] == 0.0
|
||||
assert meta["corrected"]["du"] == 0.3
|
||||
|
||||
|
||||
def test_unsupported_image_type_returns_none_without_raising(_isolated_debug_dir):
|
||||
assert debug_capture.save_maybe_map(object()) is None
|
||||
|
||||
|
||||
def _proposal(**overrides):
|
||||
from fenigma.map_import import Proposal
|
||||
defaults = dict(side="hostile", label="K8", sub_x=3, sub_y=4, unit="Armor_Tank.png",
|
||||
centre=(0, 0), box=(0, 0, 0, 0))
|
||||
defaults.update(overrides)
|
||||
return Proposal(**defaults)
|
||||
|
||||
|
||||
def test_marker_ground_truth_records_verdict_per_proposal(_isolated_debug_dir):
|
||||
accepted = _proposal(accepted=True, confirmed_type="TANK")
|
||||
rejected = _proposal(label="K9", rejected=True)
|
||||
undecided = _proposal(label="L1")
|
||||
|
||||
path = debug_capture.save_marker_ground_truth(_tiny_png_bytes(), [accepted, rejected, undecided])
|
||||
assert path is not None
|
||||
meta = json.loads(path.with_suffix(".json").read_text())
|
||||
by_label = {p["label"]: p for p in meta["proposals"]}
|
||||
assert by_label["K8"]["verdict"] == "accepted"
|
||||
assert by_label["K8"]["confirmed_type"] == "TANK"
|
||||
assert by_label["K9"]["verdict"] == "rejected"
|
||||
assert by_label["L1"]["verdict"] == "undecided"
|
||||
|
||||
|
||||
def test_marker_ground_truth_records_units_with_no_matching_proposal(_isolated_debug_dir):
|
||||
from fenigma.models import Board, Coord, TargetType
|
||||
|
||||
board = Board()
|
||||
manual_target = board.add_target(TargetType.TANK, Coord("K", 8, 3, 4))
|
||||
manual_ally = board.add_ally(TargetType.INFANTRY, Coord("K", 9, 0, 0))
|
||||
|
||||
path = debug_capture.save_marker_ground_truth(
|
||||
_tiny_png_bytes(), [], added_targets=[manual_target], added_allies=[manual_ally])
|
||||
assert path is not None
|
||||
meta = json.loads(path.with_suffix(".json").read_text())
|
||||
kinds = {(u["kind"], u["type"], u["coord"]) for u in meta["added_units"]}
|
||||
assert ("target", "TANK", "K8 3:4") in kinds
|
||||
assert ("ally", "INFANTRY", "K9 0:0") in kinds
|
||||
|
||||
|
||||
def test_marker_ground_truth_skips_when_nothing_to_say(_isolated_debug_dir):
|
||||
assert debug_capture.save_marker_ground_truth(_tiny_png_bytes(), []) is None
|
||||
@@ -0,0 +1,54 @@
|
||||
"""read_marker_id: template-correlation read of a marker's own small
|
||||
"#<N>" id label (see map_vision.read_marker_id's own docstring for why
|
||||
this is template correlation, not OCR -- same reasoning as
|
||||
read_cell_label). Synthetic image, real font, no fixture screenshot or
|
||||
the (slow) detection pipeline needed -- just render the label the way
|
||||
the game does and check it round-trips.
|
||||
"""
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from fenigma import map_vision
|
||||
|
||||
|
||||
def _render_label(text: str, height: int) -> Image.Image:
|
||||
"""Cream glyph, heavy dark outline, same style glyph_template expects
|
||||
to correlate against -- see glyph_template's own docstring."""
|
||||
font = ImageFont.truetype(str(map_vision.FONT_PATH), height)
|
||||
pad = height
|
||||
im = Image.new("L", (height * 4 + pad, height * 2 + pad), 30) # dark "photo" background
|
||||
ImageDraw.Draw(im).text((pad // 2, pad // 4), text, font=font, fill=230,
|
||||
stroke_width=max(1, int(height * 0.10)), stroke_fill=0)
|
||||
return im
|
||||
|
||||
|
||||
def test_reads_a_clean_id_label():
|
||||
# A marker box roughly where a real one measures (see read_marker_id's
|
||||
# own calibration note), with a rendered "#8" sitting where the game
|
||||
# draws it: above-left of the box.
|
||||
box_w, box_h = 40, 40
|
||||
label_h = int(0.45 * box_h)
|
||||
label_im = _render_label("#8", label_h)
|
||||
|
||||
canvas = Image.new("L", (200, 200), 60)
|
||||
label_x, label_y = 60, 60
|
||||
canvas.paste(label_im, (label_x, label_y))
|
||||
gray = np.array(canvas)
|
||||
|
||||
box_x = label_x + int(1.0 * box_w) - 5 # box sits to the right of/below the label
|
||||
box_y = label_y + int(0.45 * box_h)
|
||||
box = (box_x, box_y, box_w, box_h)
|
||||
|
||||
assert map_vision.read_marker_id(gray, box) == "8"
|
||||
|
||||
|
||||
def test_returns_none_on_a_blank_patch():
|
||||
gray = np.full((200, 200), 60, dtype=np.uint8)
|
||||
box = (100, 100, 40, 40)
|
||||
assert map_vision.read_marker_id(gray, box) is None
|
||||
|
||||
|
||||
def test_returns_none_on_a_degenerate_box_at_the_image_edge():
|
||||
gray = np.full((200, 200), 60, dtype=np.uint8)
|
||||
box = (0, 0, 2, 2) # crop region collapses to nothing usable
|
||||
assert map_vision.read_marker_id(gray, box) is None
|
||||
@@ -0,0 +1,59 @@
|
||||
"""warp_to_map's img_scale param: a caller can hand it a differently-sized
|
||||
image than the one `sol` was actually solved against (see
|
||||
map_vision.load_full_res / ScreenshotImport.full_image), scaled to
|
||||
compensate. This checks that compensation is correct, without needing a
|
||||
real fixture screenshot or the (slow) line-detection/solve pipeline --
|
||||
just a synthetic image and a stub solution with a predictable transform.
|
||||
"""
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from fenigma import map_vision
|
||||
|
||||
|
||||
class _IdentitySolution:
|
||||
"""H and lattice_to_grid() both identity: warp_to_map's transform then
|
||||
reduces to just grid_to_map, so the output is a directly px_per_km-
|
||||
scaled (and row-flipped, per warp_to_map's own comment) copy of
|
||||
whatever region of the input `warp_to_map` reads as "grid space"."""
|
||||
H = np.eye(3)
|
||||
|
||||
def lattice_to_grid(self):
|
||||
return np.eye(3)
|
||||
|
||||
|
||||
def test_img_scale_compensates_for_a_bigger_source_image():
|
||||
# A small solid-color source, plus a 2x upscaled copy of it -- same
|
||||
# content, different pixel dimensions.
|
||||
small = np.zeros((20, 20, 3), dtype=np.uint8)
|
||||
small[:, :] = (10, 20, 30) # BGR
|
||||
big = np.zeros((40, 40, 3), dtype=np.uint8)
|
||||
big[:, :] = (10, 20, 30)
|
||||
|
||||
sol = _IdentitySolution()
|
||||
out_small, ppk_small = map_vision.warp_to_map(small, sol, px_per_km=1)
|
||||
out_big, ppk_big = map_vision.warp_to_map(big, sol, px_per_km=1, img_scale=2.0)
|
||||
|
||||
assert ppk_small == ppk_big == 1
|
||||
assert out_small.shape == out_big.shape # output is always MAP_KM_W/H * px_per_km, regardless of source size
|
||||
# Same solid color warped in (opaque region only -- compare where both
|
||||
# actually painted something, alpha channel nonzero).
|
||||
painted = (out_small[:, :, 3] > 0) & (out_big[:, :, 3] > 0)
|
||||
assert painted.any()
|
||||
np.testing.assert_array_equal(out_small[painted][:, :3], out_big[painted][:, :3])
|
||||
|
||||
|
||||
def test_default_img_scale_is_unchanged_behavior():
|
||||
"""img_scale's default (1.0) must reproduce pre-existing behavior
|
||||
exactly -- every other warp_to_map call site doesn't pass it."""
|
||||
img = np.zeros((20, 20, 3), dtype=np.uint8)
|
||||
img[:, :] = (1, 2, 3)
|
||||
sol = _IdentitySolution()
|
||||
out_default, _ = map_vision.warp_to_map(img, sol, px_per_km=1)
|
||||
out_explicit, _ = map_vision.warp_to_map(img, sol, px_per_km=1, img_scale=1.0)
|
||||
np.testing.assert_array_equal(out_default, out_explicit)
|
||||
|
||||
|
||||
def test_load_full_res_raises_like_load_on_a_bad_path(tmp_path):
|
||||
with pytest.raises(ValueError):
|
||||
map_vision.load_full_res(tmp_path / "does-not-exist.png")
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Regression coverage for Board's bulk-mutation helpers (clear/clear_units)
|
||||
and the id namespaces Target/Ally are supposed to keep separate."""
|
||||
from fenigma.models import Board, Coord, TargetType
|
||||
|
||||
|
||||
def _coord(x=0, y=0):
|
||||
return Coord(X="A", Y=1, x=x, y=y)
|
||||
|
||||
|
||||
def test_clear_drops_allies_too():
|
||||
"""Board.clear() used to leave self.allies untouched -- the "clear
|
||||
board" action then reported success but a previously-placed ally
|
||||
stayed on the map."""
|
||||
board = Board()
|
||||
board.nest.coord = _coord()
|
||||
board.add_spotter(_coord())
|
||||
board.add_reference_point(_coord())
|
||||
board.add_target(TargetType.TANK, _coord())
|
||||
board.add_ally(TargetType.TANK, _coord())
|
||||
board.add_scout_flight((1.0, 1.0), 45.0)
|
||||
|
||||
board.clear()
|
||||
|
||||
assert board.nest.coord is None
|
||||
assert board.spotters == []
|
||||
assert board.reference_points == []
|
||||
assert board.targets == []
|
||||
assert board.allies == []
|
||||
assert board.scout_flights == []
|
||||
|
||||
|
||||
def test_clear_units_keeps_recon_infrastructure():
|
||||
"""The Clear button's right-click "Clear enemies, units & flights"
|
||||
option: drops targets/allies/scout flights but keeps the Nest,
|
||||
spotters, and reference points."""
|
||||
board = Board()
|
||||
board.nest.coord = _coord()
|
||||
sp = board.add_spotter(_coord())
|
||||
rp = board.add_reference_point(_coord())
|
||||
board.add_target(TargetType.TANK, _coord())
|
||||
board.add_ally(TargetType.TANK, _coord())
|
||||
board.add_scout_flight((1.0, 1.0), 45.0)
|
||||
|
||||
board.clear_units()
|
||||
|
||||
assert board.nest.coord is not None
|
||||
assert board.spotters == [sp]
|
||||
assert board.reference_points == [rp]
|
||||
assert board.targets == []
|
||||
assert board.allies == []
|
||||
assert board.scout_flights == []
|
||||
|
||||
|
||||
def test_ally_and_target_ids_are_independent_namespaces():
|
||||
"""An ally Tank#1 and a hostile Target Tank#1 are unrelated -- adding
|
||||
one must never be influenced by the other's ids, and auto-assignment
|
||||
on each side starts from 'A' independently. Explicit id_="1" here
|
||||
(as an accepted screenshot proposal's detected_id would pass, see
|
||||
app.py's _accept_proposal) to also check that auto-assignment
|
||||
correctly skips a real numeric id already in use, not just other
|
||||
letters."""
|
||||
board = Board()
|
||||
t1 = board.add_target(TargetType.TANK, _coord(), id_="1")
|
||||
a1 = board.add_ally(TargetType.TANK, _coord(), id_="1")
|
||||
assert t1.id == a1.id == "1"
|
||||
assert t1 is not a1
|
||||
|
||||
t_auto = board.add_target(TargetType.TANK, _coord())
|
||||
a_auto = board.add_ally(TargetType.TANK, _coord())
|
||||
assert t_auto.id == "A" # first free letter among *target* Tanks only
|
||||
assert a_auto.id == "A" # first free letter among *ally* Tanks only, unaffected by the target above
|
||||
|
||||
|
||||
def test_auto_id_is_per_type_within_targets_and_within_allies():
|
||||
"""Each TYPE gets its own independent A/B/C... sequence within a group
|
||||
(all targets, or all allies) -- a Tank and an Infantry auto-assigned
|
||||
back to back both start at 'A' (Tank#A, Infantry#A), rather than
|
||||
sharing one sequence across every type in the group."""
|
||||
board = Board()
|
||||
tank = board.add_target(TargetType.TANK, _coord())
|
||||
infantry = board.add_target(TargetType.INFANTRY, _coord())
|
||||
assert tank.id == "A"
|
||||
assert infantry.id == "A" # own sequence, not 'B' just because a Tank came first
|
||||
|
||||
second_tank = board.add_target(TargetType.TANK, _coord())
|
||||
assert second_tank.id == "B" # but a SECOND Tank does advance the Tank sequence
|
||||
|
||||
ally_tank = board.add_ally(TargetType.TANK, _coord())
|
||||
ally_infantry = board.add_ally(TargetType.INFANTRY, _coord())
|
||||
assert ally_tank.id == "A"
|
||||
assert ally_infantry.id == "A"
|
||||
|
||||
|
||||
def test_auto_id_survives_past_26_entities_of_one_type():
|
||||
"""A real crash: `next(c for c in string.ascii_uppercase if c not in
|
||||
used)` raises StopIteration the instant all 26 letters are taken --
|
||||
reachable after accepting/adding 26+ of the same type into one group
|
||||
in a single session. Must roll over to two-letter ids ('AA', 'AB',
|
||||
...) instead of raising."""
|
||||
board = Board()
|
||||
for _ in range(26):
|
||||
board.add_target(TargetType.TANK, _coord())
|
||||
twenty_seventh = board.add_target(TargetType.TANK, _coord())
|
||||
assert twenty_seventh.id == "AA"
|
||||
|
||||
board2 = Board()
|
||||
for _ in range(26):
|
||||
board2.add_ally(TargetType.TANK, _coord())
|
||||
twenty_seventh_ally = board2.add_ally(TargetType.TANK, _coord())
|
||||
assert twenty_seventh_ally.id == "AA"
|
||||
|
||||
|
||||
def test_find_by_name_prefers_target_over_same_named_ally():
|
||||
"""find_by_name() (used to resolve Clue references) checks targets
|
||||
before allies -- documented, deliberate priority, not a namespace
|
||||
collision: an ally and a same-typed/same-id target are still two
|
||||
distinct objects, this only matters when something's Clue names one
|
||||
ambiguously by the shared display name."""
|
||||
board = Board()
|
||||
target = board.add_target(TargetType.TANK, _coord(x=1), id_="1")
|
||||
board.add_ally(TargetType.TANK, _coord(x=2), id_="1")
|
||||
assert board.find_by_name("Tank#1") is target
|
||||
@@ -10,6 +10,7 @@ being noticed (or not) days later.
|
||||
"""
|
||||
from fenigma import ocr
|
||||
from fenigma.models import Coord, TargetType
|
||||
from fenigma.shells import Shell
|
||||
|
||||
|
||||
def test_standard_target_and_rp_blocks():
|
||||
@@ -51,6 +52,43 @@ def test_destroyed_reports_digit_and_letter_id():
|
||||
assert info.destroyed == {(TargetType.SUPPLY_CACHE, "2"), (TargetType.TANK, "3")}
|
||||
|
||||
|
||||
def test_destroyed_report_strips_a_leading_enemy_prefix():
|
||||
"""A real kill-feed paste with an "Enemy <Type>#<id> Destroyed" shape
|
||||
(squashed by squash_multiword_ids to "EnemyMechanizedInfantry#1"
|
||||
before this ever runs) was silently dropping every single-word type
|
||||
("Enemy Infantry#11 Destroyed") -- the un-stripped "Enemy" prefix
|
||||
only accidentally fuzzy-matched for longer/more distinctive type
|
||||
words (Mechanized Infantry), not shorter/more different ones (Field
|
||||
Gun -- see test_field_gun_is_an_artillery_alias). _ALLY_PREFIX_RE now
|
||||
strips "Enemy" the same as "Hostile"."""
|
||||
text = ("Enemy Mechanized Infantry#1 Destroyed, +5 Requisition.\n"
|
||||
"Enemy Infantry#11 Destroyed, +5 Requisition.")
|
||||
info = ocr.parse_text(text)
|
||||
assert info.destroyed == {(TargetType.INFANTRY_MECHANIZED, "1"), (TargetType.INFANTRY, "11")}
|
||||
|
||||
|
||||
def test_bare_enemy_destroyed_report_is_still_target_type_enemy():
|
||||
"""The lookahead in _ALLY_PREFIX_RE (only strip "Enemy" when there's
|
||||
something AFTER it) matters here specifically: a BARE "Enemy#N" is
|
||||
TargetType.ENEMY itself (its own value IS "Enemy") -- stripping the
|
||||
prefix unconditionally would leave an empty type_word and silently
|
||||
drop every ad-hoc "Enemy#N Destroyed" report instead."""
|
||||
text = "Enemy#7 Destroyed, +5 Requisition."
|
||||
info = ocr.parse_text(text)
|
||||
assert (TargetType.ENEMY, "7") in info.destroyed
|
||||
|
||||
|
||||
def test_field_gun_is_an_artillery_alias():
|
||||
"""The game calls plain Artillery "Field Gun" in at least this kill-
|
||||
feed message -- confirmed by the user against a real "Enemy Field
|
||||
Gun#1 Destroyed" line that was otherwise silently dropping (no
|
||||
TargetType.FIELD_GUN exists, nor should one -- see _TYPE_WORD_ALIASES'
|
||||
own comment, same treatment as AmmoCache/CoastalBattery)."""
|
||||
text = "Priority target Enemy Field Gun#1 Destroyed, +50 Requisition."
|
||||
info = ocr.parse_text(text)
|
||||
assert (TargetType.ARTILLERY, "1") in info.destroyed
|
||||
|
||||
|
||||
def test_train_arrival_intel():
|
||||
text = """ARRIVAL STATION:
|
||||
Valle de Mula MainStation: J6 0:4
|
||||
@@ -236,3 +274,142 @@ Tank#3 Spotted. 095, 3.00km from Spotter#1
|
||||
assert (TargetType.TANK, "1") not in info.targets
|
||||
assert (TargetType.TANK, "2") in info.targets
|
||||
assert (TargetType.TANK, "3") in info.targets
|
||||
|
||||
|
||||
def test_infantry_taking_fire_direct_position_request():
|
||||
"""A different fire-support-request grammar from Marine Garrison's:
|
||||
shell word order reversed ('Requesting X Shell' not 'X Shells
|
||||
requested'), deadline is a bare 'before <time>' with no 'Requested'/
|
||||
dashes. The '<b>id1</b>' attacker mention is just prose here, not
|
||||
parsed into anything -- only the request itself (shell, position,
|
||||
deadline) matters.
|
||||
|
||||
The reporting unit ('Infantry#1 taking fire') is always a FRIENDLY
|
||||
calling in its own distress -- no hostile ever radios in about
|
||||
itself -- so it lands in info.allies, not info.targets (a real bug:
|
||||
it used to default to not-ally, no "Friendly"/"Hostile" prefix word
|
||||
exists in this grammar for the usual inference to key off of). The
|
||||
shell/deadline still need a home a plain Ally tuple doesn't have
|
||||
room for, so they move to a synthetic StrikeRequest target at the
|
||||
SAME coord as the reporting unit ('on our position' means the fire
|
||||
point IS that position, no offset given)."""
|
||||
text = ("Infantry#1 taking fire from <b>id1</b>!\n"
|
||||
"Requesting <u><b>SMK Shell</b></u> on our position at <b>J6 2:7</b> "
|
||||
"before <u>10:38:57</u>!")
|
||||
info = ocr.parse_text(text)
|
||||
|
||||
assert (TargetType.INFANTRY, "1") in info.allies
|
||||
assert (TargetType.INFANTRY, "1") not in info.targets
|
||||
raw, clues, coord = info.allies[(TargetType.INFANTRY, "1")]
|
||||
assert coord == Coord("J", 6, 2, 7)
|
||||
|
||||
assert (TargetType.STRIKE_REQUEST, "Infantry1") in info.targets
|
||||
raw, clues, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry1")]
|
||||
assert coord == Coord("J", 6, 2, 7)
|
||||
assert shell is Shell.SMK
|
||||
assert requested_time == "10:38:57"
|
||||
|
||||
|
||||
def test_infantry_taking_fire_no_attacker_mention():
|
||||
text = ("Infantry#3 taking fire!\n"
|
||||
"Requesting <u><b>SMK Shell</b></u> on our position at <b>J6 2:5</b> "
|
||||
"before <u>10:37:52</u>!")
|
||||
info = ocr.parse_text(text)
|
||||
|
||||
assert (TargetType.INFANTRY, "3") in info.allies
|
||||
assert (TargetType.INFANTRY, "3") not in info.targets
|
||||
raw, clues, coord = info.allies[(TargetType.INFANTRY, "3")]
|
||||
assert coord == Coord("J", 6, 2, 5)
|
||||
|
||||
assert (TargetType.STRIKE_REQUEST, "Infantry3") in info.targets
|
||||
raw, clues, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry3")]
|
||||
assert coord == Coord("J", 6, 2, 5)
|
||||
assert shell is Shell.SMK
|
||||
assert requested_time == "10:37:52"
|
||||
|
||||
|
||||
def test_infantry_taking_fire_bearing_distance_from_position():
|
||||
"""The other request shape: the shell isn't wanted right on top of the
|
||||
reporting unit, but at a bearing/distance offset from its own
|
||||
(inline-given) position -- two different places, so this becomes two
|
||||
entries: Infantry#3 stays at its own reported position, as an ALLY
|
||||
(see test_infantry_taking_fire_no_attacker_mention's own docstring --
|
||||
same reasoning, this is still a taking-fire report), and a separate
|
||||
synthetic Strike entry carries the shell/deadline at the computed
|
||||
offset coord ('our position' isn't a named board entity to hang a
|
||||
Clue off of, so this resolves straight to an absolute coord rather
|
||||
than via one)."""
|
||||
text = ("Infantry#3 taking fire!\n"
|
||||
"Requesting <u><b>HE Shell</b></u> at bearing <b>239°</b>, distance "
|
||||
"<b>10.76km</b> from our position, <b>J6 2:5</b>, by <u>10:38:18</u> "
|
||||
"or we will be overrun!")
|
||||
info = ocr.parse_text(text)
|
||||
|
||||
assert (TargetType.INFANTRY, "3") in info.allies
|
||||
assert (TargetType.INFANTRY, "3") not in info.targets
|
||||
raw, clues, coord = info.allies[(TargetType.INFANTRY, "3")]
|
||||
assert coord == Coord("J", 6, 2, 5)
|
||||
|
||||
assert (TargetType.STRIKE_REQUEST, "Infantry3") in info.targets
|
||||
raw, clues, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry3")]
|
||||
assert shell is Shell.HE
|
||||
assert requested_time == "10:38:18"
|
||||
from fenigma import solver
|
||||
expected = solver.point_to_coord(
|
||||
solver.point_from_bearing_distance(Coord("J", 6, 2, 5).as_fraction(), 239.0, 10.76))
|
||||
assert coord == expected
|
||||
|
||||
|
||||
def test_infantry_taking_fire_bearing_distance_short_range():
|
||||
"""Same shape, a sub-1km offset (the earlier fixture's own distance,
|
||||
10.76km, is far enough that a rounding slip in the offset math could
|
||||
have gone unnoticed inside the same large cell -- this one crosses a
|
||||
cell boundary, I7 0:8 -> H7 8:4, so a sign/axis error would visibly
|
||||
land in the wrong cell letter entirely, not just a slightly-off
|
||||
sub-position)."""
|
||||
text = ("Infantry#11 taking fire!\n"
|
||||
"Requesting <u><b>HE Shell</b></u> at bearing <b>210°</b>, distance "
|
||||
"<b>0.43km</b> from our position, <b>I7 0:8</b>, by <u>10:17:37</u> "
|
||||
"or we will be overrun!")
|
||||
info = ocr.parse_text(text)
|
||||
|
||||
assert (TargetType.INFANTRY, "11") in info.allies
|
||||
assert (TargetType.INFANTRY, "11") not in info.targets
|
||||
_, _, coord = info.allies[(TargetType.INFANTRY, "11")]
|
||||
assert coord == Coord("I", 7, 0, 8)
|
||||
|
||||
assert (TargetType.STRIKE_REQUEST, "Infantry11") in info.targets
|
||||
_, _, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry11")]
|
||||
assert coord == Coord("H", 7, 8, 4)
|
||||
assert shell is Shell.HE
|
||||
assert requested_time == "10:17:37"
|
||||
|
||||
|
||||
def test_taking_fire_important_followup_line_does_not_steal_the_deadline():
|
||||
"""A real user-pasted message: a same-report "Important: ... Answer by
|
||||
<time>" follow-up line was being misread as a brand new named entity
|
||||
header (the last-resort bare-"<Name>:" fallback matched "Important:"
|
||||
itself), creating a bogus Target#Important that stole the actual
|
||||
report's own deadline into that wrong entry instead of the real
|
||||
StrikeRequest. "Answer by <time>" is also a deadline phrasing
|
||||
_TAKING_FIRE_TIME_RE already covers (any 'before'/'by <time>') --
|
||||
once the phantom split stops happening, it resolves correctly with
|
||||
no extra fix needed."""
|
||||
text = ("Infantry#4 taking fire!\n"
|
||||
"Requesting <u><b>TEAR Shell</b></u> first, then <u><b>HE Shell</b></u>,\n"
|
||||
"at bearing <b>308°</b>, distance <b>1.86km</b> from our position, <b>N2 0:9</b>\n"
|
||||
"<u>Important:</u> <u><b>TEAR Shell</b></u> first, then <u><b>HE Shell</b></u>.\n"
|
||||
"Answer by <u>10:30:00</u>")
|
||||
info = ocr.parse_text(text)
|
||||
|
||||
assert (TargetType.UNKNOWN, "Important") not in info.targets
|
||||
|
||||
assert (TargetType.INFANTRY, "4") in info.allies
|
||||
assert (TargetType.INFANTRY, "4") not in info.targets
|
||||
|
||||
assert (TargetType.STRIKE_REQUEST, "Infantry4") in info.targets
|
||||
_, _, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry4")]
|
||||
assert requested_time == "10:30:00"
|
||||
# Known gap, not asserted as fixed here: only the FIRST shell of a
|
||||
# "X first, then Y" sequence is captured -- see TODO.md.
|
||||
assert shell is Shell.TEAR
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Regression coverage for solver.py's geometric resolution."""
|
||||
from fenigma import solver
|
||||
from fenigma.models import Board, Clue, Coord, Location, TargetType
|
||||
from fenigma.models import LARGE_X, Board, Clue, Coord, Location, TargetType
|
||||
|
||||
|
||||
def _board_with_spotters(*coords):
|
||||
@@ -104,3 +104,24 @@ def test_manual_coord_override_clears_a_stale_note():
|
||||
assert target.location.note is not None
|
||||
target.coord = Coord("A", 1, 0, 0)
|
||||
assert target.location.note is None
|
||||
|
||||
|
||||
def test_point_to_coord_round_trips_every_sub_cell():
|
||||
"""A real, reproducible bug: Coord.as_fraction() centers a sub-cell
|
||||
at x + 0.5 (so a marker drawn at its own coord's exact pixel
|
||||
position round-trips back to the same coord on click), which put
|
||||
point_to_coord()'s own rounding exactly on a .5 boundary, the worst
|
||||
case for floating point. A tiny representation error from the
|
||||
subtraction it used to do could tip round() to either side,
|
||||
silently returning a coord one sub-cell off from the one actually
|
||||
clicked, this reproduced with zero pixel math involved at all, just
|
||||
feeding as_fraction() straight back into point_to_coord(). Checked
|
||||
exhaustively, not just a couple of samples, since the failure was
|
||||
itself pattern-dependent (only some coords tripped the float
|
||||
rounding the wrong way)."""
|
||||
for X in LARGE_X:
|
||||
for Y in range(1, 11):
|
||||
for x in range(10):
|
||||
for y in range(10):
|
||||
c = Coord(X, Y, x, y)
|
||||
assert solver.point_to_coord(c.as_fraction()) == c
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Evaluate map_vision against the hand annotations, and render overlays.
|
||||
|
||||
Usage:
|
||||
.venv/bin/python tools/eval_map_vision.py # score the fixtures
|
||||
.venv/bin/python tools/eval_map_vision.py shot.png [...] # try your own images
|
||||
|
||||
Two scores are reported, because the lenient one hid a real error:
|
||||
|
||||
cell fraction of annotated points landing in the correct cell. Too
|
||||
forgiving on its own: the annotations sit near cell centres, so a
|
||||
grid wrong by a whole line still passes.
|
||||
spread every annotated point should sit at (col + a, row + b) in recovered
|
||||
grid coordinates for ONE constant (a, b) -- the label's offset
|
||||
inside its cell. So fit that constant and report the spread of the
|
||||
residual, in cell units. A skewed or off-by-one-line grid shows up
|
||||
here even when every point is nominally in the right cell.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
from fenigma import map_vision as mv # noqa: E402
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SHOTS = ROOT / "tests" / "fixtures" / "map_shots"
|
||||
GT_PATH = ROOT / "tests" / "fixtures" / "map_shots_gt.json"
|
||||
|
||||
|
||||
def draw(img, sol, markers):
|
||||
vis = img.copy()
|
||||
h, w = vis.shape[:2]
|
||||
q = np.linalg.inv(sol.H) @ np.array([[0, w, w, 0], [0, 0, h, h], [1, 1, 1, 1]], np.float64)
|
||||
ij = q[:2] / q[2]
|
||||
for i in range(int(np.floor(ij[0].min())) - 1, int(np.ceil(ij[0].max())) + 2):
|
||||
for j in range(int(np.floor(ij[1].min())) - 1, int(np.ceil(ij[1].max())) + 2):
|
||||
col, row = sol.si * i + sol.du, sol.sj * j + sol.dv
|
||||
if not (0 <= col < mv.COLS and 1 <= row <= mv.ROWS):
|
||||
continue
|
||||
p = sol.H @ np.array([[i, i + 1, i + 1, i], [j, j, j + 1, j + 1], [1, 1, 1, 1.0]])
|
||||
if np.any(np.abs(p[2]) < 1e-9):
|
||||
continue
|
||||
p = (p[:2] / p[2]).T
|
||||
cv2.polylines(vis, [p.astype(np.int32)], True, (0, 255, 255), 2, cv2.LINE_AA)
|
||||
lx = i + (mv.PAD_L if sol.si > 0 else 1 - mv.PAD_L)
|
||||
ly = j + (mv.PAD_T if sol.sj > 0 else 1 - mv.PAD_T)
|
||||
t = sol.H @ np.array([lx, ly, 1.0])
|
||||
if abs(t[2]) < 1e-9:
|
||||
continue
|
||||
x, y = int(t[0] / t[2]), int(t[1] / t[2])
|
||||
f = max(0.45, min(2.0, float(np.linalg.norm(p[1] - p[0])) / 190.0))
|
||||
txt = f"{mv.LARGE_X[col]}{row}"
|
||||
cv2.putText(vis, txt, (x, y), cv2.FONT_HERSHEY_SIMPLEX, f, (0, 0, 0), int(f * 5) + 2)
|
||||
cv2.putText(vis, txt, (x, y), cv2.FONT_HERSHEY_SIMPLEX, f, (0, 255, 0), int(f * 2) + 1)
|
||||
for m in markers:
|
||||
x, y, bw, bh = m["box"]
|
||||
c = (0, 0, 255) if m["side"] == "hostile" else (255, 210, 0)
|
||||
cv2.rectangle(vis, (x, y), (x + bw, y + bh), c, 2)
|
||||
txt = m["coord"] + (f' {m["unit"]}' if m.get("unit") else "")
|
||||
cv2.putText(vis, txt, (x, y - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 0, 0), 4)
|
||||
cv2.putText(vis, txt, (x, y - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.55, c, 2)
|
||||
return vis
|
||||
|
||||
|
||||
def run_one(path, outdir):
|
||||
"""Solve a single arbitrary screenshot (no ground truth needed)."""
|
||||
sol, img, err = mv.solve_path(path)
|
||||
if sol is None:
|
||||
print(f"{path.name}: REJECTED - {err}")
|
||||
return 1
|
||||
markers = mv.find_markers(img, sol)
|
||||
print(f"{path.name}: solved, {sol.votes} label votes, "
|
||||
f"cell {sol.steps[0]:.0f}x{sol.steps[1]:.0f}px, {len(markers)} markers")
|
||||
for m in markers:
|
||||
print(f" {m['side']:<8} {m['coord']:<9} {m.get('unit') or 'unknown type'}")
|
||||
out = outdir / f"solved_{path.stem}.png"
|
||||
cv2.imwrite(str(out), draw(img, sol, markers))
|
||||
print(f" overlay: {out}")
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
args = [a for a in sys.argv[1:]]
|
||||
outdir = ROOT / "build" / "map_vision"
|
||||
images = [Path(a) for a in args if Path(a).suffix.lower() in (".png", ".jpg", ".jpeg")]
|
||||
if images:
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
rc = 0
|
||||
for p in images:
|
||||
rc |= run_one(p, outdir)
|
||||
return rc
|
||||
outdir = Path(args[0]) if args else outdir
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
gt = json.loads(GT_PATH.read_text())
|
||||
names = sorted(k for k in gt if k.endswith(".png"))
|
||||
tiles, ok_t, n_t = [], 0, 0
|
||||
print(f"{'shot':<8} {'pts':>4} {'cell':>6} {'spread':>7} {'votes':>5} "
|
||||
f"{'mk':>3} status")
|
||||
for name in names:
|
||||
path = SHOTS / name
|
||||
native_w = cv2.imread(str(path), cv2.IMREAD_REDUCED_COLOR_8).shape[1] * 8
|
||||
pts = gt[name]
|
||||
n_t += len(pts)
|
||||
sol, img, err = mv.solve_path(path)
|
||||
s = img.shape[1] / native_w
|
||||
if sol is None:
|
||||
print(f"{name:<8} {len(pts):>4} {'-':>6} {'-':>7} {'-':>5} {'-':>3} REJECT: {err}")
|
||||
vis = img.copy()
|
||||
cv2.putText(vis, f"{name} REJECTED", (12, 34), cv2.FONT_HERSHEY_SIMPLEX,
|
||||
1.0, (0, 0, 255), 3)
|
||||
cv2.putText(vis, err[:56], (12, 66), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)
|
||||
tiles.append(vis)
|
||||
cv2.imwrite(str(outdir / f"rejected_{name}"), vis)
|
||||
continue
|
||||
offs, right = [], 0
|
||||
for lab, x, y in pts:
|
||||
got = sol.cell_of(x * s, y * s)
|
||||
if got and got[0] == lab:
|
||||
right += 1
|
||||
g = sol.grid_of(x * s, y * s)
|
||||
if g is None:
|
||||
continue
|
||||
offs.append((g[0] - mv.LARGE_X.index(lab[0]), g[1] - int(lab[1:])))
|
||||
ok_t += right
|
||||
o = np.array(offs)
|
||||
spread = float(np.sqrt(((o - o.mean(axis=0)) ** 2).sum(axis=1).mean()))
|
||||
markers = mv.find_markers(img, sol)
|
||||
verdict = "GOOD" if spread < 0.08 else ("SKEWED" if spread < 0.3 else "BAD")
|
||||
print(f"{name:<8} {len(pts):>4} {right/len(pts):>5.0%} {spread:>7.3f} "
|
||||
f"{sol.votes:>5} {len(markers):>3} {verdict}")
|
||||
vis = draw(img, sol, markers)
|
||||
cv2.putText(vis, f"{name} {len(markers)} markers spread {spread:.3f}",
|
||||
(12, 34), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 255, 0), 3)
|
||||
tiles.append(vis)
|
||||
cv2.imwrite(str(outdir / f"solved_{name}"), vis)
|
||||
for m in markers:
|
||||
print(f" {m['side']:<8} {m['coord']:<9} "
|
||||
f"{str(m.get('unit')):<26} s={m['unit_score']:.2f} d={m['unit_margin']:.3f}")
|
||||
print(f"\nTOTAL {ok_t}/{n_t} annotated points in the correct cell "
|
||||
f"({ok_t / max(n_t, 1):.0%})")
|
||||
TW, TH, cols = 860, 520, 3
|
||||
rows = (len(tiles) + cols - 1) // cols
|
||||
sheet = np.zeros((TH * rows, TW * cols, 3), np.uint8)
|
||||
for i, t in enumerate(tiles):
|
||||
hh, ww = t.shape[:2]
|
||||
sc = min(TW / ww, TH / hh)
|
||||
t2 = cv2.resize(t, (int(ww * sc), int(hh * sc)))
|
||||
y, x = (i // cols) * TH, (i % cols) * TW
|
||||
sheet[y:y + t2.shape[0], x:x + t2.shape[1]] = t2
|
||||
cv2.imwrite(str(outdir / "sheet.png"), sheet)
|
||||
print(f"wrote {outdir / 'sheet.png'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main() or 0)
|
||||