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:
parent
334d2f5421
commit
ba6b07a476
@ -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
|
||||||
|
|||||||
@ -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_),
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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)."""
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user