Compare commits

..
16 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
26 changed files with 1228 additions and 132 deletions
+295 -8
View File
@@ -119,15 +119,302 @@ Status legend: [x] fixed+tested, [~] partially addressed, [ ] open/needs input
accept attempt after that silently died before the ally/target accept attempt after that silently died before the ally/target
ever got added, popover already closed by the time it happened. ever got added, popover already closed by the time it happened.
Fixed there; not a separate bug. 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 ## Needs more scope / your input before I keep going
- [ ] Enemy type detection needs to be more robust; read the entity id - [~] Enemy type detection needs to be more robust; read the entity id
label so dedup is reliable; detect death from the log. label so dedup is reliable; detect death from the log.
All three are real computer-vision/OCR feature work (better marker
classification in `map_vision.py`'s `classify_marker`, a new OCR pass Started on the id-reading piece: `map_vision.read_marker_id` reads
reading each marker's id label off the map screenshot, and a each marker's own small "#<N>" label (distinct from the big
"<Type>#<id> Destroyed" log-scan tied into a dedup key that includes per-cell grid label `read_cell_label` reads) via the SAME template-
that read id) rather than bugs with a small fix. Worth its own pass correlation approach as `read_cell_label`, not OCR -- this text
once there's a batch of the `debug_capture` failure/maybe_map sits over the same aerial-photo backdrop that this module's own
screenshots above to develop against. 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

+32 -6
View File
@@ -6,12 +6,38 @@ machine and no GitHub, by booting a real Windows VM inside a container
hood, no license key needed for the eval install it fetches automatically) hood, no license key needed for the eval install it fetches automatically)
and driving the whole build over a shared folder. and driving the whole build over a shared folder.
**Status: written, not yet run against a real boot.** Everything here **Status: has actually been run.** Two real environment issues hit and
follows dockur/windows's and WiX's documented mechanics, but there's no fixed so far (both already applied in this directory, see "Confirmed
KVM/Windows available in the environment this was authored in to actually issues hit" below); Windows install itself was still in progress as of
exercise it end to end. Treat the first run as a debugging session, not a last check. `oem/install.bat` onward (MSYS2/GTK4/WiX provisioning, the
push-button success — watch it happen at http://localhost:8006 (dockur's actual .msi packaging) has NOT been reached/exercised yet — treat that
noVNC viewer) so you can see where it's stuck if it stalls. 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 ## How it fits together
+11
View File
@@ -30,6 +30,17 @@ fi
command -v docker >/dev/null 2>&1 || { echo "docker not found." >&2; exit 1; } command -v docker >/dev/null 2>&1 || { echo "docker not found." >&2; exit 1; }
mkdir -p storage oem shared/src shared/dist "$OUT_DIR" 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)" echo "==> starting the Windows build VM (docker compose up -d)"
docker compose up -d docker compose up -d
+17 -3
View File
@@ -49,7 +49,13 @@ 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 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% echo [build.bat] harvesting WiX components ... >> %LOG%
C:\wix\heat.exe dir C:\build\dist -cg AppFiles -gg -scom -sreg -sfrag -srd -sw5150 -dr INSTALLFOLDER -var var.DistDir -out C:\build\files.wxs >> %LOG% 2>&1 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 ( if errorlevel 1 (
echo [build.bat] FAILED: heat.exe harvest failed >> %LOG% echo [build.bat] FAILED: heat.exe harvest failed >> %LOG%
copy %LOG% Z:\build.log.failed >nul copy %LOG% Z:\build.log.failed >nul
@@ -57,10 +63,18 @@ if errorlevel 1 (
exit /b 1 exit /b 1
) )
copy /y C:\OEM\product.wxs C:\build\product.wxs >nul copy /y C:\FenigmaBuild\product.wxs C:\build\product.wxs >nul
echo [build.bat] compiling (candle) ... >> %LOG% echo [build.bat] compiling (candle) ... >> %LOG%
C:\wix\candle.exe -dDistDir=C:\build\dist -dAppVersion=%APPVER% -out C:\build\ C:\build\product.wxs C:\build\files.wxs >> %LOG% 2>&1 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 ( if errorlevel 1 (
echo [build.bat] FAILED: candle.exe failed >> %LOG% echo [build.bat] FAILED: candle.exe failed >> %LOG%
copy %LOG% Z:\build.log.failed >nul copy %LOG% Z:\build.log.failed >nul
+46 -9
View File
@@ -18,6 +18,24 @@ REM besides silence during the one-time provisioning run.
setlocal enabledelayedexpansion setlocal enabledelayedexpansion
call :log "starting FEnigma build-VM provisioning" 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 -- MSYS2: the "base" self-extracting archive, not the GUI installer --
REM (the GUI installer has no reliable non-interactive/silent flag across 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 versions; the base sfx archive is what CI pipelines actually use).
@@ -47,10 +65,23 @@ C:\msys64\usr\bin\bash.exe -lc "pacman -Syuu --noconfirm" >> C:\OEM\install.log
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 ..." call :log "installing GTK4/libadwaita/PyGObject/build deps ..."
C:\msys64\usr\bin\bash.exe -lc "pacman -S --noconfirm --needed mingw-w64-x86_64-python mingw-w64-x86_64-python-pip mingw-w64-x86_64-python-gobject mingw-w64-x86_64-gtk4 mingw-w64-x86_64-libadwaita mingw-w64-x86_64-python-numpy mingw-w64-x86_64-python-pillow mingw-w64-x86_64-opencv mingw-w64-x86_64-tesseract-ocr" >> C:\OEM\install.log 2>&1 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) ..." call :log "pip install pytesseract (pure python, no wheel needed) ..."
C:\msys64\mingw64\bin\python3.exe -m pip install pytesseract >> C:\OEM\install.log 2>&1 REM --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 -- WiX v3 toolset (candle/light/heat), a plain zip of standalone exes,
REM no installer needed. Fixed versioned URL, no scraping required. REM no installer needed. Fixed versioned URL, no scraping required.
@@ -61,13 +92,19 @@ powershell -NoProfile -Command ^
"Expand-Archive -Path 'C:\wix311-binaries.zip' -DestinationPath 'C:\wix' -Force" "Expand-Archive -Path 'C:\wix311-binaries.zip' -DestinationPath 'C:\wix' -Force"
del C:\wix311-binaries.zip del C:\wix311-binaries.zip
REM -- Register the build watcher to run at every boot from here on, plus REM -- Register the build watcher to run at every login from here on,
REM kick it off right now too (ONSTART won't retroactively fire for this REM plus kick it off right now too (a fresh login won't retroactively
REM already-in-progress boot). Runs as SYSTEM so it works with no user REM fire for this already-logged-in session). Deliberately an All-Users
REM logged in. REM Startup-folder entry, NOT a SYSTEM-context Scheduled Task: confirmed
call :log "registering build watcher ..." REM on a real run that a /ru SYSTEM task can't see Z:\ at all and spins
schtasks /create /tn "FenigmaBuildWatcher" /sc onstart /ru SYSTEM /rl HIGHEST /tr "C:\OEM\watch_build.bat" /f >> C:\OEM\install.log 2>&1 REM forever on watch_build.bat's own "if not exist Z:\" wait -- Z:\ (the
start "" cmd /c C:\OEM\watch_build.bat 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" call :log "provisioning done"
echo DONE > C:\OEM\provisioned.marker echo DONE > C:\OEM\provisioned.marker
+6 -3
View File
@@ -1,5 +1,8 @@
@echo off @echo off
REM Runs persistently from system boot (see install.bat's scheduled task). 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 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 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 on the Linux host trigger a build without any RDP/remote-exec: it's
@@ -17,14 +20,14 @@ if not exist Z:\ (
if exist Z:\BUILD_REQUEST ( if exist Z:\BUILD_REQUEST (
REM Claim the request before acting on it -- if watch_build.bat somehow 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 ended up running twice this boot (install.bat starts it once
REM immediately, the ONSTART task could also fire the same boot), only REM immediately, a fresh login could also start another copy), only
REM one of them wins this move and actually builds. REM one of them wins this move and actually builds.
move /y Z:\BUILD_REQUEST Z:\BUILD_REQUEST.claimed >nul 2>&1 move /y Z:\BUILD_REQUEST Z:\BUILD_REQUEST.claimed >nul 2>&1
if exist Z:\BUILD_REQUEST.claimed ( if exist Z:\BUILD_REQUEST.claimed (
del Z:\BUILD_REQUEST.claimed del Z:\BUILD_REQUEST.claimed
del /q Z:\BUILD_DONE 2>nul del /q Z:\BUILD_DONE 2>nul
del /q Z:\BUILD_FAILED 2>nul del /q Z:\BUILD_FAILED 2>nul
call C:\OEM\build.bat call C:\FenigmaBuild\build.bat
) )
) )
timeout /t 5 /nobreak >nul timeout /t 5 /nobreak >nul
+160 -17
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
@@ -272,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)
@@ -629,7 +640,11 @@ class MainWindow(Adw.ApplicationWindow):
both solutions as ground truth, useful later for improving the both solutions as ground truth, useful later for improving the
grid solver against exactly the case it got wrong.""" grid solver against exactly the case it got wrong."""
if solution is not imp.solution: if solution is not imp.solution:
debug_capture.save_grid_correction(imp.image, imp.solution, 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 # A screenshot already on the board (never explicitly dropped, the
# user just pasted a new one straight over it) still deserves its # user just pasted a new one straight over it) still deserves its
@@ -685,16 +700,74 @@ 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 proposal.confirmed_type = type_.name
@@ -722,7 +795,12 @@ class MainWindow(Adw.ApplicationWindow):
moment it can still be tied to this specific image.""" 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_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] added_allies = [a for a in self.board.allies if a not in imp.baseline_allies]
debug_capture.save_marker_ground_truth(imp.image, imp.proposals, added_targets, added_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:
@@ -773,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}",
@@ -794,15 +880,29 @@ class MainWindow(Adw.ApplicationWindow):
# real pickable unit types (see their own comments in # real pickable unit types (see their own comments in
# models.py) and shouldn't have been offered as "what this # models.py) and shouldn't have been offered as "what this
# detected marker actually is". # 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, propagate_natural_width=True,
max_content_height=340, max_content_height=340,
hscrollbar_policy=Gtk.PolicyType.NEVER) hscrollbar_policy=Gtk.PolicyType.NEVER)
scroller.set_child(icons.build_target_type_grid( scroller.set_child(grid)
detected, lambda t: accept(t), is_ally=(proposal.side == "friendly"),
))
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()
@@ -1521,10 +1621,31 @@ class MainWindow(Adw.ApplicationWindow):
# panel's own alive button offers, just reachable from the # panel's own alive button offers, just reachable from the
# map too rather than only from the sidebar. # map too rather than only from the sidebar.
button(box, "Mark destroyed" if obj.alive else "Mark alive", toggle_alive) 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")
@@ -1542,7 +1663,7 @@ 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
@@ -1551,6 +1672,26 @@ class MainWindow(Adw.ApplicationWindow):
self.toast(f"{_display_name(obj)} is now a " self.toast(f"{_display_name(obj)} is now a "
f"{icons.target_type_label(t, isinstance(obj, Ally))}.") 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()
heading(box, "ID") heading(box, "ID")
@@ -1581,16 +1722,18 @@ class MainWindow(Adw.ApplicationWindow):
return return
elif field == "id" and isinstance(obj, (Target, Ally)): elif field == "id" and isinstance(obj, (Target, Ally)):
# Same invariant as Board.add_target/add_ally's auto-id # Same invariant as Board.add_target/add_ally's auto-id
# (see their comments): one shared id namespace per group, # (see their comments): id namespace is targets-vs-allies,
# targets vs allies, never split further by type. A manual # AND scoped per type within that -- a Tank#1 and an
# rename has to keep that too, or you get two entities # Infantry#1 are not a collision, only two entities of the
# that both read as e.g. "...#A" with only the type prefix # SAME type sharing an id are. A manual rename has to keep
# telling them apart. # that too, or you get two entities that both read as
# e.g. "Infantry#1" with nothing telling them apart.
value = text value = text
siblings = self.board.targets if isinstance(obj, Target) else self.board.allies 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): if any(o is not obj and o.id == value for o in siblings):
kind = "target" if isinstance(obj, Target) else "ally" kind = "target" if isinstance(obj, Target) else "ally"
self.toast(f"Another {kind} already has id {value!r}.") self.toast(f"Another {obj.type.short} {kind} already has id {value!r}.")
return return
else: else:
value = text value = text
+13 -1
View File
@@ -101,7 +101,17 @@ def save_marker_ground_truth(image, proposals, added_targets=(), added_allies=()
matter: a rejected proposal is a false positive to fix, a manually- 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. 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 Skipped entirely if there's nothing to say (no proposals AND no
manually-added units), a screenshot nobody ever looked at units on.""" 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: if not proposals and not added_targets and not added_allies:
return None return None
png = _to_png_bytes(image) png = _to_png_bytes(image)
@@ -120,6 +130,8 @@ def save_marker_ground_truth(image, proposals, added_targets=(), added_allies=()
{ {
"side": p.side, "label": p.label, "sub_x": p.sub_x, "sub_y": p.sub_y, "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_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 for p in proposals
], ],
+104 -3
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
+70
View File
@@ -449,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
+28 -2
View File
@@ -41,6 +41,13 @@ 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 # The TargetType.name actually applied when accepted -- usually just
# `unit` translated through icons.target_type_from_icon, but can # `unit` translated through icons.target_type_from_icon, but can
# differ if the user corrected it via "Accept as...". Set by # differ if the user corrected it via "Accept as...". Set by
@@ -48,6 +55,15 @@ class Proposal:
# debug_capture.save_marker_ground_truth: `unit` is what the # debug_capture.save_marker_ground_truth: `unit` is what the
# classifier guessed, this is what the user actually confirmed. # classifier guessed, this is what the user actually confirmed.
confirmed_type: str | None = None 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:
@@ -95,7 +111,10 @@ class ScreenshotImport:
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=150): def build_overlay(self, px_per_km=150):
@@ -200,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")
+74 -3
View File
@@ -486,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)."""
@@ -961,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
+42 -22
View File
@@ -411,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:
@@ -500,19 +504,29 @@ class ScoutFlight:
def _next_free_id(used: set[str]) -> str: def _next_free_id(used: set[str]) -> str:
"""Next unused id in a short, human-friendly sequence: single """Next unused id in a short, human-friendly LETTER sequence: single
uppercase letters (A..Z) first, then two-letter combinations uppercase letters (A..Z) first, then two-letter combinations
(AA..ZZ, spreadsheet-column style) once those run out, and so on. (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.
A real regression lived here: `next(c for c in string.ascii_uppercase Deliberately letters, not numbers: a manually-added entity (map
if c not in used)` raises StopIteration the instant all 26 letters right-click "Add target", or an accepted screenshot proposal with no
are taken, which used to need 26+ auto-added entities of one TYPE confident id read) has no real game id to report, so it gets an
(rare) but, once add_target()/add_ally() moved to one shared id obviously-not-a-real-id placeholder instead -- app.py's
sequence per GROUP instead of per type (so a Tank and an Infantry _accept_proposal reserves plain numbers for an id it's actually
added back to back get 'A'/'B', not both 'A', see their own confident was read off the marker itself (map_vision.read_marker_id
comments), needs only 26 auto-added entities of ANY type in that via Proposal.detected_id), passed straight through as this
group -- reachable in a single big screenshot import. This can't run function's caller's `id_` and never touching this auto-assignment at
out: it just grows the id length instead.""" 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 length = 1
while True: while True:
for combo in itertools.product(string.ascii_uppercase, repeat=length): for combo in itertools.product(string.ascii_uppercase, repeat=length):
@@ -592,13 +606,17 @@ class Board:
location: Location | Coord | None = None, location: Location | Coord | None = None,
id_: str | None = None, id_: str | None = None,
) -> Target: ) -> Target:
# One shared A/B/C... sequence across every target regardless of # Own A/B/C... sequence per TYPE, not one shared across every
# type, not one sequence per type -- a Tank and an Infantry auto- # target regardless of type -- a Tank and an Infantry auto-
# assigned back to back get 'A' and 'B', never both 'A'. Only # assigned back to back both start at 'A' (Tank#A, Infantry#A).
# targets-vs-allies is a separate id namespace (see add_ally), # targets-vs-allies is still its own separate id namespace (see
# type never subdivides it further. # 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} used = {t.id for t in self.targets if t.type == type_}
id_ = _next_free_id(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)
@@ -614,13 +632,13 @@ 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, never self.targets. Same as # here only looks at other allies, never self.targets. Own
# add_target though, that's the ONLY split: one shared A/B/C... # A/B/C... sequence per TYPE too, same as add_target -- see its
# sequence across every ally regardless of type, not one per type. # own comment and _next_free_id.
if not id_: if not id_:
used = {a.id for a in self.allies} used = {a.id for a in self.allies if a.type == type_}
id_ = _next_free_id(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)
@@ -770,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
], ],
@@ -837,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", [])
] ]
+78 -20
View File
@@ -219,6 +219,13 @@ def _extract_requested_time(text: str) -> str | None:
# _extract_requesting_shell()'s docstring. # _extract_requesting_shell()'s docstring.
_REQUESTING_SHELL_RE = re.compile(r"Requesting\s+([A-Za-z]+?)\s*Shells?\b", re.IGNORECASE) _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) _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( _ON_OUR_POSITION_COORD_RE = re.compile(
rf"on\s+our\s+position\s+at\s+{_COORD_FRAGMENT}", re.IGNORECASE rf"on\s+our\s+position\s+at\s+{_COORD_FRAGMENT}", re.IGNORECASE
) )
@@ -368,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
@@ -608,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}")
@@ -699,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:
@@ -767,16 +800,36 @@ def parse_intel_blocks(text: str) -> list[dict]:
) )
shell = _extract_shell_request(joined) or _extract_requesting_shell(joined) shell = _extract_shell_request(joined) or _extract_requesting_shell(joined)
requested_time = _extract_requested_time(joined) or _extract_taking_fire_time(joined) requested_time = _extract_requested_time(joined) or _extract_taking_fire_time(joined)
# The offset variant's shell/deadline describe the FIRE POINT, # See _TAKING_FIRE_RE's own comment: overrides whatever
# not the reporting unit itself -- they move to the synthetic # _resolve_target_type would otherwise infer from type_word
# StrikeRequest entry below, not kept here too. # alone (no "Friendly"/"Hostile" prefix exists in this
current["shell"] = None if offset_coord is not None else shell # grammar to key off of) -- the reporting unit is always the
current["requested_time"] = None if offset_coord is not None else requested_time # 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)
if offset_coord is not None: # 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 # TargetType.STRIKE_REQUEST, not STRIKE: this is a
# friendly unit calling in a strike over the radio, not # friendly unit calling in a strike over the radio, not
# one the player placed themselves (see that type's own # one the player placed themselves (see that type's own
@@ -787,7 +840,7 @@ def parse_intel_blocks(text: str) -> list[dict]:
entries.append({ entries.append({
"kind": "named", "name": f"StrikeRequest#{strike_id}", "kind": "named", "name": f"StrikeRequest#{strike_id}",
"type_word": "StrikeRequest", "type_word": "StrikeRequest",
"id": strike_id, "raw": joined, "clues": [], "coord": offset_coord, "id": strike_id, "raw": joined, "clues": [], "coord": fire_coord,
"shell": shell, "requested_time": requested_time, "shell": shell, "requested_time": requested_time,
}) })
current = None current = None
@@ -866,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)
@@ -1130,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:
+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"
+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
+21 -17
View File
@@ -54,7 +54,11 @@ def test_clear_units_keeps_recon_infrastructure():
def test_ally_and_target_ids_are_independent_namespaces(): def test_ally_and_target_ids_are_independent_namespaces():
"""An ally Tank#1 and a hostile Target Tank#1 are unrelated -- adding """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 one must never be influenced by the other's ids, and auto-assignment
on each side starts from 'A' independently.""" 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() board = Board()
t1 = board.add_target(TargetType.TANK, _coord(), id_="1") t1 = board.add_target(TargetType.TANK, _coord(), id_="1")
a1 = board.add_ally(TargetType.TANK, _coord(), id_="1") a1 = board.add_ally(TargetType.TANK, _coord(), id_="1")
@@ -63,36 +67,36 @@ def test_ally_and_target_ids_are_independent_namespaces():
t_auto = board.add_target(TargetType.TANK, _coord()) t_auto = board.add_target(TargetType.TANK, _coord())
a_auto = board.add_ally(TargetType.TANK, _coord()) a_auto = board.add_ally(TargetType.TANK, _coord())
assert t_auto.id == "A" # first free letter among *targets* only assert t_auto.id == "A" # first free letter among *target* Tanks only
assert a_auto.id == "A" # first free letter among *allies* only, unaffected by the target above assert a_auto.id == "A" # first free letter among *ally* Tanks only, unaffected by the target above
def test_auto_id_is_shared_across_types_within_targets_and_within_allies(): def test_auto_id_is_per_type_within_targets_and_within_allies():
"""The id namespace split is targets-vs-allies ONLY -- different types """Each TYPE gets its own independent A/B/C... sequence within a group
within the same group (all targets, or all allies) share one A/B/C... (all targets, or all allies) -- a Tank and an Infantry auto-assigned
sequence, they do NOT each get their own independent sequence. A Tank back to back both start at 'A' (Tank#A, Infantry#A), rather than
and an Infantry auto-assigned back to back must get 'A' and 'B', never sharing one sequence across every type in the group."""
both 'A'."""
board = Board() board = Board()
tank = board.add_target(TargetType.TANK, _coord()) tank = board.add_target(TargetType.TANK, _coord())
infantry = board.add_target(TargetType.INFANTRY, _coord()) infantry = board.add_target(TargetType.INFANTRY, _coord())
assert tank.id == "A" assert tank.id == "A"
assert infantry.id == "B" # not 'A' again just because it's a different type 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_tank = board.add_ally(TargetType.TANK, _coord())
ally_infantry = board.add_ally(TargetType.INFANTRY, _coord()) ally_infantry = board.add_ally(TargetType.INFANTRY, _coord())
assert ally_tank.id == "A" assert ally_tank.id == "A"
assert ally_infantry.id == "B" assert ally_infantry.id == "A"
def test_auto_id_survives_past_26_entities_in_one_group(): 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 """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 -- used)` raises StopIteration the instant all 26 letters are taken --
reachable after accepting 26+ map-screenshot proposals into the same reachable after accepting/adding 26+ of the same type into one group
group (targets, or allies) in one session, since the fix making the in a single session. Must roll over to two-letter ids ('AA', 'AB',
id sequence shared across types (not per-type) made 26 much easier ...) instead of raising."""
to hit. Must roll over to two-letter ids ('AA', 'AB', ...) instead of
raising."""
board = Board() board = Board()
for _ in range(26): for _ in range(26):
board.add_target(TargetType.TANK, _coord()) board.add_target(TargetType.TANK, _coord())
+107 -18
View File
@@ -52,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
@@ -245,13 +282,29 @@ def test_infantry_taking_fire_direct_position_request():
requested'), deadline is a bare 'before <time>' with no 'Requested'/ requested'), deadline is a bare 'before <time>' with no 'Requested'/
dashes. The '<b>id1</b>' attacker mention is just prose here, not dashes. The '<b>id1</b>' attacker mention is just prose here, not
parsed into anything -- only the request itself (shell, position, parsed into anything -- only the request itself (shell, position,
deadline) matters.""" 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" 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> " "Requesting <u><b>SMK Shell</b></u> on our position at <b>J6 2:7</b> "
"before <u>10:38:57</u>!") "before <u>10:38:57</u>!")
info = ocr.parse_text(text) info = ocr.parse_text(text)
assert (TargetType.INFANTRY, "1") in info.targets
raw, clues, coord, shell, requested_time = info.targets[(TargetType.INFANTRY, "1")] 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 coord == Coord("J", 6, 2, 7)
assert shell is Shell.SMK assert shell is Shell.SMK
assert requested_time == "10:38:57" assert requested_time == "10:38:57"
@@ -262,8 +315,14 @@ def test_infantry_taking_fire_no_attacker_mention():
"Requesting <u><b>SMK Shell</b></u> on our position at <b>J6 2:5</b> " "Requesting <u><b>SMK Shell</b></u> on our position at <b>J6 2:5</b> "
"before <u>10:37:52</u>!") "before <u>10:37:52</u>!")
info = ocr.parse_text(text) info = ocr.parse_text(text)
assert (TargetType.INFANTRY, "3") in info.targets
raw, clues, coord, shell, requested_time = info.targets[(TargetType.INFANTRY, "3")] 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 coord == Coord("J", 6, 2, 5)
assert shell is Shell.SMK assert shell is Shell.SMK
assert requested_time == "10:37:52" assert requested_time == "10:37:52"
@@ -273,22 +332,23 @@ def test_infantry_taking_fire_bearing_distance_from_position():
"""The other request shape: the shell isn't wanted right on top of the """The other request shape: the shell isn't wanted right on top of the
reporting unit, but at a bearing/distance offset from its own reporting unit, but at a bearing/distance offset from its own
(inline-given) position -- two different places, so this becomes two (inline-given) position -- two different places, so this becomes two
entries: Infantry#3 stays at its own reported position (no shell/ entries: Infantry#3 stays at its own reported position, as an ALLY
deadline, it's not the fire point), and a separate synthetic Strike (see test_infantry_taking_fire_no_attacker_mention's own docstring --
entry carries the shell/deadline at the computed offset coord ('our same reasoning, this is still a taking-fire report), and a separate
position' isn't a named board entity to hang a Clue off of, so this synthetic Strike entry carries the shell/deadline at the computed
resolves straight to an absolute coord rather than via one).""" offset coord ('our position' isn't a named board entity to hang a
Clue off of, so this resolves straight to an absolute coord rather
than via one)."""
text = ("Infantry#3 taking fire!\n" text = ("Infantry#3 taking fire!\n"
"Requesting <u><b>HE Shell</b></u> at bearing <b>239°</b>, distance " "Requesting <u><b>HE Shell</b></u> at bearing <b>239°</b>, distance "
"<b>10.76km</b> from our position, <b>J6 2:5</b>, by <u>10:38:18</u> " "<b>10.76km</b> from our position, <b>J6 2:5</b>, by <u>10:38:18</u> "
"or we will be overrun!") "or we will be overrun!")
info = ocr.parse_text(text) info = ocr.parse_text(text)
assert (TargetType.INFANTRY, "3") in info.targets assert (TargetType.INFANTRY, "3") in info.allies
raw, clues, coord, shell, requested_time = info.targets[(TargetType.INFANTRY, "3")] assert (TargetType.INFANTRY, "3") not in info.targets
raw, clues, coord = info.allies[(TargetType.INFANTRY, "3")]
assert coord == Coord("J", 6, 2, 5) assert coord == Coord("J", 6, 2, 5)
assert shell is None
assert requested_time is None
assert (TargetType.STRIKE_REQUEST, "Infantry3") in info.targets assert (TargetType.STRIKE_REQUEST, "Infantry3") in info.targets
raw, clues, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry3")] raw, clues, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry3")]
@@ -313,14 +373,43 @@ def test_infantry_taking_fire_bearing_distance_short_range():
"or we will be overrun!") "or we will be overrun!")
info = ocr.parse_text(text) info = ocr.parse_text(text)
assert (TargetType.INFANTRY, "11") in info.targets assert (TargetType.INFANTRY, "11") in info.allies
_, _, coord, shell, requested_time = info.targets[(TargetType.INFANTRY, "11")] assert (TargetType.INFANTRY, "11") not in info.targets
_, _, coord = info.allies[(TargetType.INFANTRY, "11")]
assert coord == Coord("I", 7, 0, 8) assert coord == Coord("I", 7, 0, 8)
assert shell is None
assert requested_time is None
assert (TargetType.STRIKE_REQUEST, "Infantry11") in info.targets assert (TargetType.STRIKE_REQUEST, "Infantry11") in info.targets
_, _, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry11")] _, _, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry11")]
assert coord == Coord("H", 7, 8, 4) assert coord == Coord("H", 7, 8, 4)
assert shell is Shell.HE assert shell is Shell.HE
assert requested_time == "10:17:37" 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