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>
This commit is contained in:
Dominik Moritz Roth 2026-08-08 20:27:53 +02:00
parent 334d2f5421
commit ba6b07a476
10 changed files with 95 additions and 97 deletions

View File

@ -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

View File

@ -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_),

View File

@ -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

View File

@ -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
- "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):

View File

@ -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))

View File

@ -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):

View File

@ -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)."""

View File

@ -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 "<Type>#<id>" immediately followed by "Destroyed" the
# Just need "<Type>#<id>" 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(

View File

@ -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

View File

@ -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