Compare commits

...
21 Commits
Author SHA1 Message Date
dodoxandClaude Sonnet 5 bc74d62774 Confirm the Windows build pipeline works end to end: real .msi produced
The -arch x64 fix (previous commit) is confirmed live: build_errorlevel=0,
BUILD_DONE, 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 (gitignored).

Not yet installed/launched on a real Windows machine to confirm the
app actually runs -- packaging succeeding isn't the same claim as the
app working once installed, per this repo's own README note. Logged
as the next thing to check, along with light.exe's own ~15-18min
runtime now that there's a clean build to measure it against.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 23:21:55 +02:00
dodoxandClaude Sonnet 5 d66b881239 Correct the ICE80 fix: -arch x64 on candle.exe, not heat.exe -platform
The previous fix (heat.exe -platform x64) was wrong -- re-verified
live with the corrected build.bat confirmed actually deployed to the
VM (ruling out a stale-copy repeat of the earlier watcher bug),
identical ICE80 "32BitComponent uses 64BitDirectory" failures on every
single harvested component. WiX v3's heat.exe -platform flag 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), the standard WiX v3 way to make a whole package
consistently 64-bit. Kept the harmless-but-insufficient heat.exe flag
too.

Not yet re-verified live -- next run should confirm a real .msi.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 23:00:45 +02:00
dodoxandClaude Sonnet 5 d3246328e8 Fix WiX ICE80 mismatch: heat.exe never marked components as 64-bit
light.exe's ICE80 validation rejected essentially every harvested file
("This 32BitComponent ... uses 64BitDirectory") on the first build that
got far enough to reach it -- heat.exe's harvest command had no
-platform x64, so every component defaulted to 32-bit, while
product.wxs's own INSTALLFOLDER is correctly under
ProgramFiles64Folder (a 64-bit mingw64 toolchain is what's actually
being packaged). Real, on-disk mismatch, not a transient VM issue.

Also confirmed the earlier light.exe "timeout" wasn't a real bug either
-- a longer-budget retry finished linking fine in a few more minutes,
it was just slow, not hung.

Not yet re-verified against a live build (found right as this
session's VM time was already heavily spent) -- logged in TODO.md as
the next thing to confirm.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 22:24:14 +02:00
dodoxandClaude Sonnet 5 22fbb33923 TODO.md: clarify multi-shell request gap isn't silent data loss
The raw request text (both shells, in order) is already preserved and
shown to the player via the coord dialog's description view
(Location.desc_raw) -- not machine-parsed into a second structured
shell field, but not actually lost either. Lowers the urgency/changes
the framing before deciding whether to build real sequence support.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 22:11:56 +02:00
dodoxandClaude Sonnet 5 6e34f50b6a Fix phantom "Important:" header stealing a taking-fire report's deadline
Turned out to be one bug, not two: "Answer by <time>" phrasing was
already covered by _TAKING_FIRE_TIME_RE ('before|by <time>'). The real
bug was the last-resort bare-"<Name>:" header fallback matching a
same-message "Important:" follow-up line as a brand new named entity
(nothing excluded common prose lead-ins), creating a bogus
Target#Important that stole the deadline into its own requested_time
instead of the real report's. Fixed with a blocklist on that fallback
rule (important/note/warning/attention/caution/alert/reminder/priority).
New regression test, 55 total passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 22:09:15 +02:00
dodoxandClaude Sonnet 5 6a61bffb22 Fix taking-fire ally misclassification; Windows build fixes; Field Gun
OCR (src/fenigma/ocr.py):
- A "taking fire" report's reporting unit was added as a hostile Target,
  not a friendly Ally -- no "Friendly"/"Hostile" prefix word exists in
  that grammar for the usual inference to key off of, so it silently
  defaulted to not-ally. Fixed with an explicit force_ally override
  (_TAKING_FIRE_RE), and the shell/deadline (which an Ally tuple has no
  fields for) now always splits into a synthetic StrikeRequest target at
  the reporting position, even for the no-offset "on our position" case
  that previously kept them on the entity itself.
- "Enemy <Type>#<id> Destroyed" kill-feed lines were silently dropping
  for shorter/less distinctive type words (e.g. "Enemy Field Gun#1") --
  _ALLY_PREFIX_RE only ever stripped "Friendly"/"Hostile", never
  "Enemy", so the whole "EnemyFieldGun" token got alias/fuzzy-matched
  against "Artillery" and missed by a mile. Longer type words
  ("Enemy Mechanized Infantry#2") only ever worked by fuzzy-match
  accident. Now strips "Enemy" too (lookahead guards a BARE "Enemy#N"
  report, which IS TargetType.ENEMY itself, from being stripped to an
  empty, unresolvable string).
- "Field Gun" added to _TYPE_WORD_ALIASES as plain Artillery under
  another name (confirmed by the user), not a missing unit type.
7 new/updated regression tests, 54 total passing.

Windows build (packaging/windows/): three real bugs found and fixed by
actually booting and driving the build VM live (VNC), not just guessing
from the README's "UNTESTED end to end" note:
- install.bat's MSYS2/WiX provisioning previously left NOTHING behind
  once C:\OEM stopped existing (a 2-day-old BUILD_REQUEST sat unclaimed
  the whole time) -- the build.bat/watch_build.bat persistence fix
  (C:\FenigmaBuild instead of C:\OEM) is real and now confirmed live:
  after a full container restart, the watcher auto-starts on login and
  picks up a pending request with zero manual intervention.
- pip install pytesseract needs --break-system-packages (MSYS2's
  mingw64 Python enforces PEP 668).
- mingw-w64-x86_64-opencv is the C++ library only; the actual Python
  bindings are the separate mingw-w64-x86_64-python-opencv package,
  never in install.bat's dependency list.

With all three, import fenigma.app succeeds and a real build attempt
gets through source copy, sanity check, dist-tree assembly, and WiX
harvest+compile -- further than this pipeline has ever gotten. Full
findings, including the still-open light.exe timeout and the OCR
multi-shell/deadline-phrasing/phantom-header gaps found along the way,
logged in TODO.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 22:02:17 +02:00
dodoxandClaude Sonnet 5 5a35ea7776 Revert auto-assigned ids back to letters; only detected ids are numeric
Auto-assignment (no real id known: a manual add, or an accepted
proposal with no confident marker-id read) must stay visually
distinct from a genuinely detected id, or a made-up number could
collide with or be mistaken for a real one. Reverts the previous
commit's switch to numeric auto-assignment (_next_free_numeric_id) --
that was wrong, caught by the user immediately. _next_free_id
(letters, rolling over to "AA"/"AB"/... past 26) is back as the
fallback, still scoped per type (that part of the previous change was
correct and stays). Plain numbers are reserved for an id
_accept_proposal is actually confident was read off the marker itself
(Proposal.detected_id), passed straight through and never touching
auto-assignment.

Also fixes detected_id's own collision pre-check in _accept_proposal,
which wasn't scoped per type either -- same bug as the Change ID
popover fix, just in a second place: a detected id could get
needlessly discarded because an unrelated type already used that
number, not because of a real collision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 19:49:30 +02:00
dodoxandClaude Sonnet 5 6e18d60eb5 Fix Change ID dialog still enforcing the old shared-per-group id rule
The manual "Change ID" popover's collision check was never updated
when Board.add_target/add_ally's auto-id scheme changed to per-type
(commit d2f7067): it still rejected a rename if ANY target/ally in the
whole group had that id, regardless of type, so e.g. renaming an
Infantry to id "4" failed with "Another target already has id '4'"
just because an unrelated Mechanized already used it. Now scopes the
collision check to siblings of the SAME type, matching add_target/
add_ally exactly. Verified directly against the same expression run
on real Board/Target objects (a full GTK popover popup cycle needs a
real window surface, not available headlessly).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 19:42:52 +02:00
dodoxandClaude Sonnet 5 218909b6cf Drop redundant coord from pending-proposal map label
The coord was already shown right below via _draw_marker's own
coord=coord line -- repeating it in the main label too was pure
noise. Shows detected type/id instead when known ("? Mechanized#3",
same shape Target.name/Ally.name use, via TargetType.short so the
preview reads the same as what accepting it produces), falling back
to a bare "?" when neither is known.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 19:32:38 +02:00
dodoxandClaude Sonnet 5 d2f70675b8 Auto-ids per type not per group; show detected_id in proposal UI
Auto-assignment (Board.add_target/add_ally with no explicit id_) now
scopes its 1/2/3... sequence per TYPE within each group, not one
sequence shared across every type in the group -- Tank#1/Infantry#1
rather than Tank#A/Infantry#B, matching the game's own numbering.
Reverses the type-scoping half of an earlier fix in this file (see
TODO.md's "Allies and enemies seem to share indices" entry) per
explicit user direction; the targets-vs-allies namespace split that
fix also made is untouched, still correct. _next_free_id (letters,
rolling over to "AA" past 26) is replaced by _next_free_numeric_id --
a plain counter can't run out the way a fixed alphabet could, so
there's no equivalent rollover concern. test_models.py updated to
match (one test asserts the opposite of before, renamed accordingly).

detected_id (map_vision.read_marker_id) was being logged but never
shown anywhere a human could actually check it against the
screenshot before now: added to the proposal popover's heading and
the pending-proposal's own on-map label.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 19:28:01 +02:00
dodoxandClaude Sonnet 5 8109db2f39 Accept a proposal using its detected marker id, not always a letter
detected_id (map_vision.read_marker_id) was wired into ground-truth
logging but never actually consumed when accepting a proposal --
every accepted target/ally silently got an auto-assigned A/B/C letter
regardless of what number the game itself shows for that unit.
_accept_proposal now prefers detected_id when present, falling back to
auto-assign on a collision (two markers misread to the same id, or a
real id that happens to match one already assigned) -- a duplicate id
is worse than losing traceability to the game's own number for that
one accept. Targets/allies keep their own separate id namespace, same
as auto-assignment already does elsewhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 19:19:43 +02:00
dodoxandClaude Sonnet 5 086b871e3a Add marker id detection; log unit_score/margin; use full-res captures
read_marker_id (map_vision.py) reads each marker's own small "#<N>" id
label via template correlation, same approach as read_cell_label and
for the same documented reason (this text sits over the same aerial-
photo backdrop that defeated detection-based approaches for grid
labels). Wired end-to-end: find_markers -> Proposal.detected_id ->
save_marker_ground_truth's JSON. Reads against ScreenshotImport's
full_image when available, since the id text is tiny. Crop region and
threshold are a single-screenshot calibration, not yet validated
against real ground truth (documented as such).

Also switches save_marker_ground_truth/save_grid_correction to use
full_image over the WORK_W-downscaled image, so a human reviewing a
capture can actually read the small id text well enough to judge it.

Logs unit_score/unit_margin on every Proposal too (previously only
pass/fail `unit` was recorded), and measured current type-detection
reliability against the 6 existing ground-truth captures: 0/72 (0%)
accepted proposals had any confident detected_unit at all, not just
wrong guesses -- classify_marker never clears its own confidence floor
against real screenshots. Findings and next steps in TODO.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 18:54:53 +02:00
dodoxandClaude Sonnet 5 896c7dc36a Fix silent proposal-popover repaint bug; add underground target marker
"Accept as..." (proposal type-picker) and "Change type" (entity-edit)
opened to a visibly empty/unchanged popover with no traceback: swapping
an already-open Popover's child and re-popup()ing it reported the right
size internally but the compositor never repainted the reused surface.
Confirmed live via temporary debug instrumentation, not guessed. Fixed
by popping the old popover down and opening a genuinely new one at the
same anchor point instead of resizing in place.

Also adds a Target.underground_tier (1-3) marker: a "Mark underground"
entry in the entity-edit popover, rendered as the game's own Armor-tier
additive badge stacked directly on the unit icon. The badge is
scaled/positioned off its real opaque content (PIL bbox), not its PNG
canvas, since the additive art carries a lot of off-center transparent
padding; and overlaps down into the icon by a fixed pixel amount, since
both shapes taper to a point at the seam and exact bbox-touching still
read as a visible gap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 18:44:36 +02:00
dodoxandClaude Sonnet 5 e43c3478c9 packaging/windows: fix build watcher never firing, correct earlier
BTRFS misdiagnosis in README

The build watcher was registered as a SYSTEM-context Scheduled Task
(schtasks /ru SYSTEM). Confirmed on a real run this never actually
works: Z:\ (the /shared mount) is mapped per interactive session, a
SYSTEM task has no session of its own and can't see it, so
watch_build.bat spun forever on its own "if not exist Z:\" wait -- a
build request sat unclaimed for hours with zero indication anything
was wrong. Fixed with an All-Users Startup-folder entry instead, which
runs in whichever user's session actually logs in.

Also correcting the README's earlier "BTRFS boot-loop" entry: that was
a misdiagnosis from reading the text log alone (repeated
"loading/starting Boot0004" lines). Actually looking at the noVNC
screen showed genuine, progressing Windows Setup the whole time --
Setup legitimately reboots the VM multiple times, each one re-prints
those same firmware lines. The chattr +C fix stays (real, independently
documented dockur/QEMU/BTRFS caveat) but likely wasn't fixing an actual
problem that time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 11:57:28 +02:00
dodox 7a405ad263 Merge pull request 'packaging/windows: fix BTRFS+QEMU boot-loop, document what's confirmed' (#3) from fix/id-space-strike-request-perf-2026-08-11 into master
Reviewed-on: #3
2026-08-11 21:17:18 +02:00
dodoxandClaude Sonnet 5 2282a7d521 packaging/windows: fix BTRFS+QEMU boot-loop, document what's confirmed
Real issue hit on a real run: dockur/windows warns about BTRFS storage
but it's not idle -- on this host it boot-looped Windows Setup for
hours (same log lines repeating forever, disk barely growing), a known
bad combination for QEMU disk images on a copy-on-write filesystem.
build_windows.sh now disables COW on storage/ itself (chattr +C,
harmless no-op on non-btrfs or an already-populated dir from a prior
run). README updated to reflect both real fixes now confirmed needed
on this host (this one, plus the earlier SELinux :Z mount fix) instead
of the original "written but never run" status.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 21:16:24 +02:00
dodox bd69b183ad Merge pull request 'fix/id-space-strike-request-perf-2026-08-11' (#2) from fix/id-space-strike-request-perf-2026-08-11 into master
Reviewed-on: #2
2026-08-11 21:12:57 +02:00
dodoxandClaude Sonnet 5 4556da37ff README: drop the "Known issues" section
Map screenshot reading (grid + unit detection) is working noticeably
better now; not sure exactly which of the recent fixes did it, but the
blanket "unreliable/fails often" caveat is no longer accurate enough
to keep. TODO.md still has the fuller fixed/open list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 20:49:19 +02:00
dodoxandClaude Sonnet 5 23615a8c92 Fix id-namespace regression, add StrikeRequest type, kill full-panel
rerender on assign/alive/shell, add Windows build tooling

- Board.add_target/add_ally's id auto-assignment used a bare
  next(c for c in string.ascii_uppercase if c not in used), which
  raises StopIteration once 26 entities of a group exist -- a real
  crash confirmed via a live traceback, and a direct regression from
  moving that sequence from per-type to per-group. This was the actual
  cause of "Accept as"/"Accept all" silently doing nothing. Fixed with
  _next_free_id(), which rolls over to two-letter ids instead of
  raising.
- New TargetType.STRIKE_REQUEST: the bearing/distance-offset "taking
  fire" fire-support request (see the earlier two-entity split) now
  creates this instead of reusing STRIKE, so a radioed-in request is
  never confused with a strike the player placed themselves. Same
  crosshair icon, excluded from type pickers/dedupe like STRIKE.
- The "Accept as..." popover on a detected map marker now uses the
  same icon grid the entity-edit "Change type" popover does (was a
  plain unfiltered text list of every TargetType, which also wrongly
  offered STRIKE/STRIKE_REQUEST as pickable).
- Firing panel: _cycle_assignment/_toggle_alive/_pick_shell no longer
  route through app.py's full solver+dedupe+canvas+panel refresh --
  none of the three can affect the solver or dedupe, and none change
  which cards exist or their order (except _toggle_alive in
  hide/sort_later mode). New FiringPanel._rebuild_one() rebuilds just
  the one changed card; on_visual_change is a new, lighter callback
  (just a map redraw) for the two of these three that actually affect
  it. This was a real, confirmed lag source with many units on the
  board: every click on any of these was previously rebuilding every
  card of every target.
- Map right-click entity menu: added "Mark destroyed"/"Mark alive",
  reusing the same cheap-refresh path (new
  FiringPanel.refresh_after_alive_change).
- packaging/windows/: a from-scratch (untested against a real boot)
  MSYS2 + WiX .msi build pipeline for Windows, driven from Linux via
  dockur/windows (KVM-in-container), no Windows machine or GitHub
  required. See its own README for status/caveats.
- New/updated tests: id-namespace sharing + the 26-entity overflow
  regression (tests/test_models.py), StrikeRequest split
  (tests/test_ocr.py), warp_to_map's img_scale param
  (tests/test_map_vision_warp.py). 44/44 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 20:48:57 +02:00
dodox 49863b6045 Merge pull request 'Fix ally/target bugs, OCR fire-support parsing, add debug capture' (#1) from fix/bug-backlog-2026-08-11 into master
Reviewed-on: #1
2026-08-11 17:36:21 +02:00
dodoxandClaude Sonnet 5 1ddb532325 Fix ally/target bugs, OCR fire-support parsing, add debug capture
- Board.clear() now also drops allies; the "clear board?" guard checks
  allies too. New Board.clear_units() + Clear button right-click menu
  ("clear enemies, units & flights", keeps Nest/spotters/RPs).
- An Ally with the ad-hoc TargetType.ENEMY showed "Enemy" on the map
  popover/toast instead of "Ally" (icons.target_type_label already had
  the fix for the picker, now reused everywhere else via app.py's
  _display_name).
- Firing panel drag-reorder no longer triggers a full app refresh
  (solver + dedupe + map redraw) on every drop, just a local rebuild.
- "Always show geo" didn't draw for Allies (missing from the overlay
  candidate list); blast radius only respected selection, not the
  show_geo_desc pin.
- ocr.py: added a second fire-support-request grammar ("Infantry#N
  taking fire ... Requesting X Shell on our position at <coord> before
  <time>", plus a bearing/distance-from-position variant), distinct
  from the existing Marine Garrison one.
- New debug_capture.py: saves screenshots (+ metadata) the app handled
  badly, for later tuning of map_vision/ocr against real failures:
  map-read errors, user grid corrections (paired with the auto-detected
  grid), screenshots that read as text but may have been a map, and
  marker-detection ground truth (every proposal's accept/reject verdict
  plus units added with no matching proposal) captured whenever a
  screenshot stops being the active one.
- README: Known issues section (map screenshot reading, grid + unit
  detection, is unreliable and fails often).
- 14 new tests (tests/test_models.py, tests/test_debug_capture.py, +
  additions to tests/test_ocr.py), 38/38 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 17:35:37 +02:00
33 changed files with 2875 additions and 112 deletions
+5
View File
@@ -5,3 +5,8 @@ captures/*.png
GameAssets GameAssets
# tools/eval_map_vision.py renders its overlays here # tools/eval_map_vision.py renders its overlays here
build/ build/
# packaging/windows/build_windows.sh's VM disk/scratch and build output
packaging/windows/storage/
packaging/windows/shared/
dist-windows/
+420
View File
@@ -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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+96
View File
@@ -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.
+91
View File
@@ -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")"
+37
View File
@@ -0,0 +1,37 @@
# Boots a real Windows VM inside a container via dockur/windows (QEMU+KVM
# under the hood, no Windows license/key needed for the eval install it
# fetches automatically). Persistent disk lives in ./storage, so the
# one-time provisioning in oem/install.bat only ever runs once -- every
# later `docker compose up` just boots the already-provisioned VM.
#
# Requires /dev/kvm on the host (check with: ls -la /dev/kvm, and that
# your user is in the `kvm` group).
#
# Volumes use the :Z suffix (SELinux relabeling for a container-private
# label) -- confirmed needed on this host (Fedora, SELinux enforcing):
# without it dockur/windows refuses to start with "Storage folder
# (/storage) is not writeable!" even though normal Unix permissions are
# fine. Harmless no-op on a host without SELinux.
services:
windows:
image: dockurr/windows
container_name: fenigma-windows-builder
environment:
VERSION: "11" # Windows 11 Pro, fetched+installed unattended on first boot
RAM_SIZE: "8G"
CPU_CORES: "4"
DISK_SIZE: "80G" # MSYS2 + GTK4/libadwaita + WiX + build tree eats more than the 64G default
devices:
- /dev/kvm
- /dev/net/tun
cap_add:
- NET_ADMIN
ports:
- "8006:8006" # noVNC web viewer, http://localhost:8006 -- watch the first install here
- "3389:3389/tcp" # RDP, if you'd rather use an RDP client
volumes:
- ./storage:/storage:Z # persistent VM disk
- ./oem:/oem:Z # one-time provisioning payload, copied to C:\OEM on first install
- ./shared:/shared:Z # live exchange folder, appears as Z:\ in Windows
stop_grace_period: 2m
restart: unless-stopped
+100
View File
@@ -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
+118
View File
@@ -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
+68
View File
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Hand-authored shell: directory layout, the Start Menu shortcut, and the
PYTHONPATH environment variable the shortcut relies on (see run.sh's
equivalent `PYTHONPATH=src python -m fenigma.app`). The actual app/
runtime files are a separate auto-harvested fragment (files.wxs, built
by heat.exe in build.bat) referenced here only by its ComponentGroup id.
UpgradeCode below is a fixed, generated-once GUID: DO NOT regenerate
it, that's what lets a newer .msi upgrade an older install in place
instead of installing side by side. ProductCode is left as "*" (auto-
generated per build), which is the normal WiX pattern.
UNTESTED (see build.bat's top-of-file note).
-->
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Product Id="*"
Name="FEnigma"
Language="1033"
Version="$(var.AppVersion)"
Manufacturer="FEnigma"
UpgradeCode="DAB672A3-9E27-4F3F-8251-0AACD6E57B94">
<Package InstallerVersion="500" Compressed="yes" InstallScope="perMachine" />
<MajorUpgrade DowngradeErrorMessage="A newer version of FEnigma is already installed." />
<MediaTemplate EmbedCab="yes" />
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="ProgramFiles64Folder">
<Directory Id="INSTALLFOLDER" Name="FEnigma" />
</Directory>
<Directory Id="ProgramMenuFolder">
<Directory Id="ApplicationProgramsFolder" Name="FEnigma" />
</Directory>
</Directory>
<!-- AppFiles (all of dist\mingw64 + dist\src, harvested by heat.exe
into files.wxs) is referenced by id only: its actual file list
lives in that generated fragment, not here. -->
<Feature Id="MainFeature" Title="FEnigma" Level="1">
<ComponentGroupRef Id="AppFiles" />
<ComponentRef Id="ApplicationShortcutComponent" />
</Feature>
<DirectoryRef Id="ApplicationProgramsFolder">
<Component Id="ApplicationShortcutComponent" Guid="*">
<Shortcut Id="ApplicationStartMenuShortcut"
Name="FEnigma"
Description="Screen-reading helper for IRON NEST: Heavy Turret Simulator"
Target="[INSTALLFOLDER]mingw64\bin\pythonw.exe"
Arguments="-m fenigma.app"
WorkingDirectory="INSTALLFOLDER" />
<RemoveFolder Id="CleanUpShortcut" On="uninstall" />
<!-- Machine-wide PYTHONPATH so the bundled mingw64\bin\pythonw.exe
(which knows nothing about this app on its own) can find the
fenigma package: same role run.sh's env var plays on Linux.
Permanent="no": removed again on uninstall. -->
<Environment Id="PythonPathEnv" Name="PYTHONPATH" Value="[INSTALLFOLDER]src"
Permanent="no" Action="set" System="yes" Part="last" />
<RegistryValue Root="HKCU" Key="Software\FEnigma" Name="installed" Type="integer" Value="1" KeyPath="yes" />
</Component>
</DirectoryRef>
<!-- WixUI_Minimal: no EULA screen, so no WixUILicenseRtf override needed. -->
<UIRef Id="WixUI_Minimal" />
</Product>
</Wix>
+34
View File
@@ -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
+334 -20
View File
@@ -14,6 +14,8 @@ from __future__ import annotations
import io import io
import json import json
import pickle
import signal
import tempfile import tempfile
from pathlib import Path from pathlib import Path
@@ -26,7 +28,7 @@ gi.require_version("Gdk", "4.0")
from gi.repository import Adw, Gdk, Gio, GLib, Gtk # noqa: E402 from gi.repository import Adw, Gdk, Gio, GLib, Gtk # noqa: E402
from PIL import Image # noqa: E402 from PIL import Image # noqa: E402
from . import ballistics, icons, map_import, ocr, solver # noqa: E402 from . import ballistics, debug_capture, icons, map_import, ocr, solver # noqa: E402
from .coord_dialog import CoordDialog # noqa: E402 from .coord_dialog import CoordDialog # noqa: E402
from .firing_panel import FiringPanel # noqa: E402 from .firing_panel import FiringPanel # noqa: E402
from .grid_fix_dialog import GridFixDialog # noqa: E402 from .grid_fix_dialog import GridFixDialog # noqa: E402
@@ -64,6 +66,17 @@ def _idle(fn, *args):
GLib.idle_add(lambda: (fn(*args), False)[1]) GLib.idle_add(lambda: (fn(*args), False)[1])
def _display_name(obj) -> str:
"""obj.name, but with TargetType.ENEMY's raw "Enemy" value swapped for
"Ally" when obj is an Ally (see icons.target_type_label) -- the
underlying id (obj.name, used for save files and clue references)
keeps "Enemy" either way, only this display form differs."""
if not hasattr(obj, "type"):
return obj.name
label = icons.target_type_label(obj.type, isinstance(obj, Ally)).replace(" ", "")
return f"{label}#{obj.id}"
def _coord_from_proposal(p) -> Coord | None: def _coord_from_proposal(p) -> Coord | None:
"""map_vision reports "K8" plus sub-cell 0..9 in each axis, matching """map_vision reports "K8" plus sub-cell 0..9 in each axis, matching
Coord's own convention (see GridSolution.lattice_to_grid).""" Coord's own convention (see GridSolution.lattice_to_grid)."""
@@ -261,6 +274,15 @@ class MainWindow(Adw.ApplicationWindow):
self._import_job = None # in-flight map_import.ImportJob, if any self._import_job = None # in-flight map_import.ImportJob, if any
self.screenshot_import = None # the map screenshot currently on the board self.screenshot_import = None # the map screenshot currently on the board
# Dev-only: SIGUSR1 pickles {board, screenshot_import} to a fixed
# path so a `kill -USR1` + relaunch (e.g. while bisecting a live
# bug) can restore the in-progress board/screenshot/proposals
# instead of losing them. One-shot: the restore consumes and
# deletes the file. Not wired to any UI -- debugging aid only.
GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGUSR1,
self._dev_dump_session_for_restart)
self._dev_maybe_restore_session()
self.toast_overlay = Adw.ToastOverlay() self.toast_overlay = Adw.ToastOverlay()
self.set_content(self.toast_overlay) self.set_content(self.toast_overlay)
@@ -282,8 +304,13 @@ class MainWindow(Adw.ApplicationWindow):
header.pack_start(load_btn) header.pack_start(load_btn)
clear_btn = Gtk.Button(icon_name="edit-clear-all-symbolic") clear_btn = Gtk.Button(icon_name="edit-clear-all-symbolic")
clear_btn.set_tooltip_text("Clear board (drop everything)") clear_btn.set_tooltip_text(
"Clear board (drop everything). Right-click for a lighter option.")
clear_btn.connect("clicked", lambda _b: self._clear_board()) clear_btn.connect("clicked", lambda _b: self._clear_board())
clear_right_click = Gtk.GestureClick(button=Gdk.BUTTON_SECONDARY)
clear_right_click.connect(
"released", lambda _g, _n, x, y: self._open_clear_menu(clear_btn, x, y))
clear_btn.add_controller(clear_right_click)
header.pack_start(clear_btn) header.pack_start(clear_btn)
self._clip_btn = Gtk.Button(icon_name="edit-paste-symbolic") self._clip_btn = Gtk.Button(icon_name="edit-paste-symbolic")
@@ -367,6 +394,7 @@ class MainWindow(Adw.ApplicationWindow):
self.firing_panel = FiringPanel( self.firing_panel = FiringPanel(
self.board, self.board,
on_change=self._refresh, on_change=self._refresh,
on_visual_change=self.canvas.refresh,
on_select=self._set_selection, on_select=self._set_selection,
on_edit_position=self._edit_target_position, on_edit_position=self._edit_target_position,
on_set_position=self._start_target_placement, on_set_position=self._start_target_placement,
@@ -529,7 +557,17 @@ class MainWindow(Adw.ApplicationWindow):
except Exception as exc: # OCR/parsing hiccups shouldn't crash the app except Exception as exc: # OCR/parsing hiccups shouldn't crash the app
self.toast(f"OCR failed: {exc}") self.toast(f"OCR failed: {exc}")
return return
on_parsed(info) result = on_parsed(info)
# Empty result specifically from a screenshot IMAGE (not a plain-
# text paste) is suspicious: this screenshot fell through to the
# OCR/text path -- either the map-vision gate misrouted it, or
# map_vision itself rejected it -- and came back with nothing at
# all. It might genuinely have been a map, worth keeping to check
# against later. A plain-text paste that finds nothing is normal
# and never reaches this function at all (see
# _on_clipboard_text_ready), so no separate guard needed here.
if isinstance(result, list) and not result:
debug_capture.save_maybe_map(png)
def _start_map_import(self, png: bytes, not_a_map) -> None: def _start_map_import(self, png: bytes, not_a_map) -> None:
"""Try to read the clipboard image as a map screenshot, off-thread. """Try to read the clipboard image as a map screenshot, off-thread.
@@ -561,6 +599,7 @@ class MainWindow(Adw.ApplicationWindow):
# one and the user wants to know why it didn't take. # one and the user wants to know why it didn't take.
if error != map_import.NOT_A_MAP: if error != map_import.NOT_A_MAP:
self.toast(f"Couldn't read the grid ({error}), trying as text.") self.toast(f"Couldn't read the grid ({error}), trying as text.")
debug_capture.save_map_read_failure(png, error)
not_a_map() not_a_map()
return return
self._on_map_import_ready(result) self._on_map_import_ready(result)
@@ -594,8 +633,31 @@ class MainWindow(Adw.ApplicationWindow):
).present(self) ).present(self)
def _accept_grid(self, imp, solution) -> None: def _accept_grid(self, imp, solution) -> None:
"""Grid confirmed: rectify the screenshot onto the board, then detect.""" """Grid confirmed: rectify the screenshot onto the board, then detect.
If the confirmed grid isn't the one the solver proposed (the user
dragged a corner in GridFixDialog), keep both the screenshot and
both solutions as ground truth, useful later for improving the
grid solver against exactly the case it got wrong."""
if solution is not imp.solution:
# full_image over image: sharper source for a human reviewing
# the capture later, same reasoning as save_marker_ground_truth's.
debug_capture.save_grid_correction(
imp.full_image if imp.full_image is not None else imp.image,
imp.solution, solution)
imp.solution = solution imp.solution = solution
# A screenshot already on the board (never explicitly dropped, the
# user just pasted a new one straight over it) still deserves its
# ground truth captured before it's replaced -- same as an
# explicit drop, see _capture_screenshot_ground_truth.
if self.screenshot_import is not None:
self._capture_screenshot_ground_truth(self.screenshot_import)
# Snapshot of what's on the board BEFORE this screenshot's own
# units get added, so _capture_screenshot_ground_truth can later
# tell "added because of this screenshot" apart from "was already
# there" -- see ScreenshotImport.baseline_targets/baseline_allies.
imp.baseline_targets = set(self.board.targets)
imp.baseline_allies = set(self.board.allies)
self.screenshot_import = imp self.screenshot_import = imp
imp.build_overlay() imp.build_overlay()
self.canvas.set_screenshot(imp.overlay, imp.px_per_km) self.canvas.set_screenshot(imp.overlay, imp.px_per_km)
@@ -638,17 +700,76 @@ class MainWindow(Adw.ApplicationWindow):
self._drop_shot_btn.set_visible(imp is not None) self._drop_shot_btn.set_visible(imp is not None)
self._accept_all_btn.set_sensitive(bool(imp is not None and imp.pending())) self._accept_all_btn.set_sensitive(bool(imp is not None and imp.pending()))
_DEV_SESSION_PATH = Path(tempfile.gettempdir()) / "fenigma_dev_session.pkl"
def _dev_dump_session_for_restart(self, *_a) -> bool:
"""SIGUSR1 handler: pickle {board, screenshot_import} so a
following relaunch can pick this session right back up. See the
SIGUSR1 registration in __init__ for why this exists."""
try:
with open(self._DEV_SESSION_PATH, "wb") as f:
pickle.dump({"board": self.board, "screenshot_import": self.screenshot_import}, f)
print(f"fenigma: dev session dumped to {self._DEV_SESSION_PATH}", flush=True)
except Exception as exc:
print(f"fenigma: dev session dump failed: {exc!r}", flush=True)
return GLib.SOURCE_CONTINUE
def _dev_maybe_restore_session(self) -> None:
"""Counterpart to `_dev_dump_session_for_restart`: one-shot restore
on startup if a dump is sitting there. Sets `self.board`/
`self.screenshot_import` directly (before the rest of __init__
builds the widgets that reference them) but defers the actual
redraw to an idle callback, since `self.canvas` doesn't exist yet
at this point in __init__."""
if not self._DEV_SESSION_PATH.exists():
return
try:
with open(self._DEV_SESSION_PATH, "rb") as f:
data = pickle.load(f)
self.board = data["board"]
self.screenshot_import = data["screenshot_import"]
self._DEV_SESSION_PATH.unlink()
print("fenigma: dev session restored", flush=True)
except Exception as exc:
print(f"fenigma: dev session restore failed: {exc!r}", flush=True)
return
GLib.idle_add(self._dev_finish_session_restore)
def _dev_finish_session_restore(self) -> bool:
self._refresh()
self._refresh_proposals()
if self.screenshot_import is not None:
self.canvas.set_screenshot(self.screenshot_import.overlay, self.screenshot_import.px_per_km)
return GLib.SOURCE_REMOVE
def _accept_proposal(self, proposal, type_=None) -> None: def _accept_proposal(self, proposal, type_=None) -> None:
coord = _coord_from_proposal(proposal) coord = _coord_from_proposal(proposal)
if coord is None: if coord is None:
return return
if type_ is None: if type_ is None:
type_ = icons.target_type_from_icon(proposal.unit) or TargetType.UNKNOWN type_ = icons.target_type_from_icon(proposal.unit) or TargetType.UNKNOWN
if proposal.side == "friendly": is_ally = proposal.side == "friendly"
self.board.add_ally(type_, coord) # Prefer the marker's own detected "#<N>" id (map_vision.read_marker_id,
# already past its own confidence floor by the time it's non-None)
# over an auto-assigned letter -- lets an accepted unit's id match
# what's actually on screen, rather than every accept silently
# becoming A/B/C regardless of what the game itself calls it.
# Falls back to auto-assign (id_=None) on a collision: two markers
# misread to the same id, or a real id that just happens to match
# one already auto-assigned, either way a duplicate id is worse
# than losing this one accept's traceability to the game's own
# number.
id_ = proposal.detected_id
group = self.board.allies if is_ally else self.board.targets
existing = {o.id for o in group if o.type == type_} # per-type, same as Board.add_target/add_ally
if id_ in existing:
id_ = None
if is_ally:
self.board.add_ally(type_, coord, id_=id_)
else: else:
self.board.add_target(type_, coord) self.board.add_target(type_, coord, id_=id_)
proposal.accepted = True proposal.accepted = True
proposal.confirmed_type = type_.name
def _accept_all_proposals(self) -> None: def _accept_all_proposals(self) -> None:
imp = self.screenshot_import imp = self.screenshot_import
@@ -661,6 +782,26 @@ class MainWindow(Adw.ApplicationWindow):
self._refresh_proposals() self._refresh_proposals()
self.toast(f"Accepted {len(pending)} unit(s).") self.toast(f"Accepted {len(pending)} unit(s).")
def _capture_screenshot_ground_truth(self, imp) -> None:
"""Whatever the user actually confirmed while `imp` was the active
screenshot -- accepted/rejected proposals, plus anything added to
the board that wasn't from a proposal at all (a manual add, or an
OCR-text merge run alongside it, see
ScreenshotImport.baseline_targets/baseline_allies) -- is exactly
the ground truth marker detection needs to improve against.
Called right before `imp` stops being the active screenshot,
whether that's an explicit drop (_remove_screenshot) or a new
screenshot pasted straight over it (_accept_grid), the last
moment it can still be tied to this specific image."""
added_targets = [t for t in self.board.targets if t not in imp.baseline_targets]
added_allies = [a for a in self.board.allies if a not in imp.baseline_allies]
# full_image over image: a human checking a detected_id against
# this capture later needs to actually read that tiny text, see
# save_marker_ground_truth's own docstring.
debug_capture.save_marker_ground_truth(
imp.full_image if imp.full_image is not None else imp.image,
imp.proposals, added_targets, added_allies)
def _remove_screenshot(self) -> None: def _remove_screenshot(self) -> None:
"""Dropping the screenshot also drops every proposal never accepted: """Dropping the screenshot also drops every proposal never accepted:
they were only ever readings OF that screenshot, so without it there is they were only ever readings OF that screenshot, so without it there is
@@ -668,6 +809,7 @@ class MainWindow(Adw.ApplicationWindow):
imp = self.screenshot_import imp = self.screenshot_import
if imp is None: if imp is None:
return return
self._capture_screenshot_ground_truth(imp)
dropped = len(imp.pending()) dropped = len(imp.pending())
imp.drop_unaccepted() imp.drop_unaccepted()
self.screenshot_import = None self.screenshot_import = None
@@ -709,9 +851,17 @@ class MainWindow(Adw.ApplicationWindow):
box = page() box = page()
lbl = Gtk.Label(xalign=0, margin_start=4, margin_bottom=2) lbl = Gtk.Label(xalign=0, margin_start=4, margin_bottom=2)
side = "friendly" if proposal.side == "friendly" else "hostile" side = "friendly" if proposal.side == "friendly" else "hostile"
# detected_id (map_vision.read_marker_id's best-effort read of
# the marker's own "#<N>" label, see its own docstring) is
# shown here so it's visible right when there's still a
# screenshot to actually check it against -- accept already
# uses it for the entity's id when present (see
# _accept_proposal), this is just making that fact visible
# before the click, not a separate signal.
id_part = f", id #{proposal.detected_id}" if proposal.detected_id else ""
lbl.set_markup( lbl.set_markup(
f"<b>{GLib.markup_escape_text(proposal.coord)}</b> — {side}, " f"<b>{GLib.markup_escape_text(proposal.coord)}</b> — {side}, "
f"{detected.value if detected else 'type unknown'}") f"{detected.value if detected else 'type unknown'}{GLib.markup_escape_text(id_part)}")
box.append(lbl) box.append(lbl)
box.append(Gtk.Separator(margin_top=2, margin_bottom=2)) box.append(Gtk.Separator(margin_top=2, margin_bottom=2))
button(box, f"Accept as {detected.value if detected else TargetType.UNKNOWN.value}", button(box, f"Accept as {detected.value if detected else TargetType.UNKNOWN.value}",
@@ -722,15 +872,37 @@ class MainWindow(Adw.ApplicationWindow):
def show_type(): def show_type():
box = page() box = page()
# Same icon grid the entity-edit "Change type" popover uses
# (see _open_entity_menu's own show_type below), not a plain
# text list -- also gets that grid's filtering for free
# (icons.available_target_types), which a bare `for t in
# TargetType` here didn't have: STRIKE/STRIKE_REQUEST aren't
# real pickable unit types (see their own comments in
# models.py) and shouldn't have been offered as "what this
# detected marker actually is".
grid = icons.build_target_type_grid(
detected, lambda t: accept(t), is_ally=(proposal.side == "friendly"),
)
scroller = Gtk.ScrolledWindow(propagate_natural_height=True, scroller = Gtk.ScrolledWindow(propagate_natural_height=True,
propagate_natural_width=True,
max_content_height=340, max_content_height=340,
hscrollbar_policy=Gtk.PolicyType.NEVER) hscrollbar_policy=Gtk.PolicyType.NEVER)
inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) scroller.set_child(grid)
for t in TargetType:
button(inner, t.value, lambda t=t: accept(t))
scroller.set_child(inner)
box.append(scroller) box.append(scroller)
# Swapping the child of an ALREADY-open Popover and re-popup()ing
# it reports the right size internally (visible=True, sane
# width/height) but the compositor never actually repaints the
# reused surface -- confirmed live: nothing appears on screen no
# matter how many times it's reopened. Popping the OLD popover
# down and opening a genuinely NEW one (fresh native surface,
# same anchor point) instead of resizing the existing one
# sidesteps that.
nonlocal popover
old_popover = popover
popover = self._popover_at(x, y)
popover.set_child(box) popover.set_child(box)
old_popover.popdown()
popover.popup()
show_main() show_main()
popover.popup() popover.popup()
@@ -764,16 +936,80 @@ class MainWindow(Adw.ApplicationWindow):
if self._clipboard_watch_handler is not None: if self._clipboard_watch_handler is not None:
Gdk.Display.get_default().get_clipboard().disconnect(self._clipboard_watch_handler) Gdk.Display.get_default().get_clipboard().disconnect(self._clipboard_watch_handler)
self._clipboard_watch_handler = None self._clipboard_watch_handler = None
# Closing with a screenshot still up is otherwise-silent data loss
# for debug_capture: same ground-truth capture as an explicit drop
# or pasting a new screenshot over it, see
# _capture_screenshot_ground_truth.
if self.screenshot_import is not None:
self._capture_screenshot_ground_truth(self.screenshot_import)
def _open_clear_menu(self, clear_btn: Gtk.Button, x: float, y: float) -> None:
"""Right-click on the Clear button: a lighter option than the full
Clear (left-click), for wiping the round's contacts without losing
the Nest/spotters/reference points set up for it."""
popover = Gtk.Popover()
popover.set_parent(clear_btn)
# See _popover_at's comment: Gdk.Rectangle's constructor silently
# ignores keyword args on this PyGObject version, field assignment
# after construction is the only way that actually works.
rect = Gdk.Rectangle()
rect.x, rect.y, rect.width, rect.height = int(x), int(y), 1, 1
popover.set_pointing_to(rect)
popover.connect("closed", lambda _p: popover.unparent())
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
margin_top=6, margin_bottom=6, margin_start=6, margin_end=6)
btn = Gtk.Button(label="Clear enemies, units & flights", css_classes=["destructive-action"])
btn.set_tooltip_text("Drops targets, allies, and scout flights. "
"Keeps the Nest, spotters, and reference points.")
if btn.get_child() is not None:
btn.get_child().set_xalign(0.0)
def go():
popover.popdown()
self._clear_units()
btn.connect("clicked", lambda _b: go())
box.append(btn)
popover.set_child(box)
popover.popup()
def _clear_units(self) -> None:
board = self.board
if not board.targets and not board.allies and not board.scout_flights:
self.toast("Nothing to clear.")
return
dialog = Adw.AlertDialog(
heading="Clear enemies, units & flights?",
body="Drops every target, ally, and scout flight. The Nest, spotters, and reference "
"points are kept. This can't be undone.",
)
dialog.add_response("cancel", "Cancel")
dialog.add_response("clear", "Clear")
dialog.set_response_appearance("clear", Adw.ResponseAppearance.DESTRUCTIVE)
dialog.set_default_response("cancel")
dialog.set_close_response("cancel")
def on_response(_dialog, response):
if response != "clear":
return
board.clear_units()
self._set_selection(None)
self._refresh()
self.toast("Enemies, units & flights cleared.")
dialog.connect("response", on_response)
dialog.present(self)
def _clear_board(self) -> None: def _clear_board(self) -> None:
board = self.board board = self.board
if (board.nest.coord is None and not board.spotters and not board.reference_points if (board.nest.coord is None and not board.spotters and not board.reference_points
and not board.targets and not board.scout_flights): and not board.targets and not board.allies and not board.scout_flights):
return # nothing to clear return # nothing to clear
dialog = Adw.AlertDialog( dialog = Adw.AlertDialog(
heading="Clear board?", heading="Clear board?",
body="Drops the Nest position and every spotter, reference point, target, and scout flight. " body="Drops the Nest position and every spotter, reference point, target, ally, and scout "
"This can't be undone.", "flight. This can't be undone.",
) )
dialog.add_response("cancel", "Cancel") dialog.add_response("cancel", "Cancel")
dialog.add_response("clear", "Clear") dialog.add_response("clear", "Clear")
@@ -791,7 +1027,7 @@ class MainWindow(Adw.ApplicationWindow):
self._refresh() self._refresh()
self.toast("Board cleared.") self.toast("Board cleared.")
def _merge_all(self, info: "ocr.ParsedInfo") -> None: def _merge_all(self, info: "ocr.ParsedInfo") -> list[str]:
changed = [] changed = []
if info.nest_coord is not None: if info.nest_coord is not None:
self.board.nest.coord = info.nest_coord self.board.nest.coord = info.nest_coord
@@ -806,6 +1042,7 @@ class MainWindow(Adw.ApplicationWindow):
else: else:
self.toast("Merged from screenshot: " + ", ".join(changed)) self.toast("Merged from screenshot: " + ", ".join(changed))
self._refresh() self._refresh()
return changed
def _merge_spotters(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]: def _merge_spotters(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]:
changed = [] changed = []
@@ -1291,6 +1528,7 @@ class MainWindow(Adw.ApplicationWindow):
def _add_strike_at(self, coord, shell: Shell) -> None: def _add_strike_at(self, coord, shell: Shell) -> None:
target = self.board.add_target(TargetType.STRIKE, coord) target = self.board.add_target(TargetType.STRIKE, coord)
target.shell = shell target.shell = shell
target.show_geo_desc = True # a strike's whole point is its blast radius; show it without needing a click
self.board.reorder_target(target, 0) # new strikes go to the front of the list self.board.reorder_target(target, 0) # new strikes go to the front of the list
self._refresh() self._refresh()
@@ -1369,17 +1607,45 @@ class MainWindow(Adw.ApplicationWindow):
def show_main(): def show_main():
box = page() box = page()
where = obj.coord.label() if getattr(obj, "coord", None) else "unplaced" where = obj.coord.label() if getattr(obj, "coord", None) else "unplaced"
heading(box, f"{obj.name} — {where}") heading(box, f"{_display_name(obj)} — {where}")
box.append(Gtk.Separator(margin_top=2, margin_bottom=2)) box.append(Gtk.Separator(margin_top=2, margin_bottom=2))
if hasattr(obj, "type"): if hasattr(obj, "type"):
button(box, f"Change type ({obj.type.value})", show_type) button(box, f"Change type ({icons.target_type_label(obj.type, isinstance(obj, Ally))})",
show_type)
if self._id_field_of(obj) is not None: if self._id_field_of(obj) is not None:
button(box, "Change ID", show_id) button(box, "Change ID", show_id)
button(box, "Change position (click the map)", change_position) button(box, "Change position (click the map)", change_position)
if isinstance(obj, Target):
# Alive/dead is Target-only (see models.py's Target.alive),
# same "Mark destroyed"/"Mark alive" toggle the firing
# panel's own alive button offers, just reachable from the
# map too rather than only from the sidebar.
button(box, "Mark destroyed" if obj.alive else "Mark alive", toggle_alive)
# Underground is Target-only too, see Target.underground_tier's
# own comment -- no such thing as an underground Ally.
ug_label = ("Mark underground" if obj.underground_tier is None
else f"Underground (tier {obj.underground_tier})")
button(box, ug_label, show_underground)
if not isinstance(obj, Nest): if not isinstance(obj, Nest):
button(box, "Delete", delete, css="destructive-action") button(box, "Delete", delete, css="destructive-action")
popover.set_child(box) popover.set_child(box)
def _reopen_with(box) -> None:
"""Swapping an ALREADY-open Popover's child via set_child()
alone reports the right size (visible=True, sane width/height)
but the compositor never actually repaints the reused surface
on some setups -- confirmed live, nothing appears on screen no
matter how many times it's reopened. Popping the OLD popover
down and opening a genuinely NEW one at the same anchor point
(fresh native surface, not an in-place resize) sidesteps it.
Shared by every page past show_main() in this menu."""
nonlocal popover
old_popover = popover
popover = self._popover_at(x, y)
popover.set_child(box)
old_popover.popdown()
popover.popup()
def show_type(): def show_type():
box = page() box = page()
heading(box, "Type") heading(box, "Type")
@@ -1397,13 +1663,34 @@ class MainWindow(Adw.ApplicationWindow):
obj.type, lambda t: set_type(t), is_ally=isinstance(obj, Ally), obj.type, lambda t: set_type(t), is_ally=isinstance(obj, Ally),
)) ))
box.append(scroller) box.append(scroller)
popover.set_child(box) _reopen_with(box)
def set_type(t): def set_type(t):
obj.type = t obj.type = t
self._refresh() self._refresh()
popover.popdown() popover.popdown()
self.toast(f"{obj.name} is now a {t.value}.") self.toast(f"{_display_name(obj)} is now a "
f"{icons.target_type_label(t, isinstance(obj, Ally))}.")
def show_underground():
box = page()
heading(box, "Underground")
scroller = Gtk.ScrolledWindow(propagate_natural_height=True,
propagate_natural_width=True,
max_content_height=340,
hscrollbar_policy=Gtk.PolicyType.NEVER)
scroller.set_child(icons.build_underground_tier_grid(
obj.underground_tier, lambda tier: set_underground(tier),
))
box.append(scroller)
_reopen_with(box)
def set_underground(tier):
obj.underground_tier = tier
self._refresh()
popover.popdown()
self.toast(f"{_display_name(obj)} is no longer underground." if tier is None
else f"{_display_name(obj)} is now underground (tier {tier}).")
def show_id(): def show_id():
box = page() box = page()
@@ -1433,6 +1720,21 @@ class MainWindow(Adw.ApplicationWindow):
if any(s is not obj and s.id == value for s in self.board.spotters): if any(s is not obj and s.id == value for s in self.board.spotters):
self.toast(f"Spotter#{value} already exists.") self.toast(f"Spotter#{value} already exists.")
return return
elif field == "id" and isinstance(obj, (Target, Ally)):
# Same invariant as Board.add_target/add_ally's auto-id
# (see their comments): id namespace is targets-vs-allies,
# AND scoped per type within that -- a Tank#1 and an
# Infantry#1 are not a collision, only two entities of the
# SAME type sharing an id are. A manual rename has to keep
# that too, or you get two entities that both read as
# e.g. "Infantry#1" with nothing telling them apart.
value = text
group = self.board.targets if isinstance(obj, Target) else self.board.allies
siblings = [o for o in group if o.type == obj.type]
if any(o is not obj and o.id == value for o in siblings):
kind = "target" if isinstance(obj, Target) else "ally"
self.toast(f"Another {obj.type.short} {kind} already has id {value!r}.")
return
else: else:
value = text value = text
old = obj.name old = obj.name
@@ -1450,6 +1752,17 @@ class MainWindow(Adw.ApplicationWindow):
lambda c: self._apply_and_refresh(obj, Location.from_coord(c))) lambda c: self._apply_and_refresh(obj, Location.from_coord(c)))
self.toast(f"Click the map to place {obj.name}, Esc to cancel.") self.toast(f"Click the map to place {obj.name}, Esc to cancel.")
def toggle_alive():
popover.popdown()
obj.alive = not obj.alive
# NOT self._refresh(): same reasoning as firing_panel.py's own
# alive toggle (see refresh_after_alive_change) -- this can
# never affect the solver or dedupe, doesn't need that full
# pipeline just because it's triggered from the map instead of
# the sidebar.
self.firing_panel.refresh_after_alive_change(obj)
self.toast(f"{_display_name(obj)} marked {'alive' if obj.alive else 'destroyed'}.")
def delete(): def delete():
popover.popdown() popover.popdown()
name = obj.name name = obj.name
@@ -1578,6 +1891,7 @@ class MainWindow(Adw.ApplicationWindow):
def add_strike(): def add_strike():
target = self.board.add_target(TargetType.STRIKE, coord) target = self.board.add_target(TargetType.STRIKE, coord)
target.show_geo_desc = True # a strike's whole point is its blast radius; show it without needing a click
self.board.reorder_target(target, 0) # new strikes go to the front of the list self.board.reorder_target(target, 0) # new strikes go to the front of the list
self._refresh() self._refresh()
popover.popdown() popover.popdown()
+164
View File
@@ -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),
})
+108 -9
View File
@@ -106,12 +106,28 @@ class FiringPanel(Gtk.Box):
"""Right-hand sidebar content: sort/filter toolbar + scrollable cards.""" """Right-hand sidebar content: sort/filter toolbar + scrollable cards."""
def __init__( def __init__(
self, board: Board, *, on_change, on_select, on_edit_position, on_set_position, on_remove, self, board: Board, *, on_change, on_visual_change, on_select, on_edit_position, on_set_position,
on_toggle_hide_dead_map, on_remove, on_toggle_hide_dead_map,
) -> None: ) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL) super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.board = board self.board = board
self.on_change = on_change self.on_change = on_change
# app.py's full pipeline (solver + dedupe + redraw + THIS panel's
# own full rebuild) -- for mutations that actually need it (a
# position/clue changed, a target was added/removed/reordered).
# Assignment/alive/shell changes don't: nothing about them can
# ever be produced by the solver or change dedupe's outcome, they
# just need the MAP redrawn (assignment isn't drawn there at all;
# alive dims a marker; shell can change a selected/pinned
# target's blast-radius circle). on_visual_change is that lighter
# path -- just a map redraw, no solver/dedupe/panel-rebuild -- see
# _cycle_assignment/_toggle_alive/_pick_shell, which pair it with
# _rebuild_one() for this panel's own (single-card, not
# whole-board) update. Was a real, measured lag source: every one
# of those three going through on_change() meant every single
# click rebuilt every card of every target on the board, not just
# the one that changed.
self.on_visual_change = on_visual_change
self.on_select = on_select self.on_select = on_select
self.on_edit_position = on_edit_position self.on_edit_position = on_edit_position
self.on_set_position = on_set_position self.on_set_position = on_set_position
@@ -135,14 +151,14 @@ class FiringPanel(Gtk.Box):
self._list_box.set_margin_bottom(10) self._list_box.set_margin_bottom(10)
self._list_box.set_margin_start(10) self._list_box.set_margin_start(10)
self._list_box.set_margin_end(10) self._list_box.set_margin_end(10)
scroller = Gtk.ScrolledWindow(child=self._list_box, vexpand=True) self._scroller = Gtk.ScrolledWindow(child=self._list_box, vexpand=True)
# Horizontal scrolling is never wanted here (fixed-width sidebar), # Horizontal scrolling is never wanted here (fixed-width sidebar),
# leaving it on AUTOMATIC (the default) lets a vertical scrollbar's # leaving it on AUTOMATIC (the default) lets a vertical scrollbar's
# own width shrink the content area enough to trigger a horizontal # own width shrink the content area enough to trigger a horizontal
# one too, which then perturbs card heights and can trip vertical # one too, which then perturbs card heights and can trip vertical
# scrolling that wasn't actually needed. Pin it off outright. # scrolling that wasn't actually needed. Pin it off outright.
scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) self._scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
self.append(scroller) self.append(self._scroller)
self.refresh() self.refresh()
@@ -195,6 +211,33 @@ class FiringPanel(Gtk.Box):
self._restyle(self.selected, self.selected_point, _SELECTED_CSS, False) self._restyle(self.selected, self.selected_point, _SELECTED_CSS, False)
self.selected, self.selected_point = target, point self.selected, self.selected_point = target, point
self._restyle(self.selected, self.selected_point, _SELECTED_CSS, True) self._restyle(self.selected, self.selected_point, _SELECTED_CSS, True)
if target is not None:
self._scroll_into_view(target, point)
def _scroll_into_view(self, target, point) -> None:
"""Selecting a target on the map (or cycling selection some other
way) should bring its card on-screen if the sidebar's scrolled
past it -- otherwise "selected" is invisible state the map alone
shows, and the firing panel this is FOR doesn't actually show what
got picked. A no-op if the card's already fully visible, this
only nudges the scroll position the minimum needed, never
recentres unnecessarily."""
card = next(
(c for c, p in self._cards_by_target.get(target, []) if point is None or p == point),
None,
)
if card is None:
return
ok, bounds = card.compute_bounds(self._list_box)
if not ok:
return # not laid out yet (e.g. called right after a rebuild); skip rather than guess
vadj = self._scroller.get_vadjustment()
top, bottom = bounds.get_y(), bounds.get_y() + bounds.get_height()
view_top, view_bottom = vadj.get_value(), vadj.get_value() + vadj.get_page_size()
if top < view_top:
vadj.set_value(top)
elif bottom > view_bottom:
vadj.set_value(bottom - vadj.get_page_size())
def set_hovered(self, target, point=None) -> None: def set_hovered(self, target, point=None) -> None:
if target is self.hovered and point == self.hovered_point: if target is self.hovered and point == self.hovered_point:
@@ -203,6 +246,28 @@ class FiringPanel(Gtk.Box):
self.hovered, self.hovered_point = target, point self.hovered, self.hovered_point = target, point
self._restyle(self.hovered, self.hovered_point, _HOVERED_CSS, True) self._restyle(self.hovered, self.hovered_point, _HOVERED_CSS, True)
def _rebuild_one(self, target: Target) -> None:
"""Rebuild just `target`'s own card(s) in place -- O(1) in the
number of OTHER targets on the board, unlike refresh() (which
tears down and rebuilds every card) -- for a mutation that only
changes this target's own display and can never add/remove a
card or move anything in the sort order (see
_cycle_assignment/_pick_shell; _toggle_alive uses this only when
that's also true for it, falling back to refresh() otherwise).
"""
old_cards = self._cards_by_target.get(target)
if not old_cards:
return # not currently shown (e.g. filtered out) -- nothing to update
new_cards = self._build_cards(target)
for (old_widget, _old_point), (new_widget, new_point) in zip(old_cards, new_cards):
self._list_box.insert_child_after(new_widget, old_widget)
self._list_box.remove(old_widget)
if target is self.selected and (self.selected_point is None or new_point == self.selected_point):
new_widget.add_css_class(_SELECTED_CSS)
if target is self.hovered and (self.hovered_point is None or new_point == self.hovered_point):
new_widget.add_css_class(_HOVERED_CSS)
self._cards_by_target[target] = new_cards
def _restyle(self, target, point, css_class: str, add: bool) -> None: def _restyle(self, target, point, css_class: str, add: bool) -> None:
"""point=None means "the whole target" (every one of its cards); """point=None means "the whole target" (every one of its cards);
otherwise only the card for that specific ambiguous candidate, otherwise only the card for that specific ambiguous candidate,
@@ -369,7 +434,16 @@ class FiringPanel(Gtk.Box):
if dragged not in targets or drop_onto not in targets: if dragged not in targets or drop_onto not in targets:
return return
self.board.reorder_target(dragged, targets.index(drop_onto)) self.board.reorder_target(dragged, targets.index(drop_onto))
self.on_change() # NOT self.on_change(): that's app.py's "single choke point" full
# refresh (re-run the solver over every target's clues, dedupe,
# redraw the map, THEN rebuild this panel), all of it wasted work
# for a pure order change -- no location/clue/coord/alive state
# moved, so nothing the solver or the map drawing cares about
# changed, only this panel's own card order did. Calling that
# full pipeline on every single drag-drop was what made
# reordering feel laggy; a local refresh() is the only rebuild a
# reorder actually needs.
self.refresh()
def _build_unresolved_card(self, target: Target) -> Gtk.Widget: def _build_unresolved_card(self, target: Target) -> Gtk.Widget:
card, inner = self._build_card_shell(target) card, inner = self._build_card_shell(target)
@@ -496,14 +570,39 @@ class FiringPanel(Gtk.Box):
return row return row
def _cycle_assignment(self, target: Target) -> None: def _cycle_assignment(self, target: Target) -> None:
# Assignment (L/R/unassigned) isn't drawn on the map at all, so
# this doesn't even need on_visual_change, just the card itself.
idx = _ASSIGNMENT_STATES.index(target.assignment) idx = _ASSIGNMENT_STATES.index(target.assignment)
target.assignment = _ASSIGNMENT_STATES[(idx + 1) % len(_ASSIGNMENT_STATES)] target.assignment = _ASSIGNMENT_STATES[(idx + 1) % len(_ASSIGNMENT_STATES)]
self.on_change() self._rebuild_one(target)
def _toggle_alive(self, target: Target) -> None: def _toggle_alive(self, target: Target) -> None:
target.alive = not target.alive target.alive = not target.alive
self.on_change() self.refresh_after_alive_change(target)
def refresh_after_alive_change(self, target: Target) -> None:
"""The display-only aftermath of target.alive flipping, split out
from _toggle_alive so app.py's map-popover "Mark destroyed"/"Mark
alive" (which flips target.alive itself, reaching this same
target) can reuse the same cheap-when-possible logic rather than
going through on_change()'s full solver+dedupe+canvas+panel pass
again -- exactly the rebuild this class exists to avoid paying
for a change that was never going to affect the solver or dedupe.
A card's presence/position can depend on alive (show_dead "hide"
drops dead cards entirely, "sort_later" moves them to their own
group at the bottom) -- only "show" guarantees this card stays
exactly where it is, just dimmed, so only that mode gets the
cheap single-card path; the other two need this panel's own full
rebuild (still far cheaper than on_change()'s, since it skips
everything but the last step)."""
if self.show_dead == "show":
self._rebuild_one(target)
else:
self.refresh()
self.on_visual_change() # dead dimming / hide_dead_from_map affects the map too
def _pick_shell(self, target: Target, shell: Shell) -> None: def _pick_shell(self, target: Target, shell: Shell) -> None:
target.shell = shell target.shell = shell
self.on_change() self._rebuild_one(target)
self.on_visual_change() # a selected/pinned target's blast-radius circle depends on its shell
+131 -15
View File
@@ -12,6 +12,7 @@ from collections import namedtuple
import cairo import cairo
import gi import gi
import numpy as np import numpy as np
from PIL import Image as PILImage
gi.require_version("Gtk", "4.0") gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0") gi.require_version("Gdk", "4.0")
@@ -195,6 +196,44 @@ def _icon_for(category: str, obj) -> cairo.ImageSurface | None:
return None return None
# path -> (surface, content_bbox) for additive badges specifically.
# Separate from _ICON_SURFACE_CACHE because these also need their real
# opaque content's bounding box: unlike the unit icons (already ~edge to
# edge in their own canvas, see _draw_icon_marker), the additive art
# (assets/icons/targets/additives/) sits inside a lot of transparent
# padding that isn't even centered -- scaling/positioning off the full
# 256x256 canvas made the badge look tiny and float with a visible gap
# above the icon it's supposed to touch. bbox is None for a path that
# failed to load, or (l, t, r, b) of its actual opaque pixels.
_ADDITIVE_CACHE: dict = {}
def _additive_surface(path) -> tuple:
if path not in _ADDITIVE_CACHE:
surface, bbox = None, None
try:
surface = cairo.ImageSurface.create_from_png(str(path))
bbox = PILImage.open(str(path)).getbbox()
except Exception:
pass
_ADDITIVE_CACHE[path] = (surface, bbox)
return _ADDITIVE_CACHE[path]
def _additive_for(category: str, obj) -> tuple | None:
"""The underground-tier badge overlaid on top of a Target's own icon,
or None. Target-only (see Target.underground_tier's own comment)."""
if category != "target":
return None
tier = getattr(obj, "underground_tier", None)
if tier is None:
return None
surface, bbox = _additive_surface(icons.underground_icon_path(tier))
if surface is None:
return None
return (surface, bbox)
class GridCanvas(Gtk.DrawingArea): class GridCanvas(Gtk.DrawingArea):
def __init__(self, board: Board) -> None: def __init__(self, board: Board) -> None:
super().__init__() super().__init__()
@@ -599,8 +638,24 @@ class GridCanvas(Gtk.DrawingArea):
what a proposal is until the user accepts it.""" what a proposal is until the user accepts it."""
for p, coord in self._pending_proposals(): for p, coord in self._pending_proposals():
color = CATEGORY_COLOR["ally" if p.side == "friendly" else "target"] color = CATEGORY_COLOR["ally" if p.side == "friendly" else "target"]
# No coord here: _draw_marker already shows one right below
# this label (`coord=coord` below), repeating it in the main
# label too was pure noise. detected_type/detected_id
# (map_vision.classify_marker/read_marker_id's best-effort
# reads) shown instead when known -- same "? Mechanized#3"
# shape an accepted entity's own name takes (Target.name),
# checkable against the actual screenshot pixels while it's
# still up, and detected_id is the same id _accept_proposal
# will use for the entity if this gets accepted.
detected_type = icons.target_type_from_icon(p.unit)
# .short, not target_type_label(): matches Target.name/Ally.name's
# own naming exactly ("SupplyCache" not "Supply Cache"), so this
# preview label reads the same as what accepting it produces.
type_part = detected_type.short if detected_type else ""
id_part = f"#{p.detected_id}" if p.detected_id else ""
label = f"? {type_part}{id_part}" if (type_part or id_part) else "?"
self._draw_marker(cr, view, coord.as_fraction(), color, self._draw_marker(cr, view, coord.as_fraction(), color,
f"? {coord.label()}", width, height, label, width, height,
hollow=True, coord=coord) hollow=True, coord=coord)
def _hit_test(self, view: _View, x: float, y: float): def _hit_test(self, view: _View, x: float, y: float):
@@ -817,7 +872,8 @@ class GridCanvas(Gtk.DrawingArea):
dim=(category == "target" and not obj.alive) or obj.hidden, dim=(category == "target" and not obj.alive) or obj.hidden,
selected=(obj is self.selected), coord=obj.coord, selected=(obj is self.selected), coord=obj.coord,
extra_line=getattr(obj, "requested_time", None), extra_line=getattr(obj, "requested_time", None),
icon_surface=_icon_for(category, obj)) icon_surface=_icon_for(category, obj),
additive=_additive_for(category, obj))
for category, obj in self.board.ambiguous_entities_all(): for category, obj in self.board.ambiguous_entities_all():
if self._excluded_from_map(obj): if self._excluded_from_map(obj):
@@ -878,7 +934,8 @@ class GridCanvas(Gtk.DrawingArea):
def _draw_marker(self, cr, view, point_km, color, label, def _draw_marker(self, cr, view, point_km, color, label,
canvas_width, canvas_height, *, hollow=False, dim=False, canvas_width, canvas_height, *, hollow=False, dim=False,
selected=False, coord=None, extra_line=None, icon_surface=None) -> None: selected=False, coord=None, extra_line=None, icon_surface=None,
additive=None) -> None:
x, y = self._km_to_px(view, point_km) x, y = self._km_to_px(view, point_km)
r, g, b = color r, g, b = color
alpha = 0.45 if dim else 1.0 alpha = 0.45 if dim else 1.0
@@ -898,6 +955,8 @@ class GridCanvas(Gtk.DrawingArea):
# plain filled dot is more honest about the current zoom level. # plain filled dot is more honest about the current zoom level.
if not hollow and icon_surface is not None and view.cell_w >= ICON_MIN_CELL_PX: if not hollow and icon_surface is not None and view.cell_w >= ICON_MIN_CELL_PX:
self._draw_icon_marker(cr, x, y, icon_surface, alpha) self._draw_icon_marker(cr, x, y, icon_surface, alpha)
if additive is not None:
self._draw_additive_badge(cr, x, y, additive, alpha)
elif hollow: elif hollow:
cr.new_path() # cairo's arc() draws a line from any stale current cr.new_path() # cairo's arc() draws a line from any stale current
cr.set_source_rgba(r, g, b, alpha) # point (e.g. the last label's cr.set_source_rgba(r, g, b, alpha) # point (e.g. the last label's
@@ -962,6 +1021,48 @@ class GridCanvas(Gtk.DrawingArea):
cr.paint_with_alpha(alpha) cr.paint_with_alpha(alpha)
cr.restore() cr.restore()
# How far the badge's content bbox sinks into the icon's, in the
# icon's own 32px box units. Both the diamond's top corner and the
# Armor badge's bottom are tapered to a near-point, not a flat edge
# (see assets/icons/targets/enemy/Enemy_Infantry.png and the Armor
# additives) -- lining up their bboxes exactly *touching* leaves them
# meeting at a single pixel with no visual mass on either side of it,
# which still reads as a gap. A real pixel overlap is what actually
# looks contiguous, confirmed against the game's own stacked-badge
# screenshots (stars/helmet/diamond all overlapping, not edge-to-edge).
_ADDITIVE_OVERLAP_PX = 10.0
def _draw_additive_badge(self, cr, x, y, additive, alpha) -> None:
"""A badge (underground tier, currently the only additive) drawn
directly north of the icon marker, overlapping down into it by
`_ADDITIVE_OVERLAP_PX`, at the same full size as the marker
itself -- stacked above it rather than shrunk into a corner, so
it reads as its own clearly-legible symbol, not a tiny decoration
obscuring the unit icon it modifies.
Scaled/positioned off the source art's actual opaque content
(`bbox`), not its full canvas: the additive PNGs carry a lot of
transparent padding that isn't even centered (see _ADDITIVE_CACHE's
comment), so sizing/placing off the raw canvas made the badge look
tiny and float with a visible gap above the icon -- using bbox
instead makes what's actually drawn sit right against it."""
surface, bbox = additive
sw, sh = surface.get_width(), surface.get_height()
left, top, right, bottom = bbox if bbox is not None else (0, 0, sw, sh)
content_w, content_h = right - left, bottom - top
if content_w <= 0 or content_h <= 0:
return
box = 32.0 # same visual size as the icon marker's own box
scale = box / max(content_w, content_h)
icon_top = y - 16 # _draw_icon_marker's own box=32, centered on y
ty = icon_top - bottom * scale + self._ADDITIVE_OVERLAP_PX
cr.save()
cr.translate(x - (left + right) / 2 * scale, ty)
cr.scale(scale, scale)
cr.set_source_surface(surface, 0, 0)
cr.paint_with_alpha(alpha)
cr.restore()
def _draw_firing_arrows(self, cr, view) -> None: def _draw_firing_arrows(self, cr, view) -> None:
"""Red arrow(s) Nest -> Target, for whatever's hovered or selected. """Red arrow(s) Nest -> Target, for whatever's hovered or selected.
Points at exactly the hovered/selected candidate when one is known Points at exactly the hovered/selected candidate when one is known
@@ -996,20 +1097,28 @@ class GridCanvas(Gtk.DrawingArea):
self._draw_arrow(cr, nx, ny, tx, ty) self._draw_arrow(cr, nx, ny, tx, ty)
def _draw_blast_radius(self, cr, view) -> None: def _draw_blast_radius(self, cr, view) -> None:
"""When a Target is selected, its effective shell's blast radius, """Every Target's effective shell's blast radius, for whichever
selection only, not hover (unlike the geo overlays/firing arrow), ones are selected or pinned via the same "always show geo"
per spec. Uses the specific selected candidate point if the target show_geo_desc toggle the bearing/distance overlay uses (not on
is ambiguous; skipped entirely if there's no known point yet, or plain hover, unlike that overlay -- a blast radius circle
the shell's blast radius isn't known.""" flickering in on every hover was judged too noisy, selection/
if not isinstance(self.selected, Target): pinning is a deliberate choice). Uses the specific selected
return candidate point if an ambiguous target is the selected one;
target = self.selected skipped per-target if there's no known point yet, or the shell's
point = target.coord if target.coord is not None else self.selected_point blast radius isn't known."""
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: if point is None:
return continue
radius_km = target.effective_shell.blast_radius_km radius_km = target.effective_shell.blast_radius_km
if radius_km is None: if radius_km is None:
return continue
x, y = self._km_to_px(view, point.as_fraction()) x, y = self._km_to_px(view, point.as_fraction())
rx, ry = view.cell_w * radius_km, view.cell_h * radius_km rx, ry = view.cell_w * radius_km, view.cell_h * radius_km
@@ -1084,7 +1193,14 @@ class GridCanvas(Gtk.DrawingArea):
way, so this looks at every RP/Target directly rather than those, way, so this looks at every RP/Target directly rather than those,
the only way to let the user eyeball a bad-but-close reading the only way to let the user eyeball a bad-but-close reading
against what it should have crossed.""" against what it should have crossed."""
candidates = list(self.board.reference_points) + list(self.board.targets) # Nest/Spotter never carry clues (always given as a direct grid
# coord, no relative-bearing mechanic for them), so leaving them
# out here wouldn't visibly change anything -- but Allies DO get
# clues from OCR ("FriendlyTank#1 Spotted. 088, 12.10km from
# Spotter#1") and also have a show_geo_desc pin in the UI (see
# app.py's per-card "always show geo" toggle), so omitting them
# here meant pinning one silently did nothing.
candidates = list(self.board.reference_points) + list(self.board.targets) + list(self.board.allies)
to_show = [ to_show = [
obj for obj in candidates obj for obj in candidates
if obj.location.clues and not self._excluded_from_map(obj) if obj.location.clues and not self._excluded_from_map(obj)
+103 -19
View File
@@ -101,9 +101,9 @@ _TARGET_ICON = {
TargetType.RECON: ("Recon.png", "Reconnaissance.png"), # name differs TargetType.RECON: ("Recon.png", "Reconnaissance.png"), # name differs
TargetType.RECON_LISTENING: ("Recon_Listening.png", "Recon_Listening.png"), TargetType.RECON_LISTENING: ("Recon_Listening.png", "Recon_Listening.png"),
} }
assert {*_TARGET_ICON} | {TargetType.STRIKE} == {*TargetType}, ( assert {*_TARGET_ICON} | {TargetType.STRIKE, TargetType.STRIKE_REQUEST} == {*TargetType}, (
"every TargetType needs a row in _TARGET_ICON (STRIKE is the one " "every TargetType needs a row in _TARGET_ICON (STRIKE/STRIKE_REQUEST "
"deliberate exception, see the comment above it)" "are the deliberate exceptions, see the comment above target_icon_path)"
) )
@@ -150,10 +150,11 @@ def target_icon_path(target_type: TargetType, is_ally: bool = False) -> Path | N
good one. `is_ally` picks the friendly side of _TARGET_ICON over the good one. `is_ally` picks the friendly side of _TARGET_ICON over the
enemy one, falling back to the enemy icon if this particular type has enemy one, falling back to the enemy icon if this particular type has
no friendly art of its own at all (the two sets aren't the same size, no friendly art of its own at all (the two sets aren't the same size,
see assets/icons/README.md). STRIKE (a planned impact point, not a see assets/icons/README.md). STRIKE/STRIKE_REQUEST (a planned impact
unit) gets its own crosshair rather than a unit icon, it doesn't fit point, not a unit -- player-placed vs called in by a friendly, see
the Enemy_/Friendly_ naming scheme at all.""" STRIKE_REQUEST's own comment) both get the same crosshair rather than
if target_type is TargetType.STRIKE: a unit icon, neither fits the Enemy_/Friendly_ naming scheme at all."""
if target_type in (TargetType.STRIKE, TargetType.STRIKE_REQUEST):
return STRIKE_ICON_PATH return STRIKE_ICON_PATH
own = _icon_for_side(target_type, is_ally) own = _icon_for_side(target_type, is_ally)
if own is not None: if own is not None:
@@ -448,6 +449,76 @@ def target_type_icon_image(target_type: "TargetType", is_ally: bool = False, wid
return _plain_dot(is_ally, width) return _plain_dot(is_ally, width)
_ADDITIVES_DIR = _ICONS_DIR / "targets" / "additives"
UNDERGROUND_TIERS = (1, 2, 3)
def underground_icon_path(tier: int) -> Path:
"""The badge overlaid on a Target's own icon when it's marked
underground at this tier (1..3, harder to hit = higher). Reuses the
game's own Armor-tier additive art (assets/icons/targets/additives/
Additive_Armor{1,2,3}.png) rather than inventing bespoke "underground"
art of our own -- there's nothing else in the game's icon set for
"buried/fortified", and Armor's own visual (a plate) already reads
right for that."""
return _ADDITIVES_DIR / f"Additive_Armor{tier}.png"
def underground_tier_image(tier: int | None, width: int = _TYPE_GRID_ICON_WIDTH) -> Gtk.Widget:
"""A widget for one cell of the underground-tier picker: the additive
badge itself for a real tier, or a plain dot (this module's usual
'nothing chosen' placeholder) for the "not underground" cell."""
if tier is None:
return _plain_dot(False, width)
path = underground_icon_path(tier)
if path.exists():
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(path), width, -1, True)
picture = Gtk.Picture.new_for_pixbuf(pixbuf)
picture.set_content_fit(Gtk.ContentFit.CONTAIN)
picture.set_can_shrink(True)
picture.set_size_request(pixbuf.get_width(), pixbuf.get_height())
return picture
return _plain_dot(False, width)
def build_underground_tier_grid(selected: int | None, on_pick) -> Gtk.Widget:
"""Same radio-style grid idea as build_target_type_grid, just over
(None, 1, 2, 3) instead of TargetType -- None first, as "not
underground" (clearing an existing tier) is exactly as valid a pick
as any real tier, not a separate "remove" action bolted on
afterward."""
_ensure_icon_button_css()
leader: Gtk.ToggleButton | None = None
items = [None, *UNDERGROUND_TIERS]
def make_button(tier: int | None) -> Gtk.Widget:
nonlocal leader
cell = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
margin_top=4, margin_bottom=4, margin_start=2, margin_end=2)
cell.append(underground_tier_image(tier))
label = Gtk.Label(label="None" if tier is None else f"Tier {tier}",
wrap=False, single_line_mode=True,
justify=Gtk.Justification.CENTER, width_chars=9,
max_width_chars=9, ellipsize=Pango.EllipsizeMode.END)
label.add_css_class("caption")
label.add_css_class("dim-label")
cell.append(label)
btn = Gtk.ToggleButton(child=cell)
btn.add_css_class("flat")
btn.add_css_class(_ICON_BUTTON_CSS_CLASS)
btn.set_tooltip_text("Not underground" if tier is None else f"Underground, tier {tier}")
if leader is None:
leader = btn
else:
btn.set_group(leader)
if tier is selected:
btn.set_active(True)
btn.connect("clicked", lambda _b, tier=tier: on_pick(tier))
return btn
return _build_icon_grid(items, _TYPE_GRID_COLUMNS, make_button)
def _has_own_icon(t: "TargetType", is_ally: bool) -> bool: def _has_own_icon(t: "TargetType", is_ally: bool) -> bool:
"""Whether THIS side specifically has real art for t -- as opposed to """Whether THIS side specifically has real art for t -- as opposed to
target_icon_path() quietly handing back the other side's icon because target_icon_path() quietly handing back the other side's icon because
@@ -467,35 +538,48 @@ def _has_own_icon(t: "TargetType", is_ally: bool) -> bool:
def available_target_types(is_ally: bool = False): def available_target_types(is_ally: bool = False):
"""TargetType members worth offering in a picker for this side. """TargetType members worth offering in a picker for this side.
STRIKE is never offered: it's not a unit type at all (a planned STRIKE/STRIKE_REQUEST are never offered: neither is a unit type at
impact point, not a contact), it's always created through its own all (a planned impact point, not a contact), each is always created
dedicated "Add strike" action (see app.py's _open_quick_add_menu), through its own path instead -- STRIKE via app.py's dedicated "Add
never by picking a type from this generic grid -- there's no such strike" action, STRIKE_REQUEST via ocr.py parsing a fire-support
thing as a Strike-typed Ally either, offering it there is just request -- never by picking a type from this generic grid. There's
confusing, not merely unlikely. no such thing as a Strike-typed Ally either, offering either one
here is just confusing, not merely unlikely.
Otherwise: each side only offers types it actually has its own art Otherwise: each side only offers types it actually has its own art
for (see _has_own_icon / _TARGET_ICON) -- some types are enemy-only for (see _has_own_icon / _TARGET_ICON) -- some types are enemy-only
and some are friendly-only (King, Police, a friendly hospital, ...), and some are friendly-only (King, Police, a friendly hospital, ...),
the game simply doesn't draw an installation of every kind on both the game simply doesn't draw an installation of every kind on both
sides.""" sides."""
return [t for t in TargetType if t is not TargetType.STRIKE and _has_own_icon(t, is_ally)] return [
t for t in TargetType
if t not in (TargetType.STRIKE, TargetType.STRIKE_REQUEST) and _has_own_icon(t, is_ally)
]
def _target_type_label(t: "TargetType", is_ally: bool) -> str: def target_type_label(t: "TargetType", is_ally: bool) -> str:
"""Display text for a picker cell/tooltip. TargetType.ENEMY's own """Display text for a picker cell/tooltip, or any other UI spot that
value is literally 'Enemy' (it's the word the game's OCR'd text uses would otherwise print obj.type.value directly (map popover headings,
for an ad-hoc *hostile* installation, see TargetType's own comment) -- "Change type" buttons, toasts, ...). TargetType.ENEMY's own value is
literally 'Enemy' (it's the word the game's OCR'd text uses for an
ad-hoc *hostile* installation, see TargetType's own comment) --
exactly right in the enemy picker, but confusing in the Ally one, exactly right in the enemy picker, but confusing in the Ally one,
where the very same generic/ad-hoc-named-unit case reads as 'Enemy' where the very same generic/ad-hoc-named-unit case reads as 'Enemy'
is somehow a kind of Ally. Cosmetic only: the underlying TargetType is somehow a kind of Ally. Cosmetic only: the underlying TargetType
stored on the entity is still ENEMY either way, only the label shown stored on the entity is still ENEMY either way, only the label shown
while picking it changes.""" changes -- callers that need an id-safe short form (Ally.name etc.)
keep using TargetType.short, not this."""
if is_ally and t is TargetType.ENEMY: if is_ally and t is TargetType.ENEMY:
return "Ally" return "Ally"
return t.value return t.value
# Old private name, kept as an alias: nothing outside this module should
# gain a new dependency on it, but this file's own internal callers below
# were written against it.
_target_type_label = target_type_label
def _target_type_cell(t: "TargetType", is_ally: bool) -> Gtk.Widget: def _target_type_cell(t: "TargetType", is_ally: bool) -> Gtk.Widget:
"""Icon + name, both a FIXED size regardless of how long the name is -- """Icon + name, both a FIXED size regardless of how long the name is --
a real cell size that varies with its label text (three-line names next a real cell size that varies with its label text (three-line names next
+78 -6
View File
@@ -41,6 +41,29 @@ class Proposal:
box: tuple box: tuple
accepted: bool = False accepted: bool = False
rejected: bool = False rejected: bool = False
# classify_marker's own raw numbers behind `unit` (best-match score,
# and its margin over the runner-up) -- unit alone only says whether
# it beat min_score/min_margin, not by how much or how close a call
# it was. Ground truth needs these to tell "confidently wrong" apart
# from "just barely missed the bar", which `unit=None` alone can't.
unit_score: float = 0.0
unit_margin: float = 0.0
# The TargetType.name actually applied when accepted -- usually just
# `unit` translated through icons.target_type_from_icon, but can
# differ if the user corrected it via "Accept as...". Set by
# app.py's _accept_proposal, the only writer. Ground truth for
# debug_capture.save_marker_ground_truth: `unit` is what the
# classifier guessed, this is what the user actually confirmed.
confirmed_type: str | None = None
# The marker's own "#<N>" id label, as read off the screenshot by
# map_vision.read_marker_id -- distinct from `label`/sub_x/sub_y
# (the grid CELL this marker is in), this is the small per-unit id
# the game itself draws. None when unread/unconfident (see
# read_marker_id's own docstring: best-effort, not yet validated
# against a real ground-truth batch). Meant for future dedup work
# (see TODO.md) once there's confidence in the read; not otherwise
# consumed yet.
detected_id: str | None = None
@property @property
def coord(self) -> str: def coord(self) -> str:
@@ -59,18 +82,49 @@ class ScreenshotImport:
proposals: list = field(default_factory=list) proposals: list = field(default_factory=list)
overlay: object = None # BGRA array in map space overlay: object = None # BGRA array in map space
px_per_km: int = 0 px_per_km: int = 0
# The same screenshot at full resolution, plus its width / `image`'s
# width -- `image` is downscaled to WORK_W for solving/marker-detection
# speed (see map_vision.WORK_W), which is plenty for those but throws
# away real detail the map overlay doesn't need to give up too (a
# screenshot can be up to 6880px wide, see map_vision.load_full_res's
# docstring). None/1.0 (rather than always loading it) because it's
# only needed for build_overlay(), and app.py sets it right after
# solving, before build_overlay() is ever called.
full_image: object = None
full_image_scale: float = 1.0
# Board.targets/Board.allies as they stood right when this screenshot's
# grid was confirmed (see app.py's _accept_grid) -- Target/Ally are
# identity-hashable (models.py's `eq=False`), so these are plain sets
# of the actual live objects, not ids/copies. Whatever's in
# board.targets/board.allies but NOT in these sets when the screenshot
# is later dropped was added while this screenshot was up, by
# whatever means (an accepted proposal, a manual add, an OCR-text
# merge run alongside it, ...) -- see app.py's _remove_screenshot,
# which treats that as this screenshot's ground truth for
# debug_capture.save_marker_ground_truth. Left for app.py to populate
# rather than done here, this module stays ignorant of the Board/
# Target/Ally types on purpose (see this file's own docstring).
baseline_targets: set = field(default_factory=set)
baseline_allies: set = field(default_factory=set)
def set_proposals(self, markers): def set_proposals(self, markers):
self.proposals = [ self.proposals = [
Proposal(side=m["side"], label=m["label"], sub_x=m["sub_x"], Proposal(side=m["side"], label=m["label"], sub_x=m["sub_x"],
sub_y=m["sub_y"], unit=m.get("unit"), sub_y=m["sub_y"], unit=m.get("unit"),
centre=m["centre"], box=m["box"]) for m in markers] centre=m["centre"], box=m["box"],
detected_id=m.get("detected_id"),
unit_score=m.get("unit_score", 0.0),
unit_margin=m.get("unit_margin", 0.0)) for m in markers]
return self.proposals return self.proposals
def build_overlay(self, px_per_km=100): def build_overlay(self, px_per_km=150):
"""Rectify the screenshot into map space, ready to draw under the grid.""" """Rectify the screenshot into map space, ready to draw under the grid.
Uses full_image (full resolution) over image (WORK_W-downscaled) when
available, see full_image's own docstring."""
src, scale = (self.full_image, self.full_image_scale) if self.full_image is not None \
else (self.image, 1.0)
self.overlay, self.px_per_km = map_vision.warp_to_map( self.overlay, self.px_per_km = map_vision.warp_to_map(
self.image, self.solution, px_per_km=px_per_km) src, self.solution, px_per_km=px_per_km, img_scale=scale)
return self.overlay return self.overlay
def accept_all(self): def accept_all(self):
@@ -145,7 +199,18 @@ class ImportJob:
sol, img, err = map_vision.solve_path(path) sol, img, err = map_vision.solve_path(path)
if sol is None: if sol is None:
return None, err return None, err
return ScreenshotImport(solution=sol, image=img), None imp = ScreenshotImport(solution=sol, image=img)
# Best-effort: a sharper source for build_overlay() than the
# WORK_W-downscaled `img` solving used (see full_image's own
# docstring). Anything going wrong here just means the overlay
# falls back to `img`, not worth failing the whole import over.
try:
full = map_vision.load_full_res(path)
imp.full_image = full
imp.full_image_scale = full.shape[1] / img.shape[1]
except (ValueError, ZeroDivisionError, OSError):
pass
return imp, None
return self._run(work, on_done, "map-import") return self._run(work, on_done, "map-import")
@@ -154,9 +219,16 @@ class ImportJob:
Fills imp.proposals and delivers on_done(imp, error). Its own thread, Fills imp.proposals and delivers on_done(imp, error). Its own thread,
because the user's grid correction sits between the two phases. because the user's grid correction sits between the two phases.
Marker detection itself always runs against imp.image (WORK_W,
same as solving used); imp.full_image is passed through only for
reading each marker's own tiny id label off a sharper source, see
map_vision.find_markers' own id_img param.
""" """
def work(): def work():
imp.set_proposals(map_vision.find_markers(imp.image, imp.solution)) id_img = imp.full_image # None is fine, find_markers falls back to imp.image
imp.set_proposals(map_vision.find_markers(
imp.image, imp.solution, id_img=id_img, id_scale=imp.full_image_scale))
return imp, None return imp, None
return self._run(work, on_done, "map-markers") return self._run(work, on_done, "map-markers")
+109 -4
View File
@@ -82,6 +82,22 @@ def load(path, work_w=None) -> np.ndarray:
return downscale(img, work_w) return downscale(img, work_w)
def load_full_res(path) -> np.ndarray:
"""Same read as load(), but never downscaled -- solving and marker
detection deliberately work at WORK_W (a screenshot's real resolution
only matters up to what a grid label needs to stay legible, see
solve_path's own docstring), but that same downscaled image is a poor
source for the map overlay the app draws the screenshot as: a
screenshot wider than WORK_W (the docstring above notes these run
700..6880px) was throwing away real detail there for no benefit. See
warp_to_map's img_scale param, which is how a caller tells it "this
image isn't the one `sol` was solved against, here's the size ratio"."""
img = cv2.imread(str(path), cv2.IMREAD_COLOR)
if img is None:
raise ValueError(f"cannot read image: {path}")
return img
def downscale(img, work_w=None) -> np.ndarray: def downscale(img, work_w=None) -> np.ndarray:
h, w = img.shape[:2] h, w = img.shape[:2]
s = min(1.0, (work_w or WORK_W) / w) s = min(1.0, (work_w or WORK_W) / w)
@@ -470,6 +486,62 @@ def read_cell_label(cell_gray, glyph_fracs=(0.10, 0.13, 0.17)):
return best return best
# Every marker the game draws also carries a small "#<N>" id label just
# above-left of its icon (distinct from the big per-cell grid label
# read_cell_label reads) -- calibrated by eye against a real screenshot
# saved under debug_captures/marker_ground_truth: it sits roughly one
# marker-width to the left and level with the marker's own top edge.
# Observed ids in practice are small (single or double digit); 1-99
# covers that generously without the search space growing large.
MARKER_ID_CANDIDATES = [f"#{n}" for n in range(1, 100)]
MIN_MARKER_ID_SCORE = 0.55 # unmeasured starting point, see read_marker_id's own docstring
def read_marker_id(gray, box, glyph_fracs=(0.30, 0.40, 0.50, 0.60)):
"""Which '#<N>' id best explains the pixels just above-left of this
marker? Same template-correlation approach as read_cell_label, and
for the same reason (see this module's own docstring): this text
sits over the same aerial-photo backdrop that defeated every
detection-based approach tried for grid labels, so glyph correlation
against a known-position crop is used here too rather than OCR.
`box` is the marker's own detected (x, y, w, h), in `gray`'s pixel
space -- the caller is responsible for scaling it if `gray` isn't
the same image the marker was detected in (see find_markers' own
id_img/id_scale params, for reading against a sharper source than
detection ran on).
Best-effort and NOT validated against a real ground-truth batch yet
(unlike read_cell_label's measured 0.73-0.87 vs 0.40-0.56 -- there's
no equivalent number here): both the crop region and
MIN_MARKER_ID_SCORE are a single-screenshot calibration, expect this
to need retuning once there's a real batch of debug_capture ground
truth with confirmed ids to check against (see TODO.md). Returns
None below the threshold rather than guessing.
"""
x, y, w, h = box
left = max(0, int(x - 1.0 * w))
top = max(0, int(y - 0.45 * h))
right = min(gray.shape[1], int(x + 0.65 * w))
bottom = min(gray.shape[0], int(y + 0.55 * h))
if right - left < 6 or bottom - top < 6:
return None
patch = np.ascontiguousarray(gray[top:bottom, left:right])
best = (None, -1.0)
for gf in glyph_fracs:
th = max(6, int(gf * h))
for cand in MARKER_ID_CANDIDATES:
t = glyph_template(cand, th)
if t is None or t.shape[0] >= patch.shape[0] or t.shape[1] >= patch.shape[1]:
continue
sc = float(cv2.matchTemplate(patch, t, cv2.TM_CCOEFF_NORMED).max())
if sc > best[1]:
best = (cand, sc)
if best[1] < MIN_MARKER_ID_SCORE:
return None
return best[0].lstrip("#")
def visible_cells(H, shape, limit=6): def visible_cells(H, shape, limit=6):
"""Lattice cells whose centre is on screen, nearest the frame centre """Lattice cells whose centre is on screen, nearest the frame centre
first (least perspective distortion, so the easiest to read).""" first (least perspective distortion, so the easiest to read)."""
@@ -649,10 +721,19 @@ def centre_cell_quad(sol, shape):
MAP_KM_W, MAP_KM_H = 20.0, 10.0 MAP_KM_W, MAP_KM_H = 20.0, 10.0
def warp_to_map(img, sol, px_per_km=100): def warp_to_map(img, sol, px_per_km=150, img_scale=1.0):
"""Rectify a screenshot into map space, ready to composite under the app's """Rectify a screenshot into map space, ready to composite under the app's
own grid. own grid.
`img` need not be the exact image `sol` was solved against (usually a
WORK_W-downscaled one, see solve_path) -- pass the original full-
resolution screenshot instead (see load_full_res) for a sharper overlay,
with `img_scale` set to img's width / the solved image's width, so this
can still map `sol`'s coordinates (which are in the SOLVED image's pixel
space) onto `img`'s actual pixels. img_scale=1.0 (the default) means
`img` IS the image `sol` was solved against, same as before this param
existed.
Returns (BGRA array, px_per_km). Only the region the screenshot actually Returns (BGRA array, px_per_km). Only the region the screenshot actually
covers is opaque; everything else is transparent, so a partial view of the covers is opaque; everything else is transparent, so a partial view of the
table does not blank out the rest of the map. table does not blank out the rest of the map.
@@ -673,6 +754,15 @@ def warp_to_map(img, sol, px_per_km=100):
# du, dv) is what pins those to named cells, and leaving it out put the # du, dv) is what pins those to named cells, and leaving it out put the
# screenshot in the wrong place for every automatically solved grid. # screenshot in the wrong place for every automatically solved grid.
M = grid_to_map @ sol.lattice_to_grid() @ np.linalg.inv(sol.H) M = grid_to_map @ sol.lattice_to_grid() @ np.linalg.inv(sol.H)
if img_scale != 1.0:
# img's pixels are img_scale times bigger than what M expects
# (the solved image's pixel space) -- shrink img-space coordinates
# down to that space first, applied first since matrices compose
# right-to-left.
to_solved_px = np.array([[1.0 / img_scale, 0.0, 0.0],
[0.0, 1.0 / img_scale, 0.0],
[0.0, 0.0, 1.0]])
M = M @ to_solved_px
bgra = cv2.cvtColor(img, cv2.COLOR_BGR2BGRA) bgra = cv2.cvtColor(img, cv2.COLOR_BGR2BGRA)
bgra[:, :, 3] = 255 bgra[:, :, 3] = 255
return cv2.warpPerspective(bgra, M, (out_w, out_h), flags=cv2.INTER_LINEAR, return cv2.warpPerspective(bgra, M, (out_w, out_h), flags=cv2.INTER_LINEAR,
@@ -927,18 +1017,33 @@ def diamonds(mask, cell_px, shape="diamond"):
MARKER_SHAPE = {"hostile": "diamond", "friendly": "rect"} MARKER_SHAPE = {"hostile": "diamond", "friendly": "rect"}
def find_markers(img, sol): def find_markers(img, sol, id_img=None, id_scale=1.0):
"""-> list of dicts: side, unit, label, sub_x, sub_y, coord, centre, box.""" """-> list of dicts: side, unit, label, sub_x, sub_y, coord, centre, box,
detected_id.
`id_img`/`id_scale`: read each marker's small "#<N>" id label (see
read_marker_id) against a sharper source than detection ran on --
ScreenshotImport.full_image over the WORK_W-downscaled `img`, same
reasoning as build_overlay's own img_scale (id text is tiny; reading
it off the downscaled image loses too much detail). `id_scale` is
id_img's width / img's width. Detection itself (marker
position/shape/color, unit classification) always runs against `img`
-- only the id read benefits from more resolution. Falls back to
reading against `img` itself when id_img is None (still better than
nothing, just at WORK_W's lower detail)."""
cell = max(sol.steps) cell = max(sol.steps)
found = [] found = []
id_gray = cv2.cvtColor(id_img if id_img is not None else img, cv2.COLOR_BGR2GRAY)
for side, mask in zip(("hostile", "friendly"), marker_masks(img)): for side, mask in zip(("hostile", "friendly"), marker_masks(img)):
for (cx, cy, box) in diamonds(mask, cell, MARKER_SHAPE[side]): for (cx, cy, box) in diamonds(mask, cell, MARKER_SHAPE[side]):
c = sol.cell_of(cx, cy) c = sol.cell_of(cx, cy)
if c is None: if c is None:
continue continue
unit, score, margin = classify_marker(img, box, side) unit, score, margin = classify_marker(img, box, side)
id_box = box if id_scale == 1.0 else tuple(v * id_scale for v in box)
detected_id = read_marker_id(id_gray, id_box)
found.append(dict(side=side, unit=unit, unit_score=score, found.append(dict(side=side, unit=unit, unit_score=score,
unit_margin=margin, label=c[0], sub_x=c[1], unit_margin=margin, label=c[0], sub_x=c[1],
sub_y=c[2], coord=format_coord(c), sub_y=c[2], coord=format_coord(c),
centre=(cx, cy), box=box)) centre=(cx, cy), box=box, detected_id=detected_id))
return found return found
+84 -9
View File
@@ -18,6 +18,7 @@ Coord) to work out everything else. This module just defines the shape.
from __future__ import annotations from __future__ import annotations
import itertools
import string import string
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
@@ -56,7 +57,18 @@ class TargetType(Enum):
# ("Enemy Signal Station", "Enemy Field Command"), not one of the # ("Enemy Signal Station", "Enemy Field Command"), not one of the
# game's fixed unit types, its id is the rest of that name with # game's fixed unit types, its id is the rest of that name with
# spaces stripped, see ocr.py's squash_enemy_names() # spaces stripped, see ocr.py's squash_enemy_names()
STRIKE = "Strike" # a planned impact point, not an enemy contact STRIKE = "Strike" # a planned impact point, not an enemy contact --
# player-placed only (app.py's dedicated "Add Strike" flow / map
# right-click), never produced by OCR.
STRIKE_REQUEST = "Strike Request" # a planned impact point a friendly
# unit is calling in over the radio (ocr.py's "taking fire" fire-
# support-request grammar, when it names a bearing/distance offset
# from the reporter rather than the reporter's own position), as
# opposed to STRIKE, which the player places themselves. Same
# "not an enemy contact, just an impact point" shape as STRIKE
# (dedupe_generic_targets/icons.py both treat the two the same way),
# kept as its own type rather than reusing STRIKE so a request that
# came in over the radio is never confused for one the player chose.
# -- Ground combat units ------------------------------------------- # -- Ground combat units -------------------------------------------
ANTI_AIR = "Anti-Air" ANTI_AIR = "Anti-Air"
@@ -399,6 +411,10 @@ class Target:
# raw string as printed, this app doesn't track a game clock to compare # raw string as printed, this app doesn't track a game clock to compare
# it against, it's shown as-is for the player's own reference. # it against, it's shown as-is for the player's own reference.
requested_time: str | None = None requested_time: str | None = None
# None = not underground. 1..3 = underground, at that hardening tier
# (see icons.UNDERGROUND_TIERS) -- higher survives more. Target-only:
# there's no such thing as an underground Ally in this game.
underground_tier: int | None = None
@property @property
def name(self) -> str: def name(self) -> str:
@@ -487,6 +503,39 @@ class ScoutFlight:
return f"ScoutFlight#{self.id}" return f"ScoutFlight#{self.id}"
def _next_free_id(used: set[str]) -> str:
"""Next unused id in a short, human-friendly LETTER sequence: single
uppercase letters (A..Z) first, then two-letter combinations
(AA..ZZ, spreadsheet-column style) once those run out, and so on.
Scoped per TYPE, not per group: add_target()/add_ally() only look at
existing entities of the SAME type when building `used`, so a Tank
and an Infantry added back to back both start at 'A' (Tank#A,
Infantry#A), each type keeping its own independent sequence.
Deliberately letters, not numbers: a manually-added entity (map
right-click "Add target", or an accepted screenshot proposal with no
confident id read) has no real game id to report, so it gets an
obviously-not-a-real-id placeholder instead -- app.py's
_accept_proposal reserves plain numbers for an id it's actually
confident was read off the marker itself (map_vision.read_marker_id
via Proposal.detected_id), passed straight through as this
function's caller's `id_` and never touching this auto-assignment at
all. Letters can't collide with a real (numeric) detected id either,
on top of just reading honestly as 'made up'.
Can't run out the way `next(c for c in string.ascii_uppercase if c
not in used)` used to (a real regression, see TODO.md): rolls over to
two-letter ids ('AA', 'AB', ...) past 26 instead of raising
StopIteration."""
length = 1
while True:
for combo in itertools.product(string.ascii_uppercase, repeat=length):
candidate = "".join(combo)
if candidate not in used:
return candidate
length += 1
SAVE_FORMAT_VERSION = 3 SAVE_FORMAT_VERSION = 3
@@ -557,9 +606,18 @@ class Board:
location: Location | Coord | None = None, location: Location | Coord | None = None,
id_: str | None = None, id_: str | None = None,
) -> Target: ) -> Target:
# Own A/B/C... sequence per TYPE, not one shared across every
# target regardless of type -- a Tank and an Infantry auto-
# assigned back to back both start at 'A' (Tank#A, Infantry#A).
# targets-vs-allies is still its own separate id namespace (see
# add_ally); type now subdivides it further too. Letters, not
# numbers, when auto-assigning here specifically: see
# _next_free_id's own docstring for why (a real detected id, when
# there is one, is passed in as `id_` and never reaches this
# auto-assignment at all).
if not id_: if not id_:
used = {t.id for t in self.targets if t.type == type_} used = {t.id for t in self.targets if t.type == type_}
id_ = next(c for c in string.ascii_uppercase if c not in used) id_ = _next_free_id(used)
t = Target(type=type_, id=id_, location=_as_location(location)) t = Target(type=type_, id=id_, location=_as_location(location))
self.targets.append(t) self.targets.append(t)
return t return t
@@ -574,13 +632,14 @@ class Board:
location: Location | Coord | None = None, location: Location | Coord | None = None,
id_: str | None = None, id_: str | None = None,
) -> Ally: ) -> Ally:
# A separate id namespace from add_target()'s: an ally Tank#1 # A separate id namespace from add_target()'s: an ally Tank#A
# and a hostile Target Tank#1 are unrelated, so auto-assignment # and a hostile Target Tank#A are unrelated, so auto-assignment
# here only looks at other allies of the same type, never # here only looks at other allies, never self.targets. Own
# self.targets. # A/B/C... sequence per TYPE too, same as add_target -- see its
# own comment and _next_free_id.
if not id_: if not id_:
used = {a.id for a in self.allies if a.type == type_} used = {a.id for a in self.allies if a.type == type_}
id_ = next(c for c in string.ascii_uppercase if c not in used) id_ = _next_free_id(used)
a = Ally(type=type_, id=id_, location=_as_location(location)) a = Ally(type=type_, id=id_, location=_as_location(location))
self.allies.append(a) self.allies.append(a)
return a return a
@@ -608,16 +667,30 @@ class Board:
# -- reset ---------------------------------------------------------- # -- reset ----------------------------------------------------------
def clear(self) -> None: def clear(self) -> None:
"""Drop everything: Nest position, spotters, reference points, """Drop everything: Nest position, spotters, reference points,
targets, scout flights. Used by the "clear board" action for a targets, allies, scout flights. Used by the "clear board" action
fresh start without restarting the app.""" for a fresh start without restarting the app."""
self.nest = Nest() self.nest = Nest()
self.spotters.clear() self.spotters.clear()
self.reference_points.clear() self.reference_points.clear()
self.targets.clear() self.targets.clear()
self.allies.clear()
self.scout_flights.clear() self.scout_flights.clear()
self._spotter_seq = 0 self._spotter_seq = 0
self._scout_flight_seq = 0 self._scout_flight_seq = 0
def clear_units(self) -> None:
"""Partial reset: drop targets, allies, and scout flights, but keep
the Nest, spotters, and reference points -- those are recon
infrastructure the player set up deliberately and usually wants to
keep across a round, unlike enemy/ally contacts and planned
overflights, which go stale fast. Wired to the Clear button's
right-click menu ("Clear enemies, units & flights") as a lighter
alternative to clear()."""
self.targets.clear()
self.allies.clear()
self.scout_flights.clear()
self._scout_flight_seq = 0
def reorder_target(self, target: Target, new_index: int) -> None: def reorder_target(self, target: Target, new_index: int) -> None:
"""Manual drag-order: `self.targets`' list order is itself the """Manual drag-order: `self.targets`' list order is itself the
persisted order (saved/loaded as a plain JSON array), and is what persisted order (saved/loaded as a plain JSON array), and is what
@@ -715,6 +788,7 @@ class Board:
"shell": t.shell.name if t.shell is not None else None, "shell": t.shell.name if t.shell is not None else None,
"assignment": t.assignment, "assignment": t.assignment,
"requested_time": t.requested_time, "requested_time": t.requested_time,
"underground_tier": t.underground_tier,
} }
for t in self.targets for t in self.targets
], ],
@@ -782,6 +856,7 @@ class Board:
shell=Shell[t["shell"]] if t.get("shell") else None, shell=Shell[t["shell"]] if t.get("shell") else None,
assignment=t.get("assignment", "unassigned"), assignment=t.get("assignment", "unassigned"),
requested_time=t.get("requested_time"), requested_time=t.get("requested_time"),
underground_tier=t.get("underground_tier"),
) )
for t in data.get("targets", []) for t in data.get("targets", [])
] ]
+196 -15
View File
@@ -197,6 +197,106 @@ def _extract_requested_time(text: str) -> str | None:
return m.group(1) if m else None return m.group(1) if m else None
# A second, unrelated fire-support-request grammar, seen from Infantry
# under attack ("taking fire") rather than a pinned Marine Garrison:
# Infantry#1 taking fire from id1! Requesting SMK Shell on our
# position at J6 2:7 before 10:38:57!
# Infantry#3 taking fire! Requesting HE Shell at bearing 239°,
# distance 10.76km from our position, J6 2:5, by 10:38:18 or we
# will be overrun!
# Differs from the Marine Garrison shape in every particular: the shell
# word order is reversed ("Requesting X Shell", not "X Shells requested"),
# the deadline has no "Requested"/dashes, just a bare "before"/"by <time>",
# and the target position is either given directly ("on our position at
# <coord>") or as a bearing/distance offset from that same inline
# position (never a *named* reference -- "our position" isn't a board
# entity, so this resolves the offset directly rather than going through
# a Clue).
# \s* (not \s+) between the shell code and 'Shell(s)': a rich-text paste's
# '<b>SMK Shell</b>' span gets squashed into one no-space token 'SMKShell'
# by squash_bold_spans() before this ever runs (same as any other
# multi-word bold span, see _squash_span_content), same reasoning as
# _extract_requesting_shell()'s docstring.
_REQUESTING_SHELL_RE = re.compile(r"Requesting\s+([A-Za-z]+?)\s*Shells?\b", re.IGNORECASE)
_TAKING_FIRE_TIME_RE = re.compile(r"\b(?:before|by)\s+(T?\d{1,2}:\d{2}:\d{2})\b", re.IGNORECASE)
# "<Type>#<id> taking fire!" always names the REPORTING unit calling in
# its own distress -- necessarily a friendly, no hostile ever radios in
# about itself under attack. There's no "Friendly"/"Hostile" prefix word
# anywhere in this grammar (see this module's own comment above) for
# _resolve_target_type to key off of, so without this the reporting unit
# defaults to not-ally (its own default) and gets added as an enemy.
_TAKING_FIRE_RE = re.compile(r"\btaking fire\b", re.IGNORECASE)
_ON_OUR_POSITION_COORD_RE = re.compile(
rf"on\s+our\s+position\s+at\s+{_COORD_FRAGMENT}", re.IGNORECASE
)
_BEARING_DISTANCE_FROM_POSITION_RE = re.compile(
rf"bearing\s*({_DIGIT_CLASS}{{1,3}})\s*°?\s*,?\s*distance\s*([\d.]+)\s*k?m\s+from\s+our\s+"
rf"position,?\s*{_COORD_FRAGMENT}",
re.IGNORECASE,
)
def _extract_requesting_shell(text: str) -> Shell | None:
"""'Requesting SMK Shell' -- the taking-fire grammar's shell mention,
word order reversed from _extract_shell_request()'s Marine Garrison
one ('SMK Shells requested')."""
m = _REQUESTING_SHELL_RE.search(text)
if not m:
return None
try:
return Shell[m.group(1).upper()]
except KeyError:
return None
def _extract_taking_fire_time(text: str) -> str | None:
m = _TAKING_FIRE_TIME_RE.search(text)
return m.group(1) if m else None
def _extract_on_our_position_coord(text: str) -> Coord | None:
m = _ON_OUR_POSITION_COORD_RE.search(text)
if not m:
return None
return _coord_from_groups(*m.groups())
def _extract_bearing_distance_from_position_coord(text: str) -> Coord | None:
"""The bearing/distance variant of a taking-fire request: the shell is
wanted somewhere OFF the reporting unit's own position, given as a
bearing/distance from it, with that position itself given inline
right there ('...from our position, J6 2:5, by ...'). "our position"
isn't a named board entity to hang a Clue off of, so this resolves
the offset directly via the same polar-projection math solve_location()
uses for an ordinary single bearing+distance clue."""
m = _BEARING_DISTANCE_FROM_POSITION_RE.search(text)
if not m:
return None
bearing, distance, letter, y, x, yy = m.groups()
origin = _coord_from_groups(letter, y, x, yy)
if origin is None:
return None
point = solver.point_from_bearing_distance(
origin.as_fraction(), float(_fix_digits(bearing)), float(distance))
return solver.point_to_coord(point)
def _extract_our_position_coord(text: str) -> Coord | None:
"""The bearing/distance variant's OWN inline position ('...from our
position, J6 2:5, by ...'), as opposed to
_extract_bearing_distance_from_position_coord's computed offset from
it. Reported unit and requested fire point are two different places
for this variant (unlike the direct "on our position at <coord>" one,
a real danger-close call), so parse_intel_blocks's flush() uses this
for the reporting unit's own entry and the offset for a second,
separate Strike entry -- see its comment."""
m = _BEARING_DISTANCE_FROM_POSITION_RE.search(text)
if not m:
return None
_bearing, _distance, letter, y, x, yy = m.groups()
return _coord_from_groups(letter, y, x, yy)
# "Reported active in grid D10": only the large-grid cell, no sub-grid # "Reported active in grid D10": only the large-grid cell, no sub-grid
# x:y at all, unlike every other coord shape in this file. Tried last # x:y at all, unlike every other coord shape in this file. Tried last
# (after _extract_grid_coord, which requires the full x:y and so is # (after _extract_grid_coord, which requires the full x:y and so is
@@ -275,6 +375,17 @@ _NAMED_HEADER_RE = re.compile(rf"^{_TYPE_ID_FRAGMENT}\s*:?\s*(.*)$")
# one), nothing else anchors this match, so an optional colon would # one), nothing else anchors this match, so an optional colon would
# false-positive on an ordinary clue-continuation line's leading word. # false-positive on an ordinary clue-continuation line's leading word.
_BARE_NAME_HEADER_RE = re.compile(r"^([A-Za-z][A-Za-z0-9]*)\s*:\s*(.*)$") _BARE_NAME_HEADER_RE = re.compile(r"^([A-Za-z][A-Za-z0-9]*)\s*:\s*(.*)$")
# Even requiring the colon isn't quite enough: a prose lead-in word right
# before a genuinely useful follow-up line ("Important: TEAR Shell first,
# then HE Shell.") false-positives the same way -- confirmed live against
# a real taking-fire message, where "Important:" got read as a brand new
# named entity ("Target#Important"), stealing the actual report's own
# "Answer by <time>" deadline into that bogus entry instead of the real
# one. None of these read as an actual thing being spotted/named, only
# ever as a prose interjection.
_BARE_NAME_HEADER_BLOCKLIST = {
"important", "note", "warning", "attention", "caution", "alert", "reminder", "priority",
}
# Ad-hoc enemy installations are named in plain English rather than given a # Ad-hoc enemy installations are named in plain English rather than given a
# Type#N id ("Enemy Signal Station:", "Bearing 034 from Enemy Signal # Type#N id ("Enemy Signal Station:", "Bearing 034 from Enemy Signal
@@ -515,10 +626,15 @@ _BARE_CLUE_VALUE_RE = re.compile(
_TYPE_BY_SHORT = {t.short: t for t in TargetType} _TYPE_BY_SHORT = {t.short: t for t in TargetType}
# The game's typewriter has used "AmmoCache" for what's now modeled as # The game's typewriter has used "AmmoCache" for what's now modeled as
# SupplyCache, and "CoastalBattery" for what's just a HostileArtillery # SupplyCache, "CoastalBattery" for what's just a HostileArtillery under
# under a different name, treat both as the same type rather than # a different name, and "Field Gun" for plain Artillery too (confirmed
# dropping the target or inventing a redundant enum member for it. # by the user against a real "Enemy Field Gun#1 Destroyed" kill-feed line
_TYPE_WORD_ALIASES = {"AmmoCache": "SupplyCache", "CoastalBattery": "HostileArtillery"} # that was otherwise silently dropping) -- treat all three as the same
# type rather than dropping the target or inventing a redundant enum
# member for each alternate name.
_TYPE_WORD_ALIASES = {
"AmmoCache": "SupplyCache", "CoastalBattery": "HostileArtillery", "FieldGun": "Artillery",
}
_REF_NAMED_RE = re.compile(rf"^{_TYPE_ID_FRAGMENT}") _REF_NAMED_RE = re.compile(rf"^{_TYPE_ID_FRAGMENT}")
@@ -606,17 +722,27 @@ def parse_clues_from_text(text: str) -> list[Clue]:
return _parse_all_clues(squash_enemy_names(squash_multiword_ids(text))) return _parse_all_clues(squash_enemy_names(squash_multiword_ids(text)))
_ALLY_PREFIX_RE = re.compile(r"^(Friendly|Hostile)", re.IGNORECASE) # 'Enemy' is also a valid not-ally prefix ("Enemy Field Gun#1 Destroyed",
# squashed to "EnemyFieldGun#1" by squash_multiword_ids -- confirmed live
# by the user this was silently dropping, the FieldGun->Artillery alias
# added below wasn't even reached because "Enemy" was never stripped off
# first, so alias/fuzzy lookup ran against "EnemyFieldGun" as a whole,
# not just "FieldGun"). The lookahead requires something AFTER the
# prefix: a bare "Enemy" alone must NOT match here and fall through
# un-stripped instead, since TargetType.ENEMY's own value IS "Enemy" --
# stripping it there would leave an empty type_word with nothing left to
# resolve, dropping every bare ad-hoc "Enemy#N Destroyed" report.
_ALLY_PREFIX_RE = re.compile(r"^(Friendly|Hostile|Enemy)(?=.)", re.IGNORECASE)
def _resolve_target_type(type_word: str) -> tuple[TargetType | None, bool]: def _resolve_target_type(type_word: str) -> tuple[TargetType | None, bool]:
"""(TargetType, is_ally). A leading 'Friendly'/'Hostile' word is """(TargetType, is_ally). A leading 'Friendly'/'Hostile'/'Enemy' word
stripped off the type word first ('FriendlyTank' -> ally, TANK; is stripped off the type word first ('FriendlyTank' -> ally, TANK;
'HostileTank' or bare 'Tank' -> not ally, TANK, an explicit 'HostileTank'/'EnemyTank'/bare 'Tank' -> not ally, TANK -- explicit
'Hostile' and no prefix at all mean the same thing, not-ally is the 'Hostile'/'Enemy' and no prefix at all all mean the same thing,
default). What's left is matched exactly against the type word not-ally is the default). What's left is matched exactly against the
(after aliasing), falling back to fuzzy (OCR can garble the type type word (after aliasing), falling back to fuzzy (OCR can garble
word itself, e.g. 'AmmoCoche').""" the type word itself, e.g. 'AmmoCoche')."""
is_ally = False is_ally = False
prefix_m = _ALLY_PREFIX_RE.match(type_word) prefix_m = _ALLY_PREFIX_RE.match(type_word)
if prefix_m: if prefix_m:
@@ -657,16 +783,66 @@ def parse_intel_blocks(text: str) -> list[dict]:
if current is not None: if current is not None:
joined = "\n".join(current["raw"]) joined = "\n".join(current["raw"])
current["clues"] = _parse_all_clues(joined) current["clues"] = _parse_all_clues(joined)
# The bearing/distance taking-fire variant names TWO different
# places (see _extract_our_position_coord's docstring): the
# reporting unit's own position, and a separate fire point
# offset from it. Everything else in this module is "one block
# -> one entry", so that offset gets split into a second,
# synthetic StrikeRequest entry below rather than folded into
# this one -- otherwise the fire point either overwrites the
# unit's real position (wrong place) or gets silently dropped.
offset_coord = _extract_bearing_distance_from_position_coord(joined)
current["coord"] = ( current["coord"] = (
_extract_grid_coord(joined) or _extract_requested_on_coord(joined) _extract_grid_coord(joined) or _extract_requested_on_coord(joined)
or _extract_on_our_position_coord(joined)
or _extract_our_position_coord(joined)
or _extract_large_grid_only_coord(joined) or _extract_large_grid_only_coord(joined)
) )
current["shell"] = _extract_shell_request(joined) shell = _extract_shell_request(joined) or _extract_requesting_shell(joined)
current["requested_time"] = _extract_requested_time(joined) requested_time = _extract_requested_time(joined) or _extract_taking_fire_time(joined)
# See _TAKING_FIRE_RE's own comment: overrides whatever
# _resolve_target_type would otherwise infer from type_word
# alone (no "Friendly"/"Hostile" prefix exists in this
# grammar to key off of) -- the reporting unit is always the
# friendly calling this in, never the hostile.
is_taking_fire = bool(_TAKING_FIRE_RE.search(joined))
current["force_ally"] = is_taking_fire
# Fire-support info (shell/deadline) always describes the FIRE
# POINT, never the reporting/named entity itself. For the
# bearing/distance offset variant that's a different place
# than the entity's own position. For a plain taking-fire
# report ("on our position") it's the SAME coord as the
# entity's own position, but the entity itself is now an ally
# (force_ally above), and Ally entries carry no shell/deadline
# fields at all to hold it -- either way a taking-fire
# report's shell+deadline moves to a synthetic StrikeRequest
# entry below rather than staying on this one, where it would
# either be wrong (offset case) or silently dropped (ally
# case, once merge_all only reads (raw, clues, coord) for allies).
split_fire_request = offset_coord is not None or is_taking_fire
current["shell"] = None if split_fire_request else shell
current["requested_time"] = None if split_fire_request else requested_time
if (current["clues"] or current["coord"] is not None if (current["clues"] or current["coord"] is not None
or current["shell"] is not None or current["requested_time"] is not None): or current["shell"] is not None or current["requested_time"] is not None):
current["raw"] = joined current["raw"] = joined
entries.append(current) entries.append(current)
# No offset given ("on our position") means the fire point
# IS the entity's own position, not a separate place.
fire_coord = offset_coord if offset_coord is not None else current["coord"]
if split_fire_request and fire_coord is not None and (shell is not None or requested_time is not None):
# TargetType.STRIKE_REQUEST, not STRIKE: this is a
# friendly unit calling in a strike over the radio, not
# one the player placed themselves (see that type's own
# comment in models.py). type_word must match its
# TargetType.short exactly ("StrikeRequest", no space),
# same as every other type_word this module produces.
strike_id = f"{current['type_word']}{current['id']}"
entries.append({
"kind": "named", "name": f"StrikeRequest#{strike_id}",
"type_word": "StrikeRequest",
"id": strike_id, "raw": joined, "clues": [], "coord": fire_coord,
"shell": shell, "requested_time": requested_time,
})
current = None current = None
for raw_line in text.splitlines(): for raw_line in text.splitlines():
@@ -743,7 +919,11 @@ def parse_intel_blocks(text: str) -> list[dict]:
# 'Target'), not a Reference Point, a named thing giving its own # 'Target'), not a Reference Point, a named thing giving its own
# clues is being spotted, not a fixed landmark spotters aim off # clues is being spotted, not a fixed landmark spotters aim off
# of, same reasoning as the "Target is at-" calibration line. # of, same reasoning as the "Target is at-" calibration line.
bare_m = next((m for c in candidates if (m := _BARE_NAME_HEADER_RE.match(c))), None) bare_m = next(
(m for c in candidates if (m := _BARE_NAME_HEADER_RE.match(c))
and m.group(1).lower() not in _BARE_NAME_HEADER_BLOCKLIST),
None,
)
if bare_m: if bare_m:
flush() flush()
name = bare_m.group(1) name = bare_m.group(1)
@@ -1007,6 +1187,7 @@ def parse_text(text: str) -> ParsedInfo:
info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"]) info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"])
continue continue
target_type, is_ally = _resolve_target_type(entry["type_word"]) target_type, is_ally = _resolve_target_type(entry["type_word"])
is_ally = is_ally or entry.get("force_ally", False)
if target_type is None: if target_type is None:
continue continue
if is_ally: if is_ally:
+6 -4
View File
@@ -372,14 +372,16 @@ def dedupe_generic_targets(board: Board) -> list[str]:
same* position as an already-known specific target, it's not a new same* position as an already-known specific target, it's not a new
contact, it's the same one being spotted, just described more contact, it's the same one being spotted, just described more
precisely. Drop the redundant generic entry, keep the specific one. precisely. Drop the redundant generic entry, keep the specific one.
Strikes are our own planned impacts, not enemy contacts, and never Strikes (player-placed or requested) are planned impacts, not enemy
participate. Run this after resolve_board(), since positions may contacts, and never participate. Run this after resolve_board(),
only become comparable once resolved. Returns the names removed.""" since positions may only become comparable once resolved. Returns
the names removed."""
removed: list[str] = [] removed: list[str] = []
unknowns = [t for t in board.targets if t.type is TargetType.UNKNOWN and t.coord is not None] unknowns = [t for t in board.targets if t.type is TargetType.UNKNOWN and t.coord is not None]
specifics = [ specifics = [
t for t in board.targets t for t in board.targets
if t.type not in (TargetType.UNKNOWN, TargetType.STRIKE) and t.coord is not None if t.type not in (TargetType.UNKNOWN, TargetType.STRIKE, TargetType.STRIKE_REQUEST)
and t.coord is not None
] ]
for generic in unknowns: for generic in unknowns:
if any(generic.coord == specific.coord for specific in specifics): if any(generic.coord == specific.coord for specific in specifics):
+70
View File
@@ -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"
+100
View File
@@ -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
+54
View File
@@ -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
+59
View File
@@ -0,0 +1,59 @@
"""warp_to_map's img_scale param: a caller can hand it a differently-sized
image than the one `sol` was actually solved against (see
map_vision.load_full_res / ScreenshotImport.full_image), scaled to
compensate. This checks that compensation is correct, without needing a
real fixture screenshot or the (slow) line-detection/solve pipeline --
just a synthetic image and a stub solution with a predictable transform.
"""
import numpy as np
import pytest
from fenigma import map_vision
class _IdentitySolution:
"""H and lattice_to_grid() both identity: warp_to_map's transform then
reduces to just grid_to_map, so the output is a directly px_per_km-
scaled (and row-flipped, per warp_to_map's own comment) copy of
whatever region of the input `warp_to_map` reads as "grid space"."""
H = np.eye(3)
def lattice_to_grid(self):
return np.eye(3)
def test_img_scale_compensates_for_a_bigger_source_image():
# A small solid-color source, plus a 2x upscaled copy of it -- same
# content, different pixel dimensions.
small = np.zeros((20, 20, 3), dtype=np.uint8)
small[:, :] = (10, 20, 30) # BGR
big = np.zeros((40, 40, 3), dtype=np.uint8)
big[:, :] = (10, 20, 30)
sol = _IdentitySolution()
out_small, ppk_small = map_vision.warp_to_map(small, sol, px_per_km=1)
out_big, ppk_big = map_vision.warp_to_map(big, sol, px_per_km=1, img_scale=2.0)
assert ppk_small == ppk_big == 1
assert out_small.shape == out_big.shape # output is always MAP_KM_W/H * px_per_km, regardless of source size
# Same solid color warped in (opaque region only -- compare where both
# actually painted something, alpha channel nonzero).
painted = (out_small[:, :, 3] > 0) & (out_big[:, :, 3] > 0)
assert painted.any()
np.testing.assert_array_equal(out_small[painted][:, :3], out_big[painted][:, :3])
def test_default_img_scale_is_unchanged_behavior():
"""img_scale's default (1.0) must reproduce pre-existing behavior
exactly -- every other warp_to_map call site doesn't pass it."""
img = np.zeros((20, 20, 3), dtype=np.uint8)
img[:, :] = (1, 2, 3)
sol = _IdentitySolution()
out_default, _ = map_vision.warp_to_map(img, sol, px_per_km=1)
out_explicit, _ = map_vision.warp_to_map(img, sol, px_per_km=1, img_scale=1.0)
np.testing.assert_array_equal(out_default, out_explicit)
def test_load_full_res_raises_like_load_on_a_bad_path(tmp_path):
with pytest.raises(ValueError):
map_vision.load_full_res(tmp_path / "does-not-exist.png")
+122
View File
@@ -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
+177
View File
@@ -10,6 +10,7 @@ being noticed (or not) days later.
""" """
from fenigma import ocr from fenigma import ocr
from fenigma.models import Coord, TargetType from fenigma.models import Coord, TargetType
from fenigma.shells import Shell
def test_standard_target_and_rp_blocks(): def test_standard_target_and_rp_blocks():
@@ -51,6 +52,43 @@ def test_destroyed_reports_digit_and_letter_id():
assert info.destroyed == {(TargetType.SUPPLY_CACHE, "2"), (TargetType.TANK, "3")} assert info.destroyed == {(TargetType.SUPPLY_CACHE, "2"), (TargetType.TANK, "3")}
def test_destroyed_report_strips_a_leading_enemy_prefix():
"""A real kill-feed paste with an "Enemy <Type>#<id> Destroyed" shape
(squashed by squash_multiword_ids to "EnemyMechanizedInfantry#1"
before this ever runs) was silently dropping every single-word type
("Enemy Infantry#11 Destroyed") -- the un-stripped "Enemy" prefix
only accidentally fuzzy-matched for longer/more distinctive type
words (Mechanized Infantry), not shorter/more different ones (Field
Gun -- see test_field_gun_is_an_artillery_alias). _ALLY_PREFIX_RE now
strips "Enemy" the same as "Hostile"."""
text = ("Enemy Mechanized Infantry#1 Destroyed, +5 Requisition.\n"
"Enemy Infantry#11 Destroyed, +5 Requisition.")
info = ocr.parse_text(text)
assert info.destroyed == {(TargetType.INFANTRY_MECHANIZED, "1"), (TargetType.INFANTRY, "11")}
def test_bare_enemy_destroyed_report_is_still_target_type_enemy():
"""The lookahead in _ALLY_PREFIX_RE (only strip "Enemy" when there's
something AFTER it) matters here specifically: a BARE "Enemy#N" is
TargetType.ENEMY itself (its own value IS "Enemy") -- stripping the
prefix unconditionally would leave an empty type_word and silently
drop every ad-hoc "Enemy#N Destroyed" report instead."""
text = "Enemy#7 Destroyed, +5 Requisition."
info = ocr.parse_text(text)
assert (TargetType.ENEMY, "7") in info.destroyed
def test_field_gun_is_an_artillery_alias():
"""The game calls plain Artillery "Field Gun" in at least this kill-
feed message -- confirmed by the user against a real "Enemy Field
Gun#1 Destroyed" line that was otherwise silently dropping (no
TargetType.FIELD_GUN exists, nor should one -- see _TYPE_WORD_ALIASES'
own comment, same treatment as AmmoCache/CoastalBattery)."""
text = "Priority target Enemy Field Gun#1 Destroyed, +50 Requisition."
info = ocr.parse_text(text)
assert (TargetType.ARTILLERY, "1") in info.destroyed
def test_train_arrival_intel(): def test_train_arrival_intel():
text = """ARRIVAL STATION: text = """ARRIVAL STATION:
Valle de Mula MainStation: J6 0:4 Valle de Mula MainStation: J6 0:4
@@ -236,3 +274,142 @@ Tank#3 Spotted. 095, 3.00km from Spotter#1
assert (TargetType.TANK, "1") not in info.targets assert (TargetType.TANK, "1") not in info.targets
assert (TargetType.TANK, "2") in info.targets assert (TargetType.TANK, "2") in info.targets
assert (TargetType.TANK, "3") in info.targets assert (TargetType.TANK, "3") in info.targets
def test_infantry_taking_fire_direct_position_request():
"""A different fire-support-request grammar from Marine Garrison's:
shell word order reversed ('Requesting X Shell' not 'X Shells
requested'), deadline is a bare 'before <time>' with no 'Requested'/
dashes. The '<b>id1</b>' attacker mention is just prose here, not
parsed into anything -- only the request itself (shell, position,
deadline) matters.
The reporting unit ('Infantry#1 taking fire') is always a FRIENDLY
calling in its own distress -- no hostile ever radios in about
itself -- so it lands in info.allies, not info.targets (a real bug:
it used to default to not-ally, no "Friendly"/"Hostile" prefix word
exists in this grammar for the usual inference to key off of). The
shell/deadline still need a home a plain Ally tuple doesn't have
room for, so they move to a synthetic StrikeRequest target at the
SAME coord as the reporting unit ('on our position' means the fire
point IS that position, no offset given)."""
text = ("Infantry#1 taking fire from <b>id1</b>!\n"
"Requesting <u><b>SMK Shell</b></u> on our position at <b>J6 2:7</b> "
"before <u>10:38:57</u>!")
info = ocr.parse_text(text)
assert (TargetType.INFANTRY, "1") in info.allies
assert (TargetType.INFANTRY, "1") not in info.targets
raw, clues, coord = info.allies[(TargetType.INFANTRY, "1")]
assert coord == Coord("J", 6, 2, 7)
assert (TargetType.STRIKE_REQUEST, "Infantry1") in info.targets
raw, clues, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry1")]
assert coord == Coord("J", 6, 2, 7)
assert shell is Shell.SMK
assert requested_time == "10:38:57"
def test_infantry_taking_fire_no_attacker_mention():
text = ("Infantry#3 taking fire!\n"
"Requesting <u><b>SMK Shell</b></u> on our position at <b>J6 2:5</b> "
"before <u>10:37:52</u>!")
info = ocr.parse_text(text)
assert (TargetType.INFANTRY, "3") in info.allies
assert (TargetType.INFANTRY, "3") not in info.targets
raw, clues, coord = info.allies[(TargetType.INFANTRY, "3")]
assert coord == Coord("J", 6, 2, 5)
assert (TargetType.STRIKE_REQUEST, "Infantry3") in info.targets
raw, clues, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry3")]
assert coord == Coord("J", 6, 2, 5)
assert shell is Shell.SMK
assert requested_time == "10:37:52"
def test_infantry_taking_fire_bearing_distance_from_position():
"""The other request shape: the shell isn't wanted right on top of the
reporting unit, but at a bearing/distance offset from its own
(inline-given) position -- two different places, so this becomes two
entries: Infantry#3 stays at its own reported position, as an ALLY
(see test_infantry_taking_fire_no_attacker_mention's own docstring --
same reasoning, this is still a taking-fire report), and a separate
synthetic Strike entry carries the shell/deadline at the computed
offset coord ('our position' isn't a named board entity to hang a
Clue off of, so this resolves straight to an absolute coord rather
than via one)."""
text = ("Infantry#3 taking fire!\n"
"Requesting <u><b>HE Shell</b></u> at bearing <b>239°</b>, distance "
"<b>10.76km</b> from our position, <b>J6 2:5</b>, by <u>10:38:18</u> "
"or we will be overrun!")
info = ocr.parse_text(text)
assert (TargetType.INFANTRY, "3") in info.allies
assert (TargetType.INFANTRY, "3") not in info.targets
raw, clues, coord = info.allies[(TargetType.INFANTRY, "3")]
assert coord == Coord("J", 6, 2, 5)
assert (TargetType.STRIKE_REQUEST, "Infantry3") in info.targets
raw, clues, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry3")]
assert shell is Shell.HE
assert requested_time == "10:38:18"
from fenigma import solver
expected = solver.point_to_coord(
solver.point_from_bearing_distance(Coord("J", 6, 2, 5).as_fraction(), 239.0, 10.76))
assert coord == expected
def test_infantry_taking_fire_bearing_distance_short_range():
"""Same shape, a sub-1km offset (the earlier fixture's own distance,
10.76km, is far enough that a rounding slip in the offset math could
have gone unnoticed inside the same large cell -- this one crosses a
cell boundary, I7 0:8 -> H7 8:4, so a sign/axis error would visibly
land in the wrong cell letter entirely, not just a slightly-off
sub-position)."""
text = ("Infantry#11 taking fire!\n"
"Requesting <u><b>HE Shell</b></u> at bearing <b>210°</b>, distance "
"<b>0.43km</b> from our position, <b>I7 0:8</b>, by <u>10:17:37</u> "
"or we will be overrun!")
info = ocr.parse_text(text)
assert (TargetType.INFANTRY, "11") in info.allies
assert (TargetType.INFANTRY, "11") not in info.targets
_, _, coord = info.allies[(TargetType.INFANTRY, "11")]
assert coord == Coord("I", 7, 0, 8)
assert (TargetType.STRIKE_REQUEST, "Infantry11") in info.targets
_, _, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry11")]
assert coord == Coord("H", 7, 8, 4)
assert shell is Shell.HE
assert requested_time == "10:17:37"
def test_taking_fire_important_followup_line_does_not_steal_the_deadline():
"""A real user-pasted message: a same-report "Important: ... Answer by
<time>" follow-up line was being misread as a brand new named entity
header (the last-resort bare-"<Name>:" fallback matched "Important:"
itself), creating a bogus Target#Important that stole the actual
report's own deadline into that wrong entry instead of the real
StrikeRequest. "Answer by <time>" is also a deadline phrasing
_TAKING_FIRE_TIME_RE already covers (any 'before'/'by <time>') --
once the phantom split stops happening, it resolves correctly with
no extra fix needed."""
text = ("Infantry#4 taking fire!\n"
"Requesting <u><b>TEAR Shell</b></u> first, then <u><b>HE Shell</b></u>,\n"
"at bearing <b>308°</b>, distance <b>1.86km</b> from our position, <b>N2 0:9</b>\n"
"<u>Important:</u> <u><b>TEAR Shell</b></u> first, then <u><b>HE Shell</b></u>.\n"
"Answer by <u>10:30:00</u>")
info = ocr.parse_text(text)
assert (TargetType.UNKNOWN, "Important") not in info.targets
assert (TargetType.INFANTRY, "4") in info.allies
assert (TargetType.INFANTRY, "4") not in info.targets
assert (TargetType.STRIKE_REQUEST, "Infantry4") in info.targets
_, _, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry4")]
assert requested_time == "10:30:00"
# Known gap, not asserted as fixed here: only the FIRST shell of a
# "X first, then Y" sequence is captured -- see TODO.md.
assert shell is Shell.TEAR