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>
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>
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>
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>
rerender on assign/alive/shell, add Windows build tooling
- Board.add_target/add_ally's id auto-assignment used a bare
next(c for c in string.ascii_uppercase if c not in used), which
raises StopIteration once 26 entities of a group exist -- a real
crash confirmed via a live traceback, and a direct regression from
moving that sequence from per-type to per-group. This was the actual
cause of "Accept as"/"Accept all" silently doing nothing. Fixed with
_next_free_id(), which rolls over to two-letter ids instead of
raising.
- New TargetType.STRIKE_REQUEST: the bearing/distance-offset "taking
fire" fire-support request (see the earlier two-entity split) now
creates this instead of reusing STRIKE, so a radioed-in request is
never confused with a strike the player placed themselves. Same
crosshair icon, excluded from type pickers/dedupe like STRIKE.
- The "Accept as..." popover on a detected map marker now uses the
same icon grid the entity-edit "Change type" popover does (was a
plain unfiltered text list of every TargetType, which also wrongly
offered STRIKE/STRIKE_REQUEST as pickable).
- Firing panel: _cycle_assignment/_toggle_alive/_pick_shell no longer
route through app.py's full solver+dedupe+canvas+panel refresh --
none of the three can affect the solver or dedupe, and none change
which cards exist or their order (except _toggle_alive in
hide/sort_later mode). New FiringPanel._rebuild_one() rebuilds just
the one changed card; on_visual_change is a new, lighter callback
(just a map redraw) for the two of these three that actually affect
it. This was a real, confirmed lag source with many units on the
board: every click on any of these was previously rebuilding every
card of every target.
- Map right-click entity menu: added "Mark destroyed"/"Mark alive",
reusing the same cheap-refresh path (new
FiringPanel.refresh_after_alive_change).
- packaging/windows/: a from-scratch (untested against a real boot)
MSYS2 + WiX .msi build pipeline for Windows, driven from Linux via
dockur/windows (KVM-in-container), no Windows machine or GitHub
required. See its own README for status/caveats.
- New/updated tests: id-namespace sharing + the 26-entity overflow
regression (tests/test_models.py), StrikeRequest split
(tests/test_ocr.py), warp_to_map's img_scale param
(tests/test_map_vision_warp.py). 44/44 passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Board.clear() now also drops allies; the "clear board?" guard checks
allies too. New Board.clear_units() + Clear button right-click menu
("clear enemies, units & flights", keeps Nest/spotters/RPs).
- An Ally with the ad-hoc TargetType.ENEMY showed "Enemy" on the map
popover/toast instead of "Ally" (icons.target_type_label already had
the fix for the picker, now reused everywhere else via app.py's
_display_name).
- Firing panel drag-reorder no longer triggers a full app refresh
(solver + dedupe + map redraw) on every drop, just a local rebuild.
- "Always show geo" didn't draw for Allies (missing from the overlay
candidate list); blast radius only respected selection, not the
show_geo_desc pin.
- ocr.py: added a second fire-support-request grammar ("Infantry#N
taking fire ... Requesting X Shell on our position at <coord> before
<time>", plus a bearing/distance-from-position variant), distinct
from the existing Marine Garrison one.
- New debug_capture.py: saves screenshots (+ metadata) the app handled
badly, for later tuning of map_vision/ocr against real failures:
map-read errors, user grid corrections (paired with the auto-detected
grid), screenshots that read as text but may have been a map, and
marker-detection ground truth (every proposal's accept/reject verdict
plus units added with no matching proposal) captured whenever a
screenshot stops being the active one.
- README: Known issues section (map screenshot reading, grid + unit
detection, is unreliable and fails often).
- 14 new tests (tests/test_models.py, tests/test_debug_capture.py, +
additions to tests/test_ocr.py), 38/38 passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
map_vision.py: marker shape test only matched diamonds, so friendly
(rectangle) markers could never be detected regardless of color match.
diamonds() now takes a per-side ideal shape (diamond for hostile, full
rectangle for friendly) with fill-ratio bands measured off real markers.
Merged tests/fixtures/map_shots/more/ into the main fixture set: 2
screenshots that solve fine (now 15.png/16.png, with hand-transcribed
ground truth) and 8 that are too low native resolution for the label
reader (same class as the existing 12.png) into too_hard/ as
17.png-24.png, with an explanatory README entry. Updated map_vision.py's
docstring numbers (9/12 solve, 104/131 points correct) to match.
TargetType: expanded from 11 to 44 members to cover every icon in
assets/icons/targets/{enemy,friendly}/, including 7 friendly-only types
(King, Police, General, Hospital, Fort, Civil-Military, Mechanized
Anti-Tank) with no enemy equivalent. UNKNOWN/ENEMY stay icon-less by
design (both are literal words the game's OCR'd text uses, confirmed via
ocr.py's _TYPE_BY_SHORT, so neither can be dropped without breaking real
parsing) and draw the same plain-dot fallback the map itself uses.
icons.py: collapsed the icon lookup into one canonical table
(_TARGET_ICON: TargetType -> (enemy_basename, friendly_basename), one
explicit row per type) instead of a basename table plus two exception
dicts layered on top -- with a startup assertion that every TargetType
has a row. Added build_target_type_grid(), an icon-grid picker (icon +
name, same idea as the existing Shell picker) that replaces the old
plain-text dropdown/list everywhere a type is chosen, and only offers
types the given side actually has real art for.
coord_dialog.py: Add/Edit Target and Add Ally now use the icon grid
instead of Adw.ComboRow. Fixed a resulting horizontal-scroll bug (an
unbreakable long word was blowing out cell width) and locked the
coordinate pickers back to 5 columns.
app.py: right-click quick-add now offers Spotter/RP alongside
Target/Ally/Strike, opens a real modal (not a Popover, which turned out
unreliable for a wide multi-row grid) to ask for a type instead of
silently defaulting to UNKNOWN, and doesn't repeat the coordinate on
every row. The type grid listens for "clicked" rather than "toggled" --
a grouped ToggleButton doesn't emit "toggled" when you click the one
that's already active, which meant confirming the pre-selected default
type silently did nothing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reads a screenshot of the in-game map table and recovers where on the board
it is looking. The scene is a flat table under a perspective camera, so a
single homography describes grid<->screen exactly; that is fitted from line
evidence (LSD segments, vanishing-point RANSAC, 1-D lattice fits).
Line evidence alone cannot finish the job: the 1km cells and the 100m
subgrid are locally identical, so it fixes neither scale, axis assignment,
axis direction nor phase. Those come from the cell labels -- and the labels
are never *detected*, they are correlated where the grid says they must be
(9% in from a cell's left edge, 6% down from its top). Blob detection on
aerial-photo terrain finds texture, not glyphs; correlating a known template
at a known place has no such failure mode. The same score then ranks the
geometry hypotheses, since a wrong lattice puts the crop where no label is,
so one number resolves scale, axis assignment, direction, phase and anchor
together.
Markers are found by hostile/friendly colour plus an IoU test against an
ideal inscribed diamond, which cut a red-lit shot from 43 false positives to
2 while preserving every hand-verified count. Unit-type classification is
present but not yet reliable, and returns unknown rather than guessing.
Measured over the fixture set: 7 of 10 solve, each with 100% of its
ground-truth points in the correct cell (85/112 overall), residual spread
0.005-0.033 cells. The other three reject cleanly; none has ever produced a
plausible-but-wrong grid. Across 122 typewriter screenshots solve() accepted
none, which is what makes clipboard routing safe.
Fixtures: 10 map shots with hand-transcribed ground truth, plus 10
typewriter shots spanning 262-5366px for routing and future OCR tests. Map
shots wider than 2400px (the pipeline's own maximum working width) were
downscaled with their coordinates rescaled to match; re-measured afterwards,
the results are identical. Typewriter shots stay at native resolution
because OCR needs the text legible.
Drops docs/map_vision_plan.md and its WIP prototype: the design now lives in
the module docstring, next to the code it describes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A real, reproducible bug reported as 'misplaced sometimes by a few
small squares': Coord.as_fraction() centers a sub-cell at x + 0.5 (so
a marker drawn at its own coord's exact pixel position round-trips
back to the same coord on click), which meant point_to_coord()'s own
rounding was landing exactly on a .5 boundary, the single worst case
for floating point, tiny representation error from the col/row math
upstream could tip round() to either side and silently return a coord
one sub-cell off from the one actually clicked.
Reproduced with zero pixel math involved at all, just feeding
Coord(...).as_fraction() straight back into point_to_coord(), ruling
out the zoom/pan refactor or the legend margin as the cause (both were
suspected first). Fixed by subtracting the 0.5 offset before rounding,
which recovers a value that's supposed to be an exact integer instead
of an exact half-integer, round() is robust to tiny float noise around
a true integer, just not around X.5.
Verified exhaustively (all 20,000 possible coordinates round-trip
correctly now, not just a handful of samples, since the original bug
was itself float-pattern-dependent) and locked in with a permanent
regression test.
Two related changes:
1. TargetType.HOSTILE_ARTILLERY/HOSTILE_TANK renamed to ARTILLERY/TANK,
dropping the baked-in hostility assumption from the type name
itself (a type describes the unit kind now, not an allegiance).
Migration entries added for both, plus the already-existing
COASTAL_BATTERY one, so old save files still load.
2. A friendly contact ('FriendlyTank#1:', detected by stripping a
leading 'Friendly'/'Hostile' word off the type word before matching
it, see ocr.py's _resolve_target_type()) is NOT a Target with a flag
flipped, it's tracked as a new, entirely separate Ally
(Board.allies), with its own id namespace: an ally Tank#1 and a
hostile Target Tank#1 are two unrelated things that happen to share
an id, not a collision (verified directly, see the rendered
screenshot both coexisting). Ally intentionally has none of Target's
firing-relevant fields (shell/powder_charges/assignment/alive),
allies are never fired on. 'Hostile' and no prefix at all both mean
a regular (non-ally) Target, not-ally is the default.
Wired through: Board.add_ally/remove_ally, placed_entities_all()/
ambiguous_entities_all() (new 'ally' category, cyan on the map,
distinct from every other category's color), solver.resolve_board()
(allies' own clues resolve too), find_by_name() (an ally can be a
clue reference target), save/load round-trip, a new 'Allies' header
popover mirroring Targets' (position/hide/geo-overlay/remove, no
shell/charge/alive controls), and ParsedInfo.allies as a same-shaped
but separate dict from ParsedInfo.targets, merged by a new
_merge_allies() alongside _merge_targets() in _merge_all().
Verified: full test suite (added a dedicated OCR test for the Friendly/
Hostile/bare-prefix routing), a GTK smoke test round-tripping an ally
through save/load and the popover build, and a rendered screenshot
showing an ally Tank#1 and a hostile Target Tank#1 both on the map at
once with the same id, distinct colors, no collision.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same pattern already established for AMMO_CACHE -> SUPPLY_CACHE: a
'Coastal Battery' is just a heavy fixed-emplacement HostileArtillery
under a different name in the game's own text, not a meaningfully
different unit type worth its own enum member. Removed the
TargetType.COASTAL_BATTERY member, added 'CoastalBattery' to
ocr.py's _TYPE_WORD_ALIASES (so 'Coastal Battery#2:' still parses,
now as a HostileArtillery), and a models.py migration entry so any
save file written before this change still loads correctly.
Verified the migration directly (COASTAL_BATTERY -> HOSTILE_ARTILLERY,
existing AMMO_CACHE -> SUPPLY_CACHE unaffected) and updated the one
test that asserted the old type.
Latent inconsistency flagged during the ocr.py refactor: solve_location()
already excludes a compass-word bearing (bearing_tolerance_deg set) from
its own bearings list before triangulating, since a toleranced bearing
names a sector, not a precise ray, and shouldn't be treated as if it
were one. explain_unresolved()'s bearings list didn't have the same
exclusion, so a toleranced-only bearing plus a distance clue could get
described as inconsistent geometry ('the bearing from X never crosses
the Ykm circle...') when the real reason nothing resolved is just that
the bearing was never usable for that math in the first place. Locked
in with a new test.
No test suite existed before this, which is exactly how a real
regression (the bold-span coordinate-squashing bug, and the
'Type#id:'/'<ref>: <value>' header collision, both from this session)
went unnoticed until manually re-triggered. One test per format,
cross-referenced against the full commit history so nothing already
shipped gets silently dropped by a future change:
tests/test_ocr.py: standard blocks, the calibration target line,
destruction reports (digit and letter id), train-arrival intel,
ad-hoc Enemy installations (+ their destroyed reports), Listening
Post/Coastal Battery, Marine Garrison fire-support requests, multi-
word RP names, bare-name-header targets, the bold-span coordinate-
squashing regression specifically, forward-observer report
triangulation, the '<ref>: <value>' clue grammar (+ its header-
collision regression specifically), 16-point compass tolerance, and
grid-only coordinates.
tests/test_solver.py: direct bearing+distance resolution, two-bearing
triangulation, genuine two-distance ambiguity, the nested-circles
compromise-point fallback, toleranced bearings never being used to
triangulate, and manual coord overrides clearing a stale note.
Runs via ============================= test session starts ==============================
platform linux -- Python 3.14.6, pytest-8.4.2, pluggy-1.6.0
rootdir: /home/dodox/Projects/FeNigma
configfile: pytest.ini
plugins: anyio-4.13.0
collected 21 items
tests/test_ocr.py ............... [ 71%]
tests/test_solver.py ...... [100%]
============================== 21 passed in 0.72s ============================== (pythonpath configured in pytest.ini), dev-only
dependency in requirements-dev.txt so the app itself stays
dependency-light. Documented in the README.