Commit Graph

26 Commits

Author SHA1 Message Date
3b209e743e Shell icon picker: fix real sizing bugs, crop icon padding, inline grid for Add Strike
Several rounds of actually looking at the running app instead of just
the code, in order:

- The icon-only shell button on a firing card, and the popover-based
  picker, both fought a genuine GTK sizing quirk: Gtk.Picture's natural
  size is the source image's full native resolution (512x256) no
  matter what set_size_request() says, so the button's own requested
  size swung unpredictably depending on layout context (sometimes
  comically wide, sometimes squeezed tiny). Fixed by pre-scaling the
  actual pixel data with GdkPixbuf first (preserving real aspect ratio)
  and wrapping the already-correctly-sized result, nothing left for
  the surrounding layout to guess about.
- The shell icon PNGs themselves carry a lot of built-in transparent
  padding around the actual artwork. Cropped all of them (except MSTD,
  see below) to the same shared bounding box, computed as the union of
  every file's real content so nothing gets clipped and every icon
  stays aligned the same way.
- MSTD (mustard/YX blister agent) removed from the Shell enum entirely:
  its own game icon is stamped 'UNAVAILABLE IN DEMO', it isn't actually
  a real selectable shell in the game.
- Extra empty chrome around icon-only buttons turned out to be GTK CSS
  specificity, not a missing property: GTK's CSS has no !important at
  all (confirmed, its parser rejects it outright), and separately,
  Gtk.MenuButton's CSS node is literally named 'menubutton', not
  'button', so a 'button.<class>' selector silently matched zero
  elements on it. The actual leftover padding lives on MenuButton's
  internal child button node, reached with a child-combinator selector
  ('menubutton.<class> > button').
- The Add Strike dialog's shell picker is now an inline radio-style
  grid (build_shell_grid(), Gtk.ToggleButton.set_group()) instead of a
  button that opens a submenu popover: it's a 'pick one before
  proceeding' dialog with room to just show every option up front,
  hiding them behind an extra click didn't earn anything. The firing
  card and its tighter row still use the popover version.
- Grid is 3 columns (was 4), sized bigger now that padding is real
  content, not just empty canvas.

Also two unrelated but real bugs found and fixed while chasing this,
from actually reproducing what screenshots showed instead of taking
them as pure cosmetic complaints:
- Selecting a firing card shifted every icon/label inside it inward by
  2px: only .firing-card-selected had a border, unselected cards had
  none at all, so gaining a border on selection shifted the box's
  content instead of just changing a color. Border is now reserved at
  the same width on every card, always, selection only changes its
  color from transparent.
- Changing a target's powder charge visibly shifted the whole card:
  the charge segment buttons had no valign, so once the row got taller
  (the bigger shell icon next to them), they silently inherited the
  default FILL alignment and stretched into ovals instead of staying a
  fixed circle. They also relied on plain 'flat'/'suggested-action' for
  their off/on look, which draws no border at all when off, making an
  unselected segment invisible against the card background. Both fixed
  with a dedicated fixed-size, always-outlined CSS class and explicit
  valign/halign=CENTER.
- The AZ column's screen position depended on the ELEV value's digit
  count (elevation recalculates per charge picked), fixed with a
  reserved width_chars on the elevation label.

Verified at each step by actually rendering the affected widget/dialog/
card to a PNG and looking at it (including a real FiringPanel card
before/after a charge change, pixel-diffed to confirm nothing but the
intended fill state moved), not by reasoning about the code alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 19:28:41 +02:00
a8f77b98cb Fix the shell icon picker: real icon size, grid layout, no horizontal scroll
Three real bugs from the first pass, all from actually looking at the
running app instead of just the code:

- Icons rendered tiny regardless of source resolution: Gtk.Image caps
  displayed size to GTK's icon-size classes (built for symbolic
  16/32px icons) no matter what you load into it. Switched to
  Gtk.Picture, which sizes by the image's real dimensions.
- The popover's plain vertical list of rows squeezed down to a near-
  unreadable width: ScrolledWindow sizes to its content's minimum, not
  natural, size unless told otherwise, and a wrapping description
  label's minimum width can shrink to almost nothing. Replaced with a
  FlowBox grid (icon + blast radius under it, full description as a
  tooltip) with an explicit natural width.
- That grid's column count was pinned to a fixed minimum, so a
  popover that didn't have the room for that many columns overflowed
  sideways and grew a horizontal scrollbar. min_children_per_line
  dropped to 1 (lets it reflow to fewer columns instead) plus
  hscrollbar_policy=NEVER as a hard backstop, not everyone has a
  horizontal scroll wheel.

Also: the firing card's shell button now shows the icon alone (it
already has the shell's short code baked in, a text label next to it
was redundant on an already-tight row), sized to 64px wide/32px tall
to actually be legible, and a new small CSS rule trims the excess
button chrome around an icon-only face so the button isn't visibly
much larger than the icon it holds.
2026-08-09 19:06:56 +02:00
2b73a96c7a Replace the plain-text shell dropdown/list with an icon picker
Both places the app asks for a shell (the Add Strike dialog's
Adw.ComboRow, and the firing panel's per-target shell popover) used to
show just the bare enum name in a plain list, nothing conveying what
the shell actually is. New icons.py builds a shared MenuButton +
popover from the game's own shell icon set (assets/icons/shells/,
already named to match Shell.name exactly): each row shows the shell's
icon, name, description, and blast radius, and the button face updates
to match whichever one gets picked.

Verified via a GTK smoke test that the button and its popover build and
open without crashing.
2026-08-09 18:59:50 +02:00
fea7c0dad2 Shell: TEAR has a 0.85km blast radius
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 18:36:39 +02:00
21c6553784 Fix explain_unresolved() not excluding toleranced bearings like solve_location() does
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.
2026-08-09 18:28:48 +02:00
ddb7867a88 Refactor ocr.py: dedupe the coordinate/type-id regex fragments
ocr.py grew through many incremental patches and had accumulated the
same two shapes copy-pasted with minor variation across the file:

- The full coordinate shape ('H3 5:5', letter + big-cell number + sub-
  grid x:y) was hand-restated as a regex literal in six places
  (_COORD_RE, _GRID_COORD_RE, _REQUESTED_ON_COORD_RE, the FO-report
  patterns, _NAMED_AT_COORD_RE, _STATION_LINE_RE), each pairing its own
  copy with its own try/except Coord(...) construction. Factored into
  one _COORD_FRAGMENT regex piece and one _coord_from_groups() helper,
  every call site now just embeds/calls it.
- The '<word><junk><digits>' type-id shape ('AmmoCache#3') was
  similarly restated across _NAMED_HEADER_RE, _REF_NAMED_RE, and
  _DESTROYED_RE. Factored into _TYPE_ID_FRAGMENT.
- Dropped _SEP, an unused leftover regex fragment.

No parsing behavior changed: every format ocr.py understands (standard
target/RP blocks, calibration line, destroyed reports, train-arrival
intel, ad-hoc Enemy installations and their destroyed reports,
Listening Post/Coastal Battery, Marine Garrison fire-support requests,
multi-word RP names, bare-name-header targets, bold-span coordinate
squashing, FO-report triangulation, the '<ref>: <value>' clue grammar,
16-point compass tolerance, grid-only coords) is still covered by the
full test suite, all 21 tests pass unchanged, plus a direct re-run of
the exact 'Enemy#name' example that prompted this session's bug report
to confirm detection by name still works.
2026-08-09 18:26:06 +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
90a26b3f85 shells: add LE/APHE/EQKE (mirroring AP/HE/HCHE blast radii) and FLCH (0.6km)
Also dropped the docstring's shell-count/standard-ratio stats, they'd
just go stale again the next time this list changes.
2026-08-09 17:55:56 +02:00
c273f57435 OCR: multi-word RP names, bare-name targets, FO-report triangulation
Three related fixes/additions, found together while working through a
batch of new intel formats:

1. squash_span_content() only handled the 'Enemy X Y' and 'Type#N'
   shapes, so a plain multi-word bold name ('The Mole', "Dockmaster's
   House") passed through untouched and got silently truncated at the
   first space by every downstream single-token assumption
   (_RP_HEADER_RE, the (\S+) clue-reference capture). Added a generic
   fallback: collapse any multi-word bold span into one alphanumeric
   token, UNLESS any word contains a digit, that's very likely a
   coordinate span ('C9 7:9') instead of a name, and squashing THAT the
   same way corrupted it into garbage ('C979') rather than a name, a
   real regression caught immediately by testing against nest/spotter
   parsing before committing.

2. A bare '<Name>:' header (no 'Reference Point'/'Enemy' keyword, no
   digit id, e.g. 'HMS Rockingham:') was previously dropped entirely,
   nothing recognized it at all. Added _BARE_NAME_HEADER_RE as the
   last-resort header check (colon required, not optional like every
   other header regex, nothing else anchors this match). Resolves to a
   Target (TargetType.UNKNOWN), not a Reference Point, a named thing
   giving its own clues is being spotted, not a fixed landmark.

3. Forward-observer reports ('FO#5 Audio report on HMS Rockingham:
   2.24km From I8 6:9'): each FO's position is a literal one-off
   coordinate, not a name referencing some known entity, and isn't
   meant to be tracked as a real board entity. parse_fo_reports()
   triangulates immediately using a throwaway scratch Board (reusing
   solve_location()'s exact geometry/priority) and keeps only the
   resulting coordinate, discarding every ephemeral FO position
   afterward, nothing leaks into the real board.

Also added a 'convert to Target/Reference Point' action (RP and
Target rows both), since bare-name-header classification is a guess
that can land in the wrong bucket, this fixes it without losing the
position/clues already worked out.

Verified against the exact reported examples plus the full existing
regression sweep (standard blocks, Enemy names, Listening
Post/Coastal Battery, Marine Garrison, nest/spotter parsing) and
through the real GTK merge flow.
2026-08-09 16:51:30 +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
eedea3ea19 OCR: fix destruction reports for ad-hoc 'Enemy X Y' installations
_DESTROYED_RE requires a digit-shaped id ('SupplyCache#2 Destroyed'),
but an Enemy installation's id (after squash_enemy_names()) is
letters ('Enemy#SignalStation'), so 'Enemy Signal Station Destroyed'
silently matched nothing and never marked the target dead. Same
digit-vs-letter split every other Enemy-aware regex in this file
already needed, just missed here. Added _ENEMY_DESTROYED_RE alongside
it, verified through the real _merge_targets() flow for both an
already-known target and one whose destruction is the first mention
of it at all.
2026-08-09 15:50:55 +02:00
19e9ffdd68 Fix firing panel scrollbar layout jitter and strike reorder lock-in
Scrollbar: the ScrolledWindow left horizontal policy on AUTOMATIC (the
default), so a vertical scrollbar's own width could shrink the
content area enough to trigger a horizontal scrollbar too, perturbing
card heights and tripping vertical scrolling that wasn't actually
needed. Pinned horizontal off outright, this is a fixed-width sidebar,
never wanted anyway.

Reorder: the card sort key forced every Strike above every non-Strike
on *every* refresh, not just at creation, silently undoing any manual
drag-reorder that moved a strike below other targets. The 'new
strikes go first' behavior only needs to happen once, at creation
(already handled by _add_strike_at's reorder_target(target, 0)); the
sort key was redundant with that and actively fighting the user
afterward. Removed it.
2026-08-09 15:46:22 +02:00
b48a4eb759 OCR: listening-post audio intel, compass-direction distances, multi-word ids
New shapes:
- 'Listening Post#1 at K6 7:8 ...': a named anchor given inline rather
  than as its own block, parse_named_at_coord() picks up any
  '<Name>#<id> at <Coord>' anywhere in the text, RP-shaped like
  everything else that resolves to a name+coord.
- 'Distance 6.28km South-East from X': a listening post gives distance
  readings with an approximate 8-point compass direction instead of a
  precise degree bearing, different word order too (the direction sits
  between the distance and 'from', no separate Bearing keyword). New
  TargetType.COASTAL_BATTERY for what these turned out to report on.
- 'Coastal Battery#2' / 'Listening Post#1': multi-word type names with
  a real digit id already attached (unlike the Enemy case, nothing to
  invent), squash_multiword_ids() collapses the embedded space so
  _NAMED_HEADER_RE and every from-<ref> clue pattern see the single
  token they expect.

Also: squash_bold_spans(), prompted by the observation that a rich-text
paste already tells us exactly where a multi-word name starts and ends
via its own <b>...</b> wrapping, no need to guess from capitalization
the way squash_enemy_names()/squash_multiword_ids() do. Runs before
strip_html() while the tags are still there, using each span's own
content as an authoritative boundary. Those two whole-document regexes
stay as the fallback for plain OCR text, which never has markup to
lean on, but squash_multiword_ids() first shipped with a real bug this
caught: 'Distance 6.28km South-East from Listening Post#1' matched
'East from ListeningPost' as if it were one multi-word name, backward
through the lowercase connector word 'from'. Fixed by requiring
Title Case on every word in that whole-document fallback path (the
scoped bold-span path never had this problem, it can't reach past a
span's own boundary). Verified end to end against the exact example
text, including through the real _merge_targets()/_merge_reference_points()
app flow, plus the full existing regression sweep (Enemy names, train
intel, standard blocks, calibration target).
2026-08-09 13:13:48 +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
40387d64b0 OCR: parse ad-hoc 'Enemy X Y' installations as a new target type
'Enemy Signal Station:' and its ilk are named in plain English rather
than the usual Type#N shape, and get referenced the same way
elsewhere ('Bearing 034 from Enemy Signal Station'), breaking two
assumptions everywhere else in this module: headers/references are a
single whitespace-free token, and an id is digit-shaped.

squash_enemy_names() collapses 'Enemy' + up to 4 Title Case words that
follow it into one token in our own id shape ('Enemy#SignalStation')
before anything else parses the text, so every existing from-<ref>
clue pattern and the named-header matcher keep working unmodified.
Wired into both parse_text() (OCR/clipboard) and
parse_clues_from_text() (manual description tab).

Two follow-on fixes this surfaced: _clean_reference() previously
assumed a named reference's id is always digit-shaped and would
truncate 'Enemy#SignalStation' down to 'Enemy#Sig' via
_fix_id_digits's letter-to-digit mapping; and the letter-id header
check needed to run *before* _NAMED_HEADER_RE, whose digit class
overlaps plain letters (S/B/Z/G/O/I/L) and would otherwise
partial-match and mangle the id first.

New TargetType.ENEMY carries these. Verified end to end (including
the solver resolving the cross-references between them) through both
parse_text() and the real _merge_targets() app flow.
2026-08-09 12:32:49 +02:00
82b3fca0a0 OCR: parse train-arrival intel into MainStation + waypoint RPs
A different intel shape entirely: a station's absolute grid ref, the
rail's bearing from it, and waypoints given only as a distance along
that same bearing. ocr.parse_train_intel() turns the station and each
waypoint into RP-shaped entries (bearing+distance-from-station is
solve_location()'s simplest case), merged through the exact same
_merge_reference_points() path as any other RP, no new UI or entity
type needed.

The T=HH:MM:SS timestamp on every line is deliberately never parsed,
there's no game clock to compare it against.

Verified end to end against the real screenshot: MainStation resolves
from its own grid ref, and all three waypoints resolve correctly along
the bearing at their reported distances.
2026-08-09 11:57:43 +02:00
980be86908 Fall back to a compromise point when two distance circles don't cross
Previously two distance-only clues whose circles didn't actually
intersect (real typewriter data isn't perfectly consistent, a
misplaced spotter or an off-by-a-bit distance reading is enough) just
gave up entirely, even when they were nearly touching.

solver.closest_compromise_point() picks the midpoint between each
circle's point facing the other, the standard notion of the closest
approach between two circles. It stays well-behaved even when the
centers are nearly coincident, unlike projecting along the center
line the way a real intersection's formula does, which diverges as
the centers get close while the radii stay far apart, exactly the
near-coincident case this is for.

The result is flagged rather than treated as a clean fix: Location
gained a  field, set whenever solve_location() had to use this
fallback, cleared by any subsequent coord.setter call (manual or
solver), shown in the entity's status label and toasted after a
manual clue edit. Persisted through save/load.
2026-08-08 23:56:24 +02:00
8c73442c03 Surface why a manual clue edit didn't resolve, instead of silence
Circle-circle intersection (and the other solvable shapes) already
worked correctly, verified with a direct test: two distance-only
clues resolve to potential_coords whenever the two circles actually
cross. What was missing was feedback when they don't, e.g. two
distances that put one circle entirely inside the other given the
references' real positions looks identical, from the dialog's
perspective, to a reference that's just not placed yet.

solver.explain_unresolved() distinguishes 'waiting on <ref> for a
known position' from 'these two readings are geometrically
inconsistent', wired into _apply_and_refresh's toast after a manual
Description-tab edit.
2026-08-08 23:50:14 +02:00
b206b382fc Map: show scout flight's anchor grid square and bearing under its name 2026-08-08 23:43:39 +02:00
d37c109cda Show the anchor grid square's name in the scout flight row, not just bearing 2026-08-08 23:42:25 +02:00
2ac133ae3e Move clear-board button to third icon on the header's left side 2026-08-08 23:41:20 +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
419674b5af Add clear-board button and clipboard auto-watch toggle
Board.clear() drops the Nest position, spotters, reference points, and
targets; wired to a header button behind a confirm dialog since it's
not reversible.

The clipboard-watch toggle connects to Gdk.Clipboard's 'changed'
signal and re-runs the merge-all OCR flow automatically whenever the
clipboard gains new image content, skipping non-image changes (e.g.
text copied elsewhere) so it doesn't spam toasts. Off by default.
Disconnected on window destroy.
2026-08-08 23:15:40 +02:00
139601b107 OCR: support the opening calibration order's plain-prose target line
'Target is at- Q4 4:2' (no #id, unlike the usual block shape) is how
the game's very first order gives the target. Store it as a fixed
(UNKNOWN, "1") entry since there's only ever one.
2026-08-08 22:49:44 +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