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.
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.
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).
'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.
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.
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).
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.
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>
- 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>