diff --git a/requirements.txt b/requirements.txt index 02fb921..97f5906 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,6 @@ -# PyGObject (GTK4 + libadwaita bindings) is installed as a system package -# on this machine, not via pip — on Fedora: `sudo dnf install python3-gobject -# gtk4 libadwaita`. Listed here for reference, not installed by pip. -# -# pygobject +# PyGObject (GTK4 + libadwaita bindings) and tesseract come from your +# distro's package manager, not pip. See install.sh / README for the +# per-distro package names; it checks for both before installing this. Pillow numpy diff --git a/src/fenigma/app.py b/src/fenigma/app.py index 133d317..03e9495 100644 --- a/src/fenigma/app.py +++ b/src/fenigma/app.py @@ -306,7 +306,7 @@ class MainWindow(Adw.ApplicationWindow): # -- OCR plumbing ----------------------------------------------------------- def _fetch_clipboard(self) -> None: - """Header button / Ctrl+P: universal fetch — run OCR and merge everything found.""" + """Header button / Ctrl+P: universal fetch, run OCR and merge everything found.""" self._run_ocr_from_clipboard(self._merge_all) def _run_ocr_from_clipboard(self, on_parsed) -> None: @@ -367,7 +367,7 @@ class MainWindow(Adw.ApplicationWindow): def _merge_parsed_location(self, existing, raw: str, clues, coord) -> None: """Refresh desc/clues from a fresh OCR read, but never clobber a - coord the entity already has — from a prior resolve, an earlier + coord the entity already has, from a prior resolve, an earlier screenshot's Grid ref, or a manual Edit Pos override. Re-reading the same intel later shouldn't undo that; only apply the new coord if there wasn't one already.""" @@ -411,7 +411,7 @@ class MainWindow(Adw.ApplicationWindow): # "SupplyCache#1 Destroyed." etc. Mark it dead if we already know # it; if this destruction report is the *first* we've heard of it # (never separately spotted with a position), still record it as a - # dead, position-unknown target rather than losing the report — + # dead, position-unknown target rather than losing the report, # better a target with no coord than no record it existed at all. for target_type, target_id in info.destroyed: existing = next( @@ -536,7 +536,7 @@ class MainWindow(Adw.ApplicationWindow): nest = self.board.nest box.append(_row( - f"Nest — {_location_status(nest)}", + f"Nest ({_location_status(nest)})", on_input=lambda: self._open_coord_dialog( title="Set Nest coordinates", on_submit=lambda loc, _id, _t: self._apply_and_refresh(nest, loc), @@ -566,7 +566,7 @@ class MainWindow(Adw.ApplicationWindow): for sp in list(self.board.spotters): box.append(_row( - f"{sp.name} — {_location_status(sp)}", + f"{sp.name} ({_location_status(sp)})", on_input=lambda sp=sp: self._open_coord_dialog( title=f"Set {sp.name} coordinates", on_submit=lambda loc, _id, _t, sp=sp: self._apply_and_refresh(sp, loc), @@ -627,7 +627,7 @@ class MainWindow(Adw.ApplicationWindow): for rp in list(self.board.reference_points): box.append(_row( - f"{rp.name} — {_location_status(rp)}", + f"{rp.name} ({_location_status(rp)})", on_input=lambda rp=rp: self._open_coord_dialog( title=f"Set {rp.name} coordinates", on_submit=lambda loc, _id, _t, rp=rp: self._apply_and_refresh(rp, loc), @@ -679,7 +679,7 @@ class MainWindow(Adw.ApplicationWindow): for t in list(self.board.targets): box.append(_row( - f"{t.name} — {_location_status(t)}", + f"{t.name} ({_location_status(t)})", on_input=lambda t=t: self._open_coord_dialog( title=f"Set {t.name} coordinates", on_submit=lambda loc, _id, _t2, t=t: self._apply_and_refresh(t, loc), @@ -716,7 +716,7 @@ class MainWindow(Adw.ApplicationWindow): def _add_strike(self) -> None: """Two-step add: pick a shell (for blast radius) first, then click - the map to place it — a Strike is just a Target with + the map to place it, a Strike is just a Target with TargetType.STRIKE and an explicit shell, and its position is set by clicking, not typing in coordinates.""" dialog = Adw.Dialog(title="Add Strike", content_width=340, content_height=200) @@ -745,7 +745,7 @@ class MainWindow(Adw.ApplicationWindow): lambda coord: self._add_strike_at(coord, chosen_shell), preview_radius_km=chosen_shell.blast_radius_km, ) - self.toast(f"Click the map to place the strike ({chosen_shell.name}) — Esc to cancel.") + self.toast(f"Click the map to place the strike ({chosen_shell.name}), Esc to cancel.") next_btn.connect("clicked", on_next) outer.append(next_btn) @@ -767,11 +767,11 @@ class MainWindow(Adw.ApplicationWindow): lambda coord: self._apply_and_refresh(target, Location.from_coord(coord)), preview_radius_km=target.effective_shell.blast_radius_km, ) - self.toast(f"Click the map to place {target.name} — Esc to cancel.") + self.toast(f"Click the map to place {target.name}, Esc to cancel.") def _on_map_right_click(self, coord, x: float, y: float) -> None: """Right-click anywhere on the map: quick-add a Target or Strike - right there, no dialog — for when you already know exactly where + right there, no dialog, for when you already know exactly where you're pointing and don't need to type coordinates.""" popover = Gtk.Popover() popover.set_parent(self.canvas) @@ -812,7 +812,7 @@ class MainWindow(Adw.ApplicationWindow): def _remove_target_via_panel(self, target) -> None: """Same as _remove_target, but for the firing panel's own delete - button (Strikes) — no popover `rebuild` callback to call there.""" + button (Strikes), no popover `rebuild` callback to call there.""" self.board.remove_target(target) self._refresh() @@ -828,10 +828,10 @@ class MainWindow(Adw.ApplicationWindow): def _edit_target_position(self, target) -> None: """Firing card's "Edit pos" button: manual coord overrides whatever - the solver had (definitive or ambiguous) — once set it's sticky, + the solver had (definitive or ambiguous), once set it's sticky, _merge_parsed_location won't touch it on a later re-screenshot. Also lets you change id/type here, prefilled with the current - values — blank id means "leave it as-is", not "auto-assign new".""" + values, blank id means "leave it as-is", not "auto-assign new".""" self._open_coord_dialog( title=f"Edit {target.name}", on_submit=lambda loc, id_, type_: self._apply_target_edit(target, loc, id_, type_), diff --git a/src/fenigma/ballistics.py b/src/fenigma/ballistics.py index ee3fc2a..8923a95 100644 --- a/src/fenigma/ballistics.py +++ b/src/fenigma/ballistics.py @@ -21,7 +21,7 @@ def distance_km_point(a: tuple[float, float], b: tuple[float, float]) -> float: def bearing_deg_point(a: tuple[float, float], b: tuple[float, float]) -> float: """Compass bearing from a to b: 0 = north (+row), 90 = east (+col), - matching solver.py's convention — the inverse of + matching solver.py's convention, the inverse of solver.point_from_bearing_distance().""" return math.degrees(math.atan2(b[0] - a[0], b[1] - a[1])) % 360 diff --git a/src/fenigma/coord_dialog.py b/src/fenigma/coord_dialog.py index f3a06c8..1978396 100644 --- a/src/fenigma/coord_dialog.py +++ b/src/fenigma/coord_dialog.py @@ -1,15 +1,15 @@ """Modal dialog for entering a Coord, or a free-text relative description. Two ways to specify a location, matching the two tabs: - - "Exact" — X/Y/x/y fields (+ id/type when adding a target). - - "Description" — free-form text box, parsed with the same - Bearing/Distance clue grammar the OCR pipeline uses - (ocr.parse_clues_from_text). Prefilled with whatever - description is already stored, if any. + - "Exact": X/Y/x/y fields (+ id/type when adding a target). + - "Description": free-form text box, parsed with the same + Bearing/Distance clue grammar the OCR pipeline uses + (ocr.parse_clues_from_text). Prefilled with whatever + description is already stored, if any. -Either tab calls on_submit with a Location — from_coord() for Exact, -from_desc() for Description — so the caller applies just that half -without clobbering the other (a coord and a description can coexist). +Either tab calls on_submit with a Location: from_coord() for Exact, +from_desc() for Description. The caller applies just that half without +clobbering the other (a coord and a description can coexist). """ from __future__ import annotations @@ -78,7 +78,7 @@ class CoordDialog(Adw.Dialog): key_controller.connect("key-pressed", self._on_key_pressed) self.add_controller(key_controller) - # Grab keyboard focus onto the dialog itself as soon as it's shown — + # Grab keyboard focus onto the dialog itself as soon as it's shown, # otherwise no descendant is focused (we made the picker buttons # non-focusable) so key events never reach our controller at all. self.set_focusable(True) @@ -201,7 +201,7 @@ class CoordDialog(Adw.Dialog): margin_end=16, ) outer.append(Gtk.Label( - label="Paste or type a description — lines like 'Bearing 293 " + label="Paste or type a description, lines like 'Bearing 293 " "from Alpha' or 'Distance 13.59km from Spotter#1' are " "parsed into clues; everything else is kept as context.", wrap=True, @@ -269,7 +269,7 @@ class CoordDialog(Adw.Dialog): self._y_buttons[digit].set_active(True) self._kb_stage = 0 # Don't auto-submit when there's an id/type field still to - # fill in (Add spotter / Add target) — the 4-digit sequence + # fill in (Add spotter / Add target), the 4-digit sequence # only ever fills the coordinate, so submitting immediately # would lock in id/type before the user's touched them. if not (self._show_id or self._show_type): diff --git a/src/fenigma/firing_panel.py b/src/fenigma/firing_panel.py index ead098d..a60c6dc 100644 --- a/src/fenigma/firing_panel.py +++ b/src/fenigma/firing_panel.py @@ -1,7 +1,7 @@ """Firing-commands sidebar: one card per target (or one per candidate position, for an ambiguous target), shown alongside the map in an Adw.OverlaySplitView (opening it narrows the map, doesn't overlay it). -Opened/closed from the main header's toggle button — no close control of +Opened/closed from the main header's toggle button, no close control of its own, so no header bar here, just the sort/filter toolbar. Card layout: @@ -13,11 +13,11 @@ Card layout: Elevation/azimuth are real (ballistics.py), computed from the Nest to whichever coord the card represents. Shell defaults per target type -(Target.effective_shell — AP for FDC/AmmoCache, HE otherwise) but is +(Target.effective_shell, AP for FDC/AmmoCache, HE otherwise) but is editable per-target via the shell button, which also drives the map's blast-radius overlay when this target is selected (grid_widget.py). -Cards are drag-reorderable — `self.board.targets`' own list order is the +Cards are drag-reorderable, `self.board.targets`' own list order is the persisted order and doubles as the sort's tie-break (see refresh()). """ @@ -78,7 +78,7 @@ class FiringPanel(Gtk.Box): self.hovered_point = None self.show_dead = "sort_later" # "hide" | "show" | "sort_later" - self.hide_dead_from_map = False # off by default — the map's own filter, separate from sort/hide-in-list + self.hide_dead_from_map = False # off by default, the map's own filter, separate from sort/hide-in-list # target -> [(card widget, point)] (usually 1; several for an ambiguous target) self._cards_by_target: dict[Target, list[tuple[Gtk.Widget, object]]] = {} @@ -137,7 +137,7 @@ class FiringPanel(Gtk.Box): self._update_hide_dead_map_button() self.on_toggle_hide_dead_map(self.hide_dead_from_map) - # -- selection / hover highlight (lightweight — no rebuild) ------------------- + # -- selection / hover highlight (lightweight, no rebuild) ------------------- def set_selected(self, target, point=None) -> None: if target is self.selected and point == self.selected_point: return @@ -154,7 +154,7 @@ class FiringPanel(Gtk.Box): def _restyle(self, target, point, css_class: str, add: bool) -> None: """point=None means "the whole target" (every one of its cards); - otherwise only the card for that specific ambiguous candidate — + otherwise only the card for that specific ambiguous candidate, without this, both candidate cards light up identically and you can't tell which one was actually picked.""" for card, card_point in self._cards_by_target.get(target, []): @@ -216,7 +216,7 @@ class FiringPanel(Gtk.Box): def _build_card_shell(self, target: Target, point=None) -> tuple[Gtk.Box, Gtk.Box]: """Card frame + top row (name, edit, assignment, alive) common to every card kind. `point` is the specific coord this card represents - (None for the "position unknown" card) — passed back on select so + (None for the "position unknown" card), passed back on select so the map can highlight/arrow exactly this candidate, not every one of them.""" card = Gtk.Box() @@ -245,7 +245,7 @@ class FiringPanel(Gtk.Box): if target.type is TargetType.STRIKE: # A strike is placed by clicking the map (set_pos_btn below); - # typing in coordinates to "edit" one doesn't fit that — delete + # typing in coordinates to "edit" one doesn't fit that, delete # and re-place instead. delete_btn = Gtk.Button(icon_name="user-trash-symbolic", tooltip_text="Delete strike") delete_btn.add_css_class("flat") @@ -322,7 +322,7 @@ class FiringPanel(Gtk.Box): card, inner = self._build_card_shell(target, coord) if ambiguous_index is not None: - tag = Gtk.Label(label=f"AMBIGUOUS — candidate {ambiguous_index}", xalign=0) + tag = Gtk.Label(label=f"AMBIGUOUS, candidate {ambiguous_index}", xalign=0) tag.add_css_class("caption") tag.add_css_class("warning") inner.append(tag) @@ -381,7 +381,7 @@ class FiringPanel(Gtk.Box): margin_top=6, margin_bottom=6, margin_start=6, margin_end=6) for s in Shell: radius = f"{s.blast_radius_km}km" if s.blast_radius_km is not None else "unknown radius" - btn = Gtk.Button(label=f"{s.name} — {s.description} ({radius})") + btn = Gtk.Button(label=f"{s.name}: {s.description} ({radius})") btn.add_css_class("flat") btn.get_child().set_xalign(0) btn.connect("clicked", lambda _b, s=s: self._pick_shell(target, s, popover)) diff --git a/src/fenigma/grid_widget.py b/src/fenigma/grid_widget.py index 9224f5c..4ac361a 100644 --- a/src/fenigma/grid_widget.py +++ b/src/fenigma/grid_widget.py @@ -137,9 +137,9 @@ class GridCanvas(Gtk.DrawingArea): return (x - MARGIN_LEFT) / cell_w, (grid_h - (y - MARGIN_TOP)) / cell_h def _excluded_from_map(self, obj) -> bool: - """True if `obj` should be dropped from the map view entirely — + """True if `obj` should be dropped from the map view entirely, it's hidden, or it's a dead target with the map's dead-hiding - toggle on — unless it's the current selection, in which case it's + toggle on, unless it's the current selection, in which case it's still drawn (darkened) so it stays reachable/un-hideable.""" if obj is self.selected: return False @@ -169,7 +169,7 @@ class GridCanvas(Gtk.DrawingArea): def _hit_test(self, x: float, y: float): """Returns (obj, coord) of the nearest marker within range, or - (None, None) — coord disambiguates which candidate of an + (None, None), coord disambiguates which candidate of an ambiguous obj was actually hit, since it can have several points.""" cell_w, cell_h = self._cell_size(self.get_width(), self.get_height()) grid_h = cell_h * ROWS @@ -193,7 +193,7 @@ class GridCanvas(Gtk.DrawingArea): if self.placement_callback is not None: self._placement_cursor_km = cursor_km self.queue_draw() - return # no hover/select while placing — the map's just a target picker right now + return # no hover/select while placing, the map's just a target picker right now hit, coord = self._hit_test(x, y) if hit is not self.hovered or coord != self.hovered_point: @@ -316,7 +316,7 @@ class GridCanvas(Gtk.DrawingArea): if hollow: cr.new_path() # cairo's arc() draws a line from any stale current cr.set_source_rgba(r, g, b, alpha) # point (e.g. the last label's - cr.set_line_width(1.5) # show_text position) to the arc's start — + cr.set_line_width(1.5) # show_text position) to the arc's start, cr.set_dash([3, 2]) # this is what stops that connector line. cr.arc(x, y, 5.5, 0, 2 * math.pi) cr.stroke() @@ -359,7 +359,7 @@ class GridCanvas(Gtk.DrawingArea): """Red arrow(s) Nest -> Target, for whatever's hovered or selected. Points at exactly the hovered/selected candidate when one is known (mouse over/click on a specific ambiguous marker) rather than every - candidate of that target — same reasoning as the selection ring: + candidate of that target, same reasoning as the selection ring: drawing to all of them makes it impossible to tell which is which.""" nest = self.board.nest if nest.coord is None: @@ -389,7 +389,7 @@ class GridCanvas(Gtk.DrawingArea): self._draw_arrow(cr, nx, ny, tx, ty) def _draw_blast_radius(self, cr, cell_w, cell_h, grid_h) -> None: - """When a Target is selected, its effective shell's blast radius — + """When a Target is selected, its effective shell's blast radius, selection only, not hover (unlike the geo overlays/firing arrow), per spec. Uses the specific selected candidate point if the target is ambiguous; skipped entirely if there's no known point yet, or @@ -416,7 +416,7 @@ class GridCanvas(Gtk.DrawingArea): def _draw_placement_preview(self, cr, cell_w, cell_h, grid_h) -> None: """While armed to place/reposition something, a small crosshair dot follows the cursor, plus a blast-radius preview circle if one was - given (e.g. placing a Strike — see its shell before you commit).""" + given (e.g. placing a Strike, see its shell before you commit).""" if self.placement_callback is None or self._placement_cursor_km is None: return x, y = self._km_to_px(self._placement_cursor_km, cell_w, cell_h, grid_h) @@ -480,7 +480,7 @@ class GridCanvas(Gtk.DrawingArea): cr.stroke() # Radius indicator: a line from center to an actual point - # on the circle — the intersection with another of this + # on the circle, the intersection with another of this # entity's clues if one pairs with it (same geometry the # solver would use; picks the nearer of two candidates), # else straight up as a last-resort fallback with nothing @@ -525,7 +525,7 @@ class GridCanvas(Gtk.DrawingArea): def _draw_ellipse(self, cr, cx, cy, rx, ry, steps=72) -> None: # Points computed explicitly (not via cr.scale) so the stroke width - # stays uniform regardless of rx/ry — a scaled CTM would stretch it. + # stays uniform regardless of rx/ry, a scaled CTM would stretch it. cr.new_path() cr.move_to(cx + rx, cy) for i in range(1, steps + 1): diff --git a/src/fenigma/models.py b/src/fenigma/models.py index f077e3e..a3175cb 100644 --- a/src/fenigma/models.py +++ b/src/fenigma/models.py @@ -5,15 +5,15 @@ Coordinate system (matches the in-game map): Sub-grid within a cell: x, y in 0..9. Not everything is known as an absolute grid coordinate. The typewriter -also hands out *relative* fixes — "Bearing 293 from Alpha", "Distance -13.59km from Spotter#1" — that only resolve to a Coord once whatever +also hands out *relative* fixes, "Bearing 293 from Alpha", "Distance +13.59km from Spotter#1", that only resolve to a Coord once whatever they're relative to is itself known, and that can chain (AmmoCache#3 is relative to AmmoCache#2, which is itself relative to Alpha and Spotter#1). A Location captures both cases: either a resolved Coord, or a raw -description plus the Clues parsed out of it — each Clue naming another -entity, so the Clues across the board form a dependency graph a future -solver walks (topologically, anchored at entities with a resolved Coord) -to work out everything else. No solver yet — this just defines the shape. +description plus the Clues parsed out of it, each Clue naming another +entity, so the Clues across the board form a dependency graph that +solver.py walks (topologically, anchored at entities with a resolved +Coord) to work out everything else. This module just defines the shape. """ from __future__ import annotations @@ -38,7 +38,7 @@ NATO_ALPHABET = [ class TargetType(Enum): SUPPLY_CACHE = "Supply Cache" UNKNOWN = "Target" # generic contact, spotted but not yet identified - FDC = "FDC" # Fire Direction Center — coordinates enemy counter-battery fire + FDC = "FDC" # Fire Direction Center, coordinates enemy counter-battery fire INFANTRY = "Infantry" # hostile ground troops MECHANIZED = "Mechanized" # hostile armored/vehicle unit HOSTILE_ARTILLERY = "Hostile Artillery" @@ -52,7 +52,7 @@ class TargetType(Enum): # Renamed/removed enum members, for loading save files written before the -# rename — AMMO_CACHE turned out to be a misreading of the game's actual +# rename. AMMO_CACHE turned out to be a misreading of the game's actual # "SupplyCache" name and was dropped in favor of it. _TARGET_TYPE_MIGRATIONS = {"AMMO_CACHE": "SUPPLY_CACHE"} @@ -133,13 +133,13 @@ class Clue: @dataclass class Location: - """Where something is. `coord` is the resolved absolute position — + """Where something is. `coord` is the resolved absolute position, known directly for entities given in grid form (Nest, Spotters), or filled in later by a solver once every clue's reference is resolved. `desc_raw` + `clues` hold a relative description before/instead of that resolution. `potential_coords` holds a solver result that was genuinely ambiguous (e.g. a bearing ray crossing a distance circle - twice) — shown on the map as candidates, never treated as resolved + twice), shown on the map as candidates, never treated as resolved and never used to resolve anything else.""" coord: Coord | None = None @@ -188,7 +188,7 @@ def _as_location(value: "Location | Coord | None") -> Location: return value if isinstance(value, Location) else Location.from_coord(value) -@dataclass(eq=False) # identity equality/hash — these are mutable, used as dict keys/set members +@dataclass(eq=False) # identity equality/hash, these are mutable, used as dict keys/set members class Nest: location: Location = field(default_factory=Location) name: str = "Nest" @@ -203,14 +203,14 @@ class Nest: def coord(self, value: Coord | None) -> None: # Set the resolved position without clobbering any desc_raw/clues # already stored on this Location (provenance: how it got there). - # potential_coords is left alone too, not cleared — a coord takes + # potential_coords is left alone too, not cleared. A coord takes # priority over it everywhere it matters (placed_entities() / # ambiguous_entities() / firing panel cards all check coord first), # so it just goes inert rather than being deleted. self.location.coord = value -@dataclass(eq=False) # identity equality/hash — these are mutable, used as dict keys/set members +@dataclass(eq=False) # identity equality/hash, these are mutable, used as dict keys/set members class Spotter: id: int location: Location = field(default_factory=Location) @@ -229,14 +229,14 @@ class Spotter: def coord(self, value: Coord | None) -> None: # Set the resolved position without clobbering any desc_raw/clues # already stored on this Location (provenance: how it got there). - # potential_coords is left alone too, not cleared — a coord takes + # potential_coords is left alone too, not cleared. A coord takes # priority over it everywhere it matters (placed_entities() / # ambiguous_entities() / firing panel cards all check coord first), # so it just goes inert rather than being deleted. self.location.coord = value -@dataclass(eq=False) # identity equality/hash — these are mutable, used as dict keys/set members +@dataclass(eq=False) # identity equality/hash, these are mutable, used as dict keys/set members class ReferencePoint: rp_name: str location: Location = field(default_factory=Location) @@ -255,14 +255,14 @@ class ReferencePoint: def coord(self, value: Coord | None) -> None: # Set the resolved position without clobbering any desc_raw/clues # already stored on this Location (provenance: how it got there). - # potential_coords is left alone too, not cleared — a coord takes + # potential_coords is left alone too, not cleared. A coord takes # priority over it everywhere it matters (placed_entities() / # ambiguous_entities() / firing panel cards all check coord first), # so it just goes inert rather than being deleted. self.location.coord = value -@dataclass(eq=False) # identity equality/hash — these are mutable, used as dict keys/set members +@dataclass(eq=False) # identity equality/hash, these are mutable, used as dict keys/set members class Target: type: TargetType id: str @@ -276,7 +276,7 @@ class Target: # None = use effective_shell's type-based default; only set once the # user picks one explicitly. shell: Shell | None = None - # Which gun this target is assigned to, if any — "unassigned" | "left" | "right". + # Which gun this target is assigned to, if any, "unassigned" | "left" | "right". assignment: str = "unassigned" @property @@ -303,7 +303,7 @@ class Target: def coord(self, value: Coord | None) -> None: # Set the resolved position without clobbering any desc_raw/clues # already stored on this Location (provenance: how it got there). - # potential_coords is left alone too, not cleared — a coord takes + # potential_coords is left alone too, not cleared. A coord takes # priority over it everywhere it matters (placed_entities() / # ambiguous_entities() / firing panel cards all check coord first), # so it just goes inert rather than being deleted. @@ -397,7 +397,7 @@ class Board: def placed_entities(self): """Yield (category, obj) for everything with a *resolved* coord that isn't hidden. Entities that only have a relative description (or - only ambiguous potential_coords) aren't drawable this way — see + only ambiguous potential_coords) aren't drawable this way. See ambiguous_entities(). See placed_entities_all()/ambiguous_entities_all() for the unfiltered versions (needed so the map can still show a hidden entity, darkened, while it's selected).""" diff --git a/src/fenigma/ocr.py b/src/fenigma/ocr.py index 6f18c6d..10b5049 100644 --- a/src/fenigma/ocr.py +++ b/src/fenigma/ocr.py @@ -1,12 +1,12 @@ """Text OCR pipeline: screenshot -> cleaned-up text -> parsed board info. Only the text sub-pipeline is implemented. There's no image/icon -recognition sub-pipeline yet (spotting markers, ship icons, etc.) — that's +recognition sub-pipeline yet (spotting markers, ship icons, etc.), that's a separate future pipeline, out of scope here. Preprocessing matters more than the regexes: the typewriter photo has an uneven vignette (in-game light falloff) that a single global threshold -can't handle — it either loses faint corners or blobs-out dark ones. We +can't handle, it either loses faint corners or blobs-out dark ones. We flatten that by dividing by a heavily blurred copy of itself (crude local background normalization) before thresholding, which recovers text in the darkened areas reliably. @@ -94,13 +94,13 @@ _SEP = r"\s*[-–—]\s*" _COORD_RE = re.compile( rf"([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})" ) -# No literal '#' required — it's just as OCR-corruptible as anything else +# No literal '#' required, it's just as OCR-corruptible as anything else # (missing entirely, or misread as e.g. 'H'). We instead anchor to *where* # the fuzzy keyword match ended and take the first run of digit-shaped # characters after that, skipping over whatever separator survived. _ID_RE = re.compile(rf"({_DIGIT_CLASS}+)") -# Not seen in a real screenshot yet, so no extraction for these — add a +# Not seen in a real screenshot yet, so no extraction for these, add a # keyword + extractor here (plus a field below and a case in parse_text) # once we know the format: # - reference points @@ -114,7 +114,7 @@ def _fix_digits(s: str) -> str: def _fix_id_digits(raw: str) -> str: """Like _fix_digits, but for id-length runs specifically: also collapses a 2-character run where one char is a genuine digit and the - other a look-alike letter that maps to the *same* digit — e.g. '1l' or + other a look-alike letter that maps to the *same* digit, e.g. '1l' or 'S5' both fix to '11'/'55', but a real 2-digit id wouldn't plausibly render as one numeral plus one letter of the identical value; that shape is the signature of OCR ghosting a single thin glyph twice @@ -132,7 +132,7 @@ def _fix_id_digits(raw: str) -> str: # A target can also be spotted with an absolute grid ref directly # ("Target#10 Spotted. Grid Q3 9:0") instead of/alongside bearing/distance -# clues — same coordinate shape as _COORD_RE, just anchored after "Grid". +# clues, same coordinate shape as _COORD_RE, just anchored after "Grid". _GRID_COORD_RE = re.compile( rf"Grid\s+([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})", re.IGNORECASE, @@ -189,17 +189,17 @@ def _extract_leading_id(remainder: str) -> int | None: # # Each named entity is a block of one or more clue lines, terminated by a # blank line or a lone '.'. Degree signs, colons, and the 'km' unit are all -# treated as optional/lossy — OCR drops them unpredictably. +# treated as optional/lossy, OCR drops them unpredictably. _RP_HEADER_RE = re.compile(r"Reference\s+Point\s+([A-Za-z][\w-]*)\s*:?", re.IGNORECASE) -# '#' isn't required literally — same reasoning as the spotter-id fix: OCR +# '#' isn't required literally, same reasoning as the spotter-id fix: OCR # drops it or renders it as noise (seen: '€'). Up to 2 junk characters # between the type word and its digits is enough slack without risking a # false match elsewhere. # ^ the junk-class run is REQUIRED (1-2 chars, not 0-2): the digit class # deliberately overlaps the alphabet (g/s/i/l/o/... look like digits), so -# with a 0-width separator allowed, "Bearing" backtracks into itself — -# word="Bearin", "digit"=its own trailing 'g' — and falsely matches as a +# with a 0-width separator allowed, "Bearing" backtracks into itself, +# word="Bearin", "digit"=its own trailing 'g', and falsely matches as a # header. Requiring real punctuation between word and digits (true of # every observed header: '#', a misread substitute, ...) rules that out, # and also stops a bare "Word 094" clue line (space only) from matching. @@ -209,13 +209,13 @@ _LEADING_NOISE_RE = re.compile(r"^[^A-Za-z]{1,3}(?=[A-Za-z])") # Reference capture is (\S+), not (.+): references are always a single # token with no spaces, and being non-greedy this way is what lets -# finditer() find more than one clue per line/block — "Bearing 118 from +# finditer() find more than one clue per line/block, "Bearing 118 from # Spotter#2 & Bearing 125 from Spotter#1" needs two separate matches, and # a greedy (.+) would let the first one swallow the rest of the string. # # _GAP sits right before "from": plain whitespace normally, but an OCR # line-wrap can drop a stray junk token right at the break ("Bearing 125°" -# / "P; from Spotter#1") — and that junk can itself contain a letter (the +# / "P; from Spotter#1"), and that junk can itself contain a letter (the # 'P' above), so this isn't just non-alnum noise like the header-bullet # case; tolerate any single short token, not just punctuation. _GAP = r"[\s]*(?:\S{1,3}\s*)?" @@ -230,7 +230,7 @@ _CLUE_DISTANCE_RE = re.compile(rf"Distance\s*([\d.]+)\s*k?m?{_GAP}from\s+(\S+)", _TYPE_BY_SHORT = {t.short: t for t in TargetType} # The game's typewriter has used "AmmoCache" for what's now modeled as -# SupplyCache — treat it as the same type rather than dropping the target. +# SupplyCache, treat it as the same type rather than dropping the target. _TYPE_WORD_ALIASES = {"AmmoCache": "SupplyCache"} @@ -239,10 +239,10 @@ _REF_NAMED_RE = re.compile(rf"^([A-Za-z]+)[^A-Za-z0-9\s]{{1,2}}({_DIGIT_CLASS}+) def _clean_reference(raw: str) -> str: """Leading name-shaped token, dropping trailing OCR noise (stray dots, - double spaces, etc.) — reference names never contain spaces. Named + double spaces, etc.), reference names never contain spaces. Named references ('Spotter#1', 'AmmoCache#2') get their digit part fixed up and their separator normalized to '#'; plain word references ('Alpha') - are left untouched — don't run digit-fixing over them or real letters + are left untouched, don't run digit-fixing over them or real letters like the 'l' in 'Alpha' get corrupted into '1'.""" token = re.match(r"\S+", raw.strip()) token = token.group(0) if token else raw.strip() @@ -271,7 +271,7 @@ _CLUE_PATTERNS = ( def _parse_all_clues(text: str) -> list[Clue]: - """Every Bearing/Distance clue found anywhere in `text` — a block can + """Every Bearing/Distance clue found anywhere in `text`, a block can have several (one per clue line, or more than one on a single line joined with '&').""" clues: list[Clue] = [] @@ -292,7 +292,7 @@ def _parse_all_clues(text: str) -> list[Clue]: def parse_clues_from_text(text: str) -> list[Clue]: - """Parse every Bearing/Distance clue found in free-form text — used + """Parse every Bearing/Distance clue found in free-form text, used for manually-typed descriptions in the coord dialog, sharing the exact same clue grammar as the OCR'd intel blocks.""" return _parse_all_clues(text) @@ -318,7 +318,7 @@ def parse_intel_blocks(text: str) -> list[dict]: with neither a clue nor a grid coord are dropped (nothing to store). Clues are extracted once per block, from the whole joined block text, - at flush time — not accumulated line-by-line while scanning. That's + at flush time, not accumulated line-by-line while scanning. That's what lets a clue split across an OCR line-wrap ("...Bearing 125°" / "from Spotter#1" on separate lines) or two clues on one line ("Bearing X from A & Bearing Y from B") both resolve correctly. A @@ -352,7 +352,7 @@ def parse_intel_blocks(text: str) -> list[dict]: # defeat the column-0-anchored header regexes below. Try the line # as-is first, and only if that fails, retry with its first # whitespace-delimited token stripped (covers symbol junk *and* - # a misread bullet that happened to OCR as a stray letter) — but + # a misread bullet that happened to OCR as a stray letter), but # only when that leading token is bullet-length (<=3 chars), else # a genuine wrapped clue continuation like "from Spotter#2" gets # its "from" stripped and "Spotter#2" misread as a new header. @@ -390,7 +390,7 @@ def parse_intel_blocks(text: str) -> list[dict]: # Destruction reports are standalone one-liners, not tied to a block: # "SupplyCache#2 Destroyed. Additional Requisition Granted." # "Direct Hit! HostileTank#3 Destroyed." -# Just need "#" immediately followed by "Destroyed" — the +# Just need "#" immediately followed by "Destroyed", the # "Direct Hit!" prefix (or its absence) doesn't matter, search() finds # the name+Destroyed pair anywhere in the line either way. _DESTROYED_RE = re.compile( diff --git a/src/fenigma/shells.py b/src/fenigma/shells.py index 95cf1b8..7f23593 100644 --- a/src/fenigma/shells.py +++ b/src/fenigma/shells.py @@ -1,6 +1,6 @@ """Shell (ammunition) types and their known properties. -From High Command's field reference. Blast radius is None where unknown — +From High Command's field reference. Blast radius is None where unknown, 5 of 12 shells (42%) are "standard" (available without special unlock/ research), and all three shells with a known blast radius are standard ones; the rest are still unmeasured. Feeds into the firing-solution diff --git a/src/fenigma/solver.py b/src/fenigma/solver.py index c7c3a8d..a7a7c52 100644 --- a/src/fenigma/solver.py +++ b/src/fenigma/solver.py @@ -20,7 +20,7 @@ typewriter data so far): -> circle/circle intersection. Two circles can cross at 0, 1, or 2 points. Cases 3 and 4 surface a 2-point result as `potential` rather than a -resolved `coord` — nothing here picks a "more likely" one of the two, so +resolved `coord`, nothing here picks a "more likely" one of the two, so nothing downstream is allowed to depend on it either. """ @@ -77,7 +77,7 @@ def circle_circle_intersections( center_a: Point, radius_a: float, center_b: Point, radius_b: float ) -> list[Point]: """Points where two circles cross, arbitrary order. 0, 1, or 2 points; - also 0 for coincident circles (infinitely many "intersections" — + also 0 for coincident circles (infinitely many "intersections", nothing useful to return).""" ax, ay = center_a bx, by = center_b @@ -85,7 +85,7 @@ def circle_circle_intersections( d = math.hypot(dx, dy) if d < 1e-9: - return [] # same center — either no solution (r differs) or infinite (r same); neither is useful + return [] # same center, either no solution (r differs) or infinite (r same); neither is useful if d > radius_a + radius_b + 1e-9 or d < abs(radius_a - radius_b) - 1e-9: return [] # too far apart, or one circle nested inside the other with no crossing @@ -210,7 +210,7 @@ def resolve_board(board: Board) -> list[str]: for entities in (board.reference_points, board.targets): for obj in entities: if obj.coord is not None or obj.location.potential_coords: - continue # already resolved, or stuck ambiguous — don't reprocess + continue # already resolved, or stuck ambiguous, don't reprocess result = solve_location(obj.location, board) if result.coord is not None: obj.coord = result.coord @@ -226,7 +226,7 @@ def dedupe_generic_targets(board: Board) -> list[str]: as the generic TargetType.UNKNOWN ("Target#N"). If a later report identifies it with a specific type and it resolves to the *exact same* position as an already-known specific target, it's not a new - contact — it's the same one being spotted, just described more + contact, it's the same one being spotted, just described more precisely. Drop the redundant generic entry, keep the specific one. Strikes are our own planned impacts, not enemy contacts, and never participate. Run this after resolve_board(), since positions may