Commit Graph

17 Commits

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 17:35:37 +02:00
3c80f93203 Import map screenshots into the board, and edit entities from the map
The existing clipboard button now routes images: a map-table shot goes to the
vision pipeline, anything else to the text OCR path as before. The decision
runs in a worker, since even the cheap pre-filter costs ~0.3s and solve()
takes 10-20s. solve() rejecting counts as "not a map" and falls through to
OCR, because it is the reliable verdict (0 false positives over 122 text
screenshots) where the pre-filter lets ~6% through; reporting a failure there
would mean a text screenshot never got read at all.

Grid first, units second. The one modal confirms or fixes the geometry only:
the screenshot with the reconstructed lattice drawn over it, plus four
draggable handles on one cell's corners. Four corners pin a homography
exactly (8 DOF, 2 equations each), and dragging any of them refits the whole
grid live. Detection deliberately does not run until this is accepted --
every unit position is expressed in grid coordinates, so detecting against a
grid about to be dragged would only be thrown away.

Once accepted the screenshot is rectified into board space and drawn as the
map's backdrop. Pre-warping is what makes it drawable at all: cairo has no
projective transform, but a rectified image places with a plain scale and
translate. Detected units then appear as proposals ON the map, drawn hollow
-- the same shape the map already uses for "this might be where it is", which
is exactly what a proposal is. Clicking one offers accept (with the detected
type or a corrected one) or reject; the header gains accept-all and
remove-screenshot, and removing the screenshot drops every proposal never
accepted, since they were only ever readings of it.

Separately, right-clicking any entity now opens an edit menu: change type,
change id, change position, delete. Which actions appear follows what the
entity actually has -- only Target/Ally carry a TargetType, Spotter's id is
an int, and the Nest is singular so it cannot be deleted. Changing an
existing target's type or id had no UI at all before this.

Also fixes warp_to_map, which composed only the lattice homography and
dropped the discrete (si,sj,du,dv) mapping that pins lattice indices to named
cells, so every automatically solved screenshot landed in the wrong place. It
happened to test fine because manual solutions have an identity mapping.
While there, the same routine had an off-by-one for a negative axis sign
(si*u+du runs from col+1 down to col across a cell, so floor() named the
neighbour); both now go through one shared GridSolution.grid_of.

Verified end to end through the real widgets on a fixture: grid phase yields
no proposals, four handles, a drag refits and still names cells correctly,
reset restores, a degenerate drag survives, accept warps to a 2000x1000
overlay, detection then yields proposals that hit-test, accept and reject
correctly, and removing the screenshot keeps accepted units only.

Completes the FEnigma rename in app.py (APP_ID, window title, class).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 21:41:56 +02:00
084764aa9b Map: follow the app's light/dark color scheme, live
The rest of the UI already adapted to system theme automatically via
libadwaita, only the hand-drawn Cairo map (grid lines, markers,
overlays, everything in grid_widget.py) was hardcoded to the dark
palette. Added a parallel light palette (first-pass guesses, same as
the Shell descriptions were, flagged for correction) and hooked
Adw.StyleManager's dark/light state, including its own live-update
signal, so switching the system theme while the app is running
repaints the map with the other palette immediately, not just at
startup.

Verified: rendered both palettes side by side with the same board
state (legible in both), and a live theme-switch test confirming the
module-level color names actually change value when the StyleManager
signal fires, not just once at construction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 21:18:47 +02:00
fcf29ad574 Map: highlight the X/Y legend for the cursor's cell, fix subgrid color
- Column/row legend labels: the one matching the hovered cell gets an
  underline and a blue accent color (not yellow, which was already the
  overlay/placement-preview color elsewhere on the map and would have
  meant two unrelated things looked the same).
- SUBGRID_LINE (the fine per-cell lines added earlier) was accidentally
  given a distinct blue hue on the first attempt at fixing its
  visibility, overcorrecting: the actual ask was a shade sitting
  between the background and the main grid line's white, not a
  different color altogether. Now a neutral warm gray instead.

Verified with a rendered screenshot showing both together: the K/6
legend in blue with an underline, the subgrid a visibly distinct but
still neutral gray, not competing hues on the same map.
2026-08-09 20:52:57 +02:00
558e9d117a Map: draw the fine 10x10 subdivision grid for the cell the cursor is in
Tracks the hovered large cell (col, row) separately from marker
hit-testing, since it needs to update on any cursor motion, not just
when crossing a marker's hit radius, redrawn only when the actual cell
changes, not on every pixel of motion within the same one. Lines drawn
inside the existing clip region so they never bleed past the visible
viewport at any zoom/pan state, dimmer than the main grid lines
(0.6x alpha, tuned down further after an initial too-loud pass) so
they read as a subtle aid, not competing visual noise.

Verified with a rendered screenshot: the subdivision lines appear only
within the hovered cell, correctly clipped, and stay faint against the
main grid.
2026-08-09 20:48:48 +02:00
e352dff531 Map: render entity game icons instead of plain dots once zoomed in
Below ICON_MIN_CELL_PX (42px cell width) everything still draws as the
plain colored dot it always has, a game icon at that size would just
be an illegible smudge. Past that threshold, Nest/Target/Ally markers
switch to the game's own unit icon for their type, drawn as a Cairo
surface (raw Cairo draw_func, not GTK widgets, so this loads PNGs
directly via cairo.ImageSurface.create_from_png(), cached per path so
a repeat draw doesn't re-hit disk).

icons.py's target_icon_path(type, is_ally) is the TargetType -> icon
file mapping, best-effort guesses the same way the Shell descriptions
were (flagged for correction): most types map onto the game's own
Enemy_*/Friendly_* unit icons (is_ally picks which set, falling back
to Enemy_ if a given type has no Friendly_ counterpart), TANK reuses
the Armor_Mechanized artwork (no dedicated tank icon exists), and
STRIKE gets its own crosshair (assets/icons/misc/Crosshair.png, not a
unit icon at all, a planned impact point) rather than a unit icon.

An ambiguous candidate (hollow, dashed-ring marker) never switches to
the icon regardless of zoom, an icon there would look more confident
about an unconfirmed position than the dashed ring is supposed to
convey.

Verified with rendered screenshots at both zoom levels: plain dots
below the threshold, real icons above it (Tank/Infantry/MarineGarrison/
Nest all confirmed showing their correct icons), and the Strike
crosshair specifically.
2026-08-09 20:45:15 +02:00
3955fa42c7 Drop TargetType.HOSTILE_*, track allies as their own Ally collection
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>
2026-08-09 20:23:42 +02:00
57e73c58ea Fix real zoom+square-cells bugs, add drag-to-pan, simplify popover buttons
Every fix here was verified directly (numeric checks against the view
math, rendered screenshots, GTK smoke tests), not just reasoned about,
after the previous zoom/square pass shipped with real problems still
in it.

Map fixes:
- Off-viewport entities bled through onto the canvas when zoomed in:
  _km_to_px() has no inherent bound, an entity actually elsewhere on
  the map still got projected and drawn if the result happened to land
  within the canvas's pixel bounds. Clipped all marker/overlay/arrow/
  scout-flight drawing to the grid's own visible rectangle.
- Gridlines themselves bled into the label margin above/left of the
  grid: the line-drawing loop deliberately over-generated a couple of
  lines past the true viewport edge (meant to cover a trailing partial
  cell, which doesn't need its own line, just the whole-integer lines
  already bounding it). Switched to ceil/floor bounds that only ever
  produce in-range lines, verified directly against the view math for
  a fractional zoom/pan (no clip needed for this half, the bug was
  generating the wrong lines in the first place, not failing to hide
  them).
- Square-cell letterbox padding stayed pixel-identical at every zoom
  level (verified: same padding at 1x/2x/5x), which meant the fixed
  bands ate a bigger and bigger share of an already-zoomed-in view.
  Once actually zoomed in with square_cells on, the viewport's own
  aspect now follows the canvas shape instead of staying locked to the
  full map's 20:10 ratio, so cells come out square with zero
  letterboxing rather than fixed padding. The whole-map view (zoom=1)
  is unchanged, that's the one case that has a real reason to keep the
  fixed 20:10 shape.
- Added drag-to-pan, active once actually zoomed in (at 1x the whole
  map's already on screen, nothing to pan to). Guards against a drag's
  release also firing as a click-select via a small pixel threshold.

Popover cleanup (Nest/Spotters/Reference Points/Targets):
- Removed each popover's own 'Load all from screenshot' bulk button
  and every row's individual 'set this one from screenshot' button:
  the universal clipboard button in the header already re-parses a
  fresh screenshot/paste and merges it into everything it recognizes
  by name, these were redundant category- and item-specific ways to
  trigger the exact same merge. Deleted the now-dead
  _set_*_from_screenshot_info() methods along with them.
- The trailing 'Add spotter'/'Add RP'/'Add target'/'Add scout flight'
  rows are now a single full-width button (new _add_row() helper)
  instead of the full two-button _row() layout, there's nothing to
  screenshot into for something that doesn't exist yet.

Verified: full pytest suite, direct numeric check that no gridline
lands outside the grid rectangle at a fractional zoom/pan, a rendered
screenshot at 4x zoom confirming off-viewport entities no longer
appear and gridlines terminate cleanly at the edges, and a full-app
smoke test exercising drag-pan end to end including the post-drag
click-suppression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 20:04:55 +02:00
e585fc5428 Map: square-cell toggle and scroll-wheel zoom
Refactored GridCanvas's geometry around a single _View namedtuple
(cell_w/cell_h/grid_w/grid_h/pad_x/pad_y/viewport origin/visible
extent) instead of threading cell_w/cell_h/grid_h separately through
every draw and hit-test method, that's what makes the two new features
below tractable without a parameter explosion.

- Square-cell toggle (header button, 'view-grid-symbolic'): forces
  cell_w == cell_h, letterboxing (padding) whichever axis has leftover
  space instead of stretching cells to fill the widget. Off by default,
  recovers the exact previous stretch-to-fill behavior.
- Scroll-wheel zoom: 1x (the whole 20x10 map, the old fixed behavior)
  up to 10x, anchored at the cursor's last known position so the km
  point under it stays under it as the zoom level changes, panned/
  clamped so the viewport never hangs off the grid's edge. Grid lines,
  column/row labels, and every marker only draw for the visible
  viewport, not always the full 20x10 grid.

Also two bugs found and fixed along the way:
- The header's cursor-location readout showed AZ/distance-from-nest
  numbers even when the cursor was off the map entirely:
  bearing_deg_point()/distance_km_point() are happy to compute on any
  raw km point, on- map or not, only the coord label itself checked
  bounds. Now the whole readout is just 'off map' whenever the cursor
  genuinely isn't over the grid.
- That bounds check initially reused solver.point_to_coord()'s own
  tolerance, which deliberately forgives up to 0.5km past an edge
  (rounding slop for noisy OCR'd coordinates), the wrong call for 'is
  the mouse over the map', a cursor visibly off the drawn grid still
  passed it. The cursor readout now uses a strict 0<=col<=COLS/
  0<=row<=ROWS check instead.

Verified with GTK smoke tests: zoom in/out and pan-anchoring math,
square-cell letterboxing padding, hover/hit-testing, the toggle wired
end-to-end through the real header button, and the full app launching
and surviving the exact scenario that crashed it earlier in this same
session (an incomplete mid-refactor commit referenced _on_scroll before
it was defined, caught immediately by re-running the app, fixed by
finishing the refactor properly instead of patching around it).

Also, on the firing card: swapped the shell icon to come after the
powder-charge segments instead of before (per feedback), and gave the
assignment cycle button (L/R/-) the same 'image-button' style class its
icon-only siblings get automatically, it was visibly wider than them
for carrying a text label instead of an icon.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 19:43:35 +02:00
08d046536e Fix cursor readout showing AZ/dist for points that are actually off the map
_on_cursor_move() only checked map bounds for the coord label itself
('off map' vs a real grid ref), the AZ/distance-from-nest numbers next
to it were computed and shown regardless, bearing_deg_point()/
distance_km_point() are happy to run on any raw km point, negative or
past the 20x10 grid included, so a cursor genuinely off the map could
still show what looked like a real reading. Now the whole label is
just 'off map', no numbers, whenever point_to_coord() says there's no
real grid position there.
2026-08-09 19:32:39 +02:00
9c7588eb12 OCR: 'ref: value' clue grammar, 16-point compass, grid-only coords, wedge overlay
New shapes, all genuinely new features (checked full git history, none
of this ever existed before):

- '<ref>: <value>' clue grammar (Spotter#2: 4.04km / Spotter#3: 298deg
  / Spotter#1: West), no keyword, no 'from', reference comes first.
  This collided hard with the existing 'Type#id:' header shape,
  'Spotter#2: 4.04km' is structurally identical to a real header like
  'AmmoCache#3:', so it was hijacking the block before any clues could
  attach to the entity above it. Fixed by checking whether a would-be
  header's trailing content is itself just a bare clue reading with
  nothing else (_BARE_CLUE_VALUE_RE); a real header's never is.

- 16-point compass words ('North Northwest'), alongside the existing
  8-point ones, longest-alternative-first in the regex so the compound
  form doesn't get cut off at the bare first word.

- A compass word names a whole sector, not a single ray, so a clue
  built from one now carries a bearing_tolerance_deg (11.25deg, half a
  16-point sector) and solve_location() deliberately never tries to
  triangulate it into an exact point, precise math on an imprecise
  reading would misrepresent the confidence. The map draws it as a
  wedge (two bounding rays + fill) instead of a single ray.

- 'Reported active in grid D10': large-grid-cell-only, no sub-grid x:y
  at all, defaults to the cell's rough middle (5:5).

Verified against the exact reported example end to end (parse ->
solver correctly resolving what it can and leaving the rest
unresolved -> map draw with the wedge overlay) plus the full existing
regression sweep across every previously-added format.
2026-08-09 18:15:27 +02:00
202219abf2 OCR: parse fire-support requests ('Marine Garrison#1 pinned!')
New shape: '<Name>#<id> pinned!' followed by '<Shell> Shells requested
on <coord>' and 'Requested before - <T-time> -'. Different from every
existing coord shape (no 'Grid' keyword), so it needed its own
extractor (_extract_requested_on_coord), plus new ones for the shell
code and the deadline string. New TargetType.MARINE_GARRISON, its
multi-word name + real digit id already works through the existing
squash_multiword_ids() pipeline (same as Coastal Battery/Listening
Post) with no new header regex needed.

Target gained a requested_time field (raw string, this app doesn't
track a game clock to compare it against), persisted through
save/load and shown on the map below the coord label. The requested
shell sets target.shell directly rather than staying a suggestion,
matching how a manually-picked shell already works.

info.targets' value tuple grew from 3 to 5 elements (raw, clues,
coord, shell, requested_time); updated both call sites in app.py
that unpack it. Verified end to end: parse -> merge -> save/load
round trip -> map draw.
2026-08-09 16:20:55 +02:00
a522963855 Draw geo overlay for an unresolved-but-selected target too
Previously _draw_geo_overlays() only ever looked at
placed_entities()/ambiguous_entities(), so a target that failed to
resolve entirely (no coord, no potential_coords, e.g. two clues that
don't quite geometrically agree) never got its bearing/distance lines
drawn no matter what, there was no way to see why on the map itself.

Selection is now also a trigger alongside hover/show_geo_desc
(matching how firing arrows and blast radius already key off
selection), and the candidate list comes from board.reference_points
+ board.targets directly rather than the resolved-only views, so an
unresolved selection still shows its clue geometry. Lets you eyeball
whether a bad reading is plain wrong or just off by a bit, bearing
readings apparently carry some real-world error margin.
2026-08-09 12:46:01 +02:00
b206b382fc Map: show scout flight's anchor grid square and bearing under its name 2026-08-08 23:43:39 +02:00
186e2c4d6d Add scout flight planning: click-to-place rectangle, new dropdown category
A ScoutFlight anchors to the center of whatever large grid square the
cursor is in, with its bearing read off where in that square the
cursor actually sits, decoupling a clean anchor point from fine
direction control. The plotted rectangle extends 0.92km back and
13.04km forward along that bearing, 1.21km to each side
(solver.scout_flight_corners), previewed live while placing (see
GridCanvas.start_scout_flight_placement) and drawn as a filled
rectangle once committed.

New 'Scout Flights' header dropdown lists them with replot/hide/remove
per entry, plus a header button to plan a new one. Wired into
Board.clear() and save/load (SAVE_FORMAT_VERSION bumped to 3, old
saves load fine via the new key's default).
2026-08-08 23:32:54 +02:00
ba6b07a476 Sweep em-dashes out of code, docstrings, and requirements.txt
Same style fix applied to the README earlier, extended everywhere:
replaced " -- " with commas/colons/periods (picking whichever reads
right per occurrence, splitting into two sentences where the clauses
were independent), fixed a few user-facing strings along the way
(entity list rows, placement/strike toasts, ambiguous-candidate tag,
shell picker button label). Left three intentional non-prose uses
alone: the "unassigned" dash glyph in firing_panel.py (and its
docstring diagram), and ocr.py's dash-variant regex character class,
which needs to literally match em/en-dashes in OCR'd text.

Also caught and fixed a stale models.py docstring claiming "no solver
yet" (solver.py has existed for a while) while touching that
paragraph anyway, and a formatting artifact in coord_dialog.py's
docstring left by the sed pass (misaligned comma from a since-removed
alignment gap).

Verified: py_compile across all files, the 9-screenshot OCR
regression sweep, and a GTK smoke test exercising the edited
toast/placement code paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 20:27:53 +02:00
7ecda4ea3b Rename package/app to FeNigma consistently
- src/ironnest_assist/ -> src/fenigma/
- run.sh invokes -m fenigma.app
- APP_ID: eu.dominik-roth.IronNestAssist -> eu.dominik-roth.FeNigma
- window title, IronNestApp class -> FeNigmaApp
- __init__.py docstring, README pgrep hint updated

IRON NEST (the game's own name, e.g. NEST_KEYWORD in ocr.py) is left
untouched, only our own project/app naming changed. Verified: clean
import under the new module path, install.sh still builds the venv
correctly, and the OCR regression sweep (9 known screenshots) still
passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 20:20:43 +02:00