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 # PyGObject (GTK4 + libadwaita bindings) and tesseract come from your
# on this machine, not via pip — on Fedora: `sudo dnf install python3-gobject # distro's package manager, not pip. See install.sh / README for the
# gtk4 libadwaita`. Listed here for reference, not installed by pip. # per-distro package names; it checks for both before installing this.
#
# pygobject
Pillow Pillow
numpy numpy

View File

@ -306,7 +306,7 @@ class MainWindow(Adw.ApplicationWindow):
# -- OCR plumbing ----------------------------------------------------------- # -- OCR plumbing -----------------------------------------------------------
def _fetch_clipboard(self) -> None: 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) self._run_ocr_from_clipboard(self._merge_all)
def _run_ocr_from_clipboard(self, on_parsed) -> None: 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: def _merge_parsed_location(self, existing, raw: str, clues, coord) -> None:
"""Refresh desc/clues from a fresh OCR read, but never clobber a """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 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 the same intel later shouldn't undo that; only apply the new coord
if there wasn't one already.""" 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 # "SupplyCache#1 Destroyed." etc. Mark it dead if we already know
# it; if this destruction report is the *first* we've heard of it # it; if this destruction report is the *first* we've heard of it
# (never separately spotted with a position), still record it as a # (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. # better a target with no coord than no record it existed at all.
for target_type, target_id in info.destroyed: for target_type, target_id in info.destroyed:
existing = next( existing = next(
@ -536,7 +536,7 @@ class MainWindow(Adw.ApplicationWindow):
nest = self.board.nest nest = self.board.nest
box.append(_row( box.append(_row(
f"Nest {_location_status(nest)}", f"Nest ({_location_status(nest)})",
on_input=lambda: self._open_coord_dialog( on_input=lambda: self._open_coord_dialog(
title="Set Nest coordinates", title="Set Nest coordinates",
on_submit=lambda loc, _id, _t: self._apply_and_refresh(nest, loc), 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): for sp in list(self.board.spotters):
box.append(_row( box.append(_row(
f"{sp.name} {_location_status(sp)}", f"{sp.name} ({_location_status(sp)})",
on_input=lambda sp=sp: self._open_coord_dialog( on_input=lambda sp=sp: self._open_coord_dialog(
title=f"Set {sp.name} coordinates", title=f"Set {sp.name} coordinates",
on_submit=lambda loc, _id, _t, sp=sp: self._apply_and_refresh(sp, loc), 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): for rp in list(self.board.reference_points):
box.append(_row( box.append(_row(
f"{rp.name} {_location_status(rp)}", f"{rp.name} ({_location_status(rp)})",
on_input=lambda rp=rp: self._open_coord_dialog( on_input=lambda rp=rp: self._open_coord_dialog(
title=f"Set {rp.name} coordinates", title=f"Set {rp.name} coordinates",
on_submit=lambda loc, _id, _t, rp=rp: self._apply_and_refresh(rp, loc), 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): for t in list(self.board.targets):
box.append(_row( box.append(_row(
f"{t.name} {_location_status(t)}", f"{t.name} ({_location_status(t)})",
on_input=lambda t=t: self._open_coord_dialog( on_input=lambda t=t: self._open_coord_dialog(
title=f"Set {t.name} coordinates", title=f"Set {t.name} coordinates",
on_submit=lambda loc, _id, _t2, t=t: self._apply_and_refresh(t, loc), 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: def _add_strike(self) -> None:
"""Two-step add: pick a shell (for blast radius) first, then click """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 TargetType.STRIKE and an explicit shell, and its position is set
by clicking, not typing in coordinates.""" by clicking, not typing in coordinates."""
dialog = Adw.Dialog(title="Add Strike", content_width=340, content_height=200) 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), lambda coord: self._add_strike_at(coord, chosen_shell),
preview_radius_km=chosen_shell.blast_radius_km, 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) next_btn.connect("clicked", on_next)
outer.append(next_btn) outer.append(next_btn)
@ -767,11 +767,11 @@ class MainWindow(Adw.ApplicationWindow):
lambda coord: self._apply_and_refresh(target, Location.from_coord(coord)), lambda coord: self._apply_and_refresh(target, Location.from_coord(coord)),
preview_radius_km=target.effective_shell.blast_radius_km, 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: 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-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.""" you're pointing and don't need to type coordinates."""
popover = Gtk.Popover() popover = Gtk.Popover()
popover.set_parent(self.canvas) popover.set_parent(self.canvas)
@ -812,7 +812,7 @@ class MainWindow(Adw.ApplicationWindow):
def _remove_target_via_panel(self, target) -> None: def _remove_target_via_panel(self, target) -> None:
"""Same as _remove_target, but for the firing panel's own delete """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.board.remove_target(target)
self._refresh() self._refresh()
@ -828,10 +828,10 @@ class MainWindow(Adw.ApplicationWindow):
def _edit_target_position(self, target) -> None: def _edit_target_position(self, target) -> None:
"""Firing card's "Edit pos" button: manual coord overrides whatever """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. _merge_parsed_location won't touch it on a later re-screenshot.
Also lets you change id/type here, prefilled with the current 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( self._open_coord_dialog(
title=f"Edit {target.name}", title=f"Edit {target.name}",
on_submit=lambda loc, id_, type_: self._apply_target_edit(target, loc, id_, type_), 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: 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), """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().""" solver.point_from_bearing_distance()."""
return math.degrees(math.atan2(b[0] - a[0], b[1] - a[1])) % 360 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. """Modal dialog for entering a Coord, or a free-text relative description.
Two ways to specify a location, matching the two tabs: Two ways to specify a location, matching the two tabs:
- "Exact" X/Y/x/y fields (+ id/type when adding a target). - "Exact": X/Y/x/y fields (+ id/type when adding a target).
- "Description" free-form text box, parsed with the same - "Description": free-form text box, parsed with the same
Bearing/Distance clue grammar the OCR pipeline uses Bearing/Distance clue grammar the OCR pipeline uses
(ocr.parse_clues_from_text). Prefilled with whatever (ocr.parse_clues_from_text). Prefilled with whatever
description is already stored, if any. description is already stored, if any.
Either tab calls on_submit with a Location from_coord() for Exact, Either tab calls on_submit with a Location: from_coord() for Exact,
from_desc() for Description so the caller applies just that half from_desc() for Description. The caller applies just that half without
without clobbering the other (a coord and a description can coexist). clobbering the other (a coord and a description can coexist).
""" """
from __future__ import annotations from __future__ import annotations
@ -78,7 +78,7 @@ class CoordDialog(Adw.Dialog):
key_controller.connect("key-pressed", self._on_key_pressed) key_controller.connect("key-pressed", self._on_key_pressed)
self.add_controller(key_controller) 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 # otherwise no descendant is focused (we made the picker buttons
# non-focusable) so key events never reach our controller at all. # non-focusable) so key events never reach our controller at all.
self.set_focusable(True) self.set_focusable(True)
@ -201,7 +201,7 @@ class CoordDialog(Adw.Dialog):
margin_end=16, margin_end=16,
) )
outer.append(Gtk.Label( 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 " "from Alpha' or 'Distance 13.59km from Spotter#1' are "
"parsed into clues; everything else is kept as context.", "parsed into clues; everything else is kept as context.",
wrap=True, wrap=True,
@ -269,7 +269,7 @@ class CoordDialog(Adw.Dialog):
self._y_buttons[digit].set_active(True) self._y_buttons[digit].set_active(True)
self._kb_stage = 0 self._kb_stage = 0
# Don't auto-submit when there's an id/type field still to # 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 # only ever fills the coordinate, so submitting immediately
# would lock in id/type before the user's touched them. # would lock in id/type before the user's touched them.
if not (self._show_id or self._show_type): 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 """Firing-commands sidebar: one card per target (or one per candidate
position, for an ambiguous target), shown alongside the map in an position, for an ambiguous target), shown alongside the map in an
Adw.OverlaySplitView (opening it narrows the map, doesn't overlay it). 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. its own, so no header bar here, just the sort/filter toolbar.
Card layout: Card layout:
@ -13,11 +13,11 @@ Card layout:
Elevation/azimuth are real (ballistics.py), computed from the Nest to Elevation/azimuth are real (ballistics.py), computed from the Nest to
whichever coord the card represents. Shell defaults per target type 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 editable per-target via the shell button, which also drives the map's
blast-radius overlay when this target is selected (grid_widget.py). 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()). 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.hovered_point = None
self.show_dead = "sort_later" # "hide" | "show" | "sort_later" 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) # target -> [(card widget, point)] (usually 1; several for an ambiguous target)
self._cards_by_target: dict[Target, list[tuple[Gtk.Widget, object]]] = {} 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._update_hide_dead_map_button()
self.on_toggle_hide_dead_map(self.hide_dead_from_map) 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: def set_selected(self, target, point=None) -> None:
if target is self.selected and point == self.selected_point: if target is self.selected and point == self.selected_point:
return return
@ -154,7 +154,7 @@ class FiringPanel(Gtk.Box):
def _restyle(self, target, point, css_class: str, add: bool) -> None: def _restyle(self, target, point, css_class: str, add: bool) -> None:
"""point=None means "the whole target" (every one of its cards); """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 without this, both candidate cards light up identically and you
can't tell which one was actually picked.""" can't tell which one was actually picked."""
for card, card_point in self._cards_by_target.get(target, []): 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]: def _build_card_shell(self, target: Target, point=None) -> tuple[Gtk.Box, Gtk.Box]:
"""Card frame + top row (name, edit, assignment, alive) common to """Card frame + top row (name, edit, assignment, alive) common to
every card kind. `point` is the specific coord this card represents 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 the map can highlight/arrow exactly this candidate, not every one
of them.""" of them."""
card = Gtk.Box() card = Gtk.Box()
@ -245,7 +245,7 @@ class FiringPanel(Gtk.Box):
if target.type is TargetType.STRIKE: if target.type is TargetType.STRIKE:
# A strike is placed by clicking the map (set_pos_btn below); # 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. # and re-place instead.
delete_btn = Gtk.Button(icon_name="user-trash-symbolic", tooltip_text="Delete strike") delete_btn = Gtk.Button(icon_name="user-trash-symbolic", tooltip_text="Delete strike")
delete_btn.add_css_class("flat") delete_btn.add_css_class("flat")
@ -322,7 +322,7 @@ class FiringPanel(Gtk.Box):
card, inner = self._build_card_shell(target, coord) card, inner = self._build_card_shell(target, coord)
if ambiguous_index is not None: 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("caption")
tag.add_css_class("warning") tag.add_css_class("warning")
inner.append(tag) inner.append(tag)
@ -381,7 +381,7 @@ class FiringPanel(Gtk.Box):
margin_top=6, margin_bottom=6, margin_start=6, margin_end=6) margin_top=6, margin_bottom=6, margin_start=6, margin_end=6)
for s in Shell: for s in Shell:
radius = f"{s.blast_radius_km}km" if s.blast_radius_km is not None else "unknown radius" 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.add_css_class("flat")
btn.get_child().set_xalign(0) btn.get_child().set_xalign(0)
btn.connect("clicked", lambda _b, s=s: self._pick_shell(target, s, popover)) 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 return (x - MARGIN_LEFT) / cell_w, (grid_h - (y - MARGIN_TOP)) / cell_h
def _excluded_from_map(self, obj) -> bool: 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 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.""" still drawn (darkened) so it stays reachable/un-hideable."""
if obj is self.selected: if obj is self.selected:
return False return False
@ -169,7 +169,7 @@ class GridCanvas(Gtk.DrawingArea):
def _hit_test(self, x: float, y: float): def _hit_test(self, x: float, y: float):
"""Returns (obj, coord) of the nearest marker within range, or """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.""" ambiguous obj was actually hit, since it can have several points."""
cell_w, cell_h = self._cell_size(self.get_width(), self.get_height()) cell_w, cell_h = self._cell_size(self.get_width(), self.get_height())
grid_h = cell_h * ROWS grid_h = cell_h * ROWS
@ -193,7 +193,7 @@ class GridCanvas(Gtk.DrawingArea):
if self.placement_callback is not None: if self.placement_callback is not None:
self._placement_cursor_km = cursor_km self._placement_cursor_km = cursor_km
self.queue_draw() 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) hit, coord = self._hit_test(x, y)
if hit is not self.hovered or coord != self.hovered_point: if hit is not self.hovered or coord != self.hovered_point:
@ -316,7 +316,7 @@ class GridCanvas(Gtk.DrawingArea):
if hollow: if hollow:
cr.new_path() # cairo's arc() draws a line from any stale current 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_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.set_dash([3, 2]) # this is what stops that connector line.
cr.arc(x, y, 5.5, 0, 2 * math.pi) cr.arc(x, y, 5.5, 0, 2 * math.pi)
cr.stroke() cr.stroke()
@ -359,7 +359,7 @@ class GridCanvas(Gtk.DrawingArea):
"""Red arrow(s) Nest -> Target, for whatever's hovered or selected. """Red arrow(s) Nest -> Target, for whatever's hovered or selected.
Points at exactly the hovered/selected candidate when one is known Points at exactly the hovered/selected candidate when one is known
(mouse over/click on a specific ambiguous marker) rather than every (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.""" drawing to all of them makes it impossible to tell which is which."""
nest = self.board.nest nest = self.board.nest
if nest.coord is None: if nest.coord is None:
@ -389,7 +389,7 @@ class GridCanvas(Gtk.DrawingArea):
self._draw_arrow(cr, nx, ny, tx, ty) self._draw_arrow(cr, nx, ny, tx, ty)
def _draw_blast_radius(self, cr, cell_w, cell_h, grid_h) -> None: 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), selection only, not hover (unlike the geo overlays/firing arrow),
per spec. Uses the specific selected candidate point if the target per spec. Uses the specific selected candidate point if the target
is ambiguous; skipped entirely if there's no known point yet, or 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: def _draw_placement_preview(self, cr, cell_w, cell_h, grid_h) -> None:
"""While armed to place/reposition something, a small crosshair dot """While armed to place/reposition something, a small crosshair dot
follows the cursor, plus a blast-radius preview circle if one was 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: if self.placement_callback is None or self._placement_cursor_km is None:
return return
x, y = self._km_to_px(self._placement_cursor_km, cell_w, cell_h, grid_h) 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() cr.stroke()
# Radius indicator: a line from center to an actual point # 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 # entity's clues if one pairs with it (same geometry the
# solver would use; picks the nearer of two candidates), # solver would use; picks the nearer of two candidates),
# else straight up as a last-resort fallback with nothing # 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: def _draw_ellipse(self, cr, cx, cy, rx, ry, steps=72) -> None:
# Points computed explicitly (not via cr.scale) so the stroke width # 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.new_path()
cr.move_to(cx + rx, cy) cr.move_to(cx + rx, cy)
for i in range(1, steps + 1): 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. Sub-grid within a cell: x, y in 0..9.
Not everything is known as an absolute grid coordinate. The typewriter Not everything is known as an absolute grid coordinate. The typewriter
also hands out *relative* fixes "Bearing 293 from Alpha", "Distance also hands out *relative* fixes, "Bearing 293 from Alpha", "Distance
13.59km from Spotter#1" that only resolve to a Coord once whatever 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 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). 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 A Location captures both cases: either a resolved Coord, or a raw
description plus the Clues parsed out of it each Clue naming another 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 entity, so the Clues across the board form a dependency graph that
solver walks (topologically, anchored at entities with a resolved Coord) solver.py walks (topologically, anchored at entities with a resolved
to work out everything else. No solver yet this just defines the shape. Coord) to work out everything else. This module just defines the shape.
""" """
from __future__ import annotations from __future__ import annotations
@ -38,7 +38,7 @@ NATO_ALPHABET = [
class TargetType(Enum): class TargetType(Enum):
SUPPLY_CACHE = "Supply Cache" SUPPLY_CACHE = "Supply Cache"
UNKNOWN = "Target" # generic contact, spotted but not yet identified 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 INFANTRY = "Infantry" # hostile ground troops
MECHANIZED = "Mechanized" # hostile armored/vehicle unit MECHANIZED = "Mechanized" # hostile armored/vehicle unit
HOSTILE_ARTILLERY = "Hostile Artillery" HOSTILE_ARTILLERY = "Hostile Artillery"
@ -52,7 +52,7 @@ class TargetType(Enum):
# Renamed/removed enum members, for loading save files written before the # 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. # "SupplyCache" name and was dropped in favor of it.
_TARGET_TYPE_MIGRATIONS = {"AMMO_CACHE": "SUPPLY_CACHE"} _TARGET_TYPE_MIGRATIONS = {"AMMO_CACHE": "SUPPLY_CACHE"}
@ -133,13 +133,13 @@ class Clue:
@dataclass @dataclass
class Location: 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 known directly for entities given in grid form (Nest, Spotters), or
filled in later by a solver once every clue's reference is resolved. filled in later by a solver once every clue's reference is resolved.
`desc_raw` + `clues` hold a relative description before/instead of `desc_raw` + `clues` hold a relative description before/instead of
that resolution. `potential_coords` holds a solver result that was that resolution. `potential_coords` holds a solver result that was
genuinely ambiguous (e.g. a bearing ray crossing a distance circle 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.""" and never used to resolve anything else."""
coord: Coord | None = None 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) 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: class Nest:
location: Location = field(default_factory=Location) location: Location = field(default_factory=Location)
name: str = "Nest" name: str = "Nest"
@ -203,14 +203,14 @@ class Nest:
def coord(self, value: Coord | None) -> None: def coord(self, value: Coord | None) -> None:
# Set the resolved position without clobbering any desc_raw/clues # Set the resolved position without clobbering any desc_raw/clues
# already stored on this Location (provenance: how it got there). # 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() / # priority over it everywhere it matters (placed_entities() /
# ambiguous_entities() / firing panel cards all check coord first), # ambiguous_entities() / firing panel cards all check coord first),
# so it just goes inert rather than being deleted. # so it just goes inert rather than being deleted.
self.location.coord = value 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: class Spotter:
id: int id: int
location: Location = field(default_factory=Location) location: Location = field(default_factory=Location)
@ -229,14 +229,14 @@ class Spotter:
def coord(self, value: Coord | None) -> None: def coord(self, value: Coord | None) -> None:
# Set the resolved position without clobbering any desc_raw/clues # Set the resolved position without clobbering any desc_raw/clues
# already stored on this Location (provenance: how it got there). # 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() / # priority over it everywhere it matters (placed_entities() /
# ambiguous_entities() / firing panel cards all check coord first), # ambiguous_entities() / firing panel cards all check coord first),
# so it just goes inert rather than being deleted. # so it just goes inert rather than being deleted.
self.location.coord = value 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: class ReferencePoint:
rp_name: str rp_name: str
location: Location = field(default_factory=Location) location: Location = field(default_factory=Location)
@ -255,14 +255,14 @@ class ReferencePoint:
def coord(self, value: Coord | None) -> None: def coord(self, value: Coord | None) -> None:
# Set the resolved position without clobbering any desc_raw/clues # Set the resolved position without clobbering any desc_raw/clues
# already stored on this Location (provenance: how it got there). # 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() / # priority over it everywhere it matters (placed_entities() /
# ambiguous_entities() / firing panel cards all check coord first), # ambiguous_entities() / firing panel cards all check coord first),
# so it just goes inert rather than being deleted. # so it just goes inert rather than being deleted.
self.location.coord = value 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: class Target:
type: TargetType type: TargetType
id: str id: str
@ -276,7 +276,7 @@ class Target:
# None = use effective_shell's type-based default; only set once the # None = use effective_shell's type-based default; only set once the
# user picks one explicitly. # user picks one explicitly.
shell: Shell | None = None 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" assignment: str = "unassigned"
@property @property
@ -303,7 +303,7 @@ class Target:
def coord(self, value: Coord | None) -> None: def coord(self, value: Coord | None) -> None:
# Set the resolved position without clobbering any desc_raw/clues # Set the resolved position without clobbering any desc_raw/clues
# already stored on this Location (provenance: how it got there). # 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() / # priority over it everywhere it matters (placed_entities() /
# ambiguous_entities() / firing panel cards all check coord first), # ambiguous_entities() / firing panel cards all check coord first),
# so it just goes inert rather than being deleted. # so it just goes inert rather than being deleted.
@ -397,7 +397,7 @@ class Board:
def placed_entities(self): def placed_entities(self):
"""Yield (category, obj) for everything with a *resolved* coord that """Yield (category, obj) for everything with a *resolved* coord that
isn't hidden. Entities that only have a relative description (or 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() ambiguous_entities(). See placed_entities_all()/ambiguous_entities_all()
for the unfiltered versions (needed so the map can still show a for the unfiltered versions (needed so the map can still show a
hidden entity, darkened, while it's selected).""" hidden entity, darkened, while it's selected)."""

View File

@ -1,12 +1,12 @@
"""Text OCR pipeline: screenshot -> cleaned-up text -> parsed board info. """Text OCR pipeline: screenshot -> cleaned-up text -> parsed board info.
Only the text sub-pipeline is implemented. There's no image/icon 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. a separate future pipeline, out of scope here.
Preprocessing matters more than the regexes: the typewriter photo has an Preprocessing matters more than the regexes: the typewriter photo has an
uneven vignette (in-game light falloff) that a single global threshold 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 flatten that by dividing by a heavily blurred copy of itself (crude local
background normalization) before thresholding, which recovers text in the background normalization) before thresholding, which recovers text in the
darkened areas reliably. darkened areas reliably.
@ -94,13 +94,13 @@ _SEP = r"\s*[-–—]\s*"
_COORD_RE = re.compile( _COORD_RE = re.compile(
rf"([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})" 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* # (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 # the fuzzy keyword match ended and take the first run of digit-shaped
# characters after that, skipping over whatever separator survived. # characters after that, skipping over whatever separator survived.
_ID_RE = re.compile(rf"({_DIGIT_CLASS}+)") _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) # keyword + extractor here (plus a field below and a case in parse_text)
# once we know the format: # once we know the format:
# - reference points # - reference points
@ -114,7 +114,7 @@ def _fix_digits(s: str) -> str:
def _fix_id_digits(raw: str) -> str: def _fix_id_digits(raw: str) -> str:
"""Like _fix_digits, but for id-length runs specifically: also """Like _fix_digits, but for id-length runs specifically: also
collapses a 2-character run where one char is a genuine digit and the 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 '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 render as one numeral plus one letter of the identical value; that
shape is the signature of OCR ghosting a single thin glyph twice 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 # A target can also be spotted with an absolute grid ref directly
# ("Target#10 Spotted. Grid Q3 9:0") instead of/alongside bearing/distance # ("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( _GRID_COORD_RE = re.compile(
rf"Grid\s+([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})", rf"Grid\s+([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})",
re.IGNORECASE, 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 # 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 # 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) _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 # 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 # between the type word and its digits is enough slack without risking a
# false match elsewhere. # false match elsewhere.
# ^ the junk-class run is REQUIRED (1-2 chars, not 0-2): the digit class # ^ 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 # deliberately overlaps the alphabet (g/s/i/l/o/... look like digits), so
# with a 0-width separator allowed, "Bearing" backtracks into itself # with a 0-width separator allowed, "Bearing" backtracks into itself,
# word="Bearin", "digit"=its own trailing 'g' and falsely matches as a # word="Bearin", "digit"=its own trailing 'g', and falsely matches as a
# header. Requiring real punctuation between word and digits (true of # header. Requiring real punctuation between word and digits (true of
# every observed header: '#', a misread substitute, ...) rules that out, # every observed header: '#', a misread substitute, ...) rules that out,
# and also stops a bare "Word 094" clue line (space only) from matching. # 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 # Reference capture is (\S+), not (.+): references are always a single
# token with no spaces, and being non-greedy this way is what lets # 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 # 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. # a greedy (.+) would let the first one swallow the rest of the string.
# #
# _GAP sits right before "from": plain whitespace normally, but an OCR # _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°" # 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 # 'P' above), so this isn't just non-alnum noise like the header-bullet
# case; tolerate any single short token, not just punctuation. # case; tolerate any single short token, not just punctuation.
_GAP = r"[\s]*(?:\S{1,3}\s*)?" _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} _TYPE_BY_SHORT = {t.short: t for t in TargetType}
# The game's typewriter has used "AmmoCache" for what's now modeled as # 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"} _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: def _clean_reference(raw: str) -> str:
"""Leading name-shaped token, dropping trailing OCR noise (stray dots, """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 references ('Spotter#1', 'AmmoCache#2') get their digit part fixed up
and their separator normalized to '#'; plain word references ('Alpha') 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'.""" like the 'l' in 'Alpha' get corrupted into '1'."""
token = re.match(r"\S+", raw.strip()) token = re.match(r"\S+", raw.strip())
token = token.group(0) if token else 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]: 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 have several (one per clue line, or more than one on a single line
joined with '&').""" joined with '&')."""
clues: list[Clue] = [] clues: list[Clue] = []
@ -292,7 +292,7 @@ def _parse_all_clues(text: str) -> list[Clue]:
def parse_clues_from_text(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 for manually-typed descriptions in the coord dialog, sharing the
exact same clue grammar as the OCR'd intel blocks.""" exact same clue grammar as the OCR'd intel blocks."""
return _parse_all_clues(text) 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). 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, 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°" / what lets a clue split across an OCR line-wrap ("...Bearing 125°" /
"from Spotter#1" on separate lines) or two clues on one line "from Spotter#1" on separate lines) or two clues on one line
("Bearing X from A & Bearing Y from B") both resolve correctly. A ("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 # defeat the column-0-anchored header regexes below. Try the line
# as-is first, and only if that fails, retry with its first # as-is first, and only if that fails, retry with its first
# whitespace-delimited token stripped (covers symbol junk *and* # 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 # only when that leading token is bullet-length (<=3 chars), else
# a genuine wrapped clue continuation like "from Spotter#2" gets # a genuine wrapped clue continuation like "from Spotter#2" gets
# its "from" stripped and "Spotter#2" misread as a new header. # 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: # Destruction reports are standalone one-liners, not tied to a block:
# "SupplyCache#2 Destroyed. Additional Requisition Granted." # "SupplyCache#2 Destroyed. Additional Requisition Granted."
# "Direct Hit! HostileTank#3 Destroyed." # "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 # "Direct Hit!" prefix (or its absence) doesn't matter, search() finds
# the name+Destroyed pair anywhere in the line either way. # the name+Destroyed pair anywhere in the line either way.
_DESTROYED_RE = re.compile( _DESTROYED_RE = re.compile(

View File

@ -1,6 +1,6 @@
"""Shell (ammunition) types and their known properties. """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/ 5 of 12 shells (42%) are "standard" (available without special unlock/
research), and all three shells with a known blast radius are standard research), and all three shells with a known blast radius are standard
ones; the rest are still unmeasured. Feeds into the firing-solution 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 -> circle/circle intersection. Two circles can cross at 0, 1, or 2
points. points.
Cases 3 and 4 surface a 2-point result as `potential` rather than a 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. 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 center_a: Point, radius_a: float, center_b: Point, radius_b: float
) -> list[Point]: ) -> list[Point]:
"""Points where two circles cross, arbitrary order. 0, 1, or 2 points; """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).""" nothing useful to return)."""
ax, ay = center_a ax, ay = center_a
bx, by = center_b bx, by = center_b
@ -85,7 +85,7 @@ def circle_circle_intersections(
d = math.hypot(dx, dy) d = math.hypot(dx, dy)
if d < 1e-9: 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: 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 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 entities in (board.reference_points, board.targets):
for obj in entities: for obj in entities:
if obj.coord is not None or obj.location.potential_coords: 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) result = solve_location(obj.location, board)
if result.coord is not None: if result.coord is not None:
obj.coord = result.coord 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 as the generic TargetType.UNKNOWN ("Target#N"). If a later report
identifies it with a specific type and it resolves to the *exact 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 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. precisely. Drop the redundant generic entry, keep the specific one.
Strikes are our own planned impacts, not enemy contacts, and never Strikes are our own planned impacts, not enemy contacts, and never
participate. Run this after resolve_board(), since positions may participate. Run this after resolve_board(), since positions may