Commit Graph

13 Commits

Author SHA1 Message Date
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
d6694b585b Fold COASTAL_BATTERY into HOSTILE_ARTILLERY, it's the same thing
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.
2026-08-09 20:06:44 +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
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
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
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
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