Initial commit: IRON NEST Assist
GTK4/libadwaita desktop helper for IRON NEST: Heavy Turret Simulator. Reads clipboard screenshots of the game's typewriter orders via Tesseract OCR, parses absolute/relative entity positions, geometrically resolves relative bearing/distance clues into map coordinates, and provides a firing-commands sidebar with real ballistics (elevation/azimuth/powder charge). Screen-reading only — no game files touched, no input injected. - models.py: Board/Nest/Spotter/ReferencePoint/Target data model - ocr.py: Tesseract preprocessing + typewriter-text parsing - solver.py: bearing/distance geometric resolution (4 solvable shapes) plus position-based dedup for generic contacts later identified more specifically at the same resolved coord - ballistics.py / shells.py: elevation/azimuth/charge math, shell types - grid_widget.py: interactive map canvas (hidden entities and, optionally, dead targets are excluded from the map view entirely unless selected) - firing_panel.py: drag-reorderable firing-command sidebar - app.py: main window wiring it all together Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
8bec273b3f
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
captures/*.png
|
||||
.venv/
|
||||
154
README.md
Normal file
154
README.md
Normal file
@ -0,0 +1,154 @@
|
||||
# ironnest-assist
|
||||
|
||||
Screen-reading helper for **IRON NEST: Heavy Turret Simulator**. Tracks the
|
||||
Nest, Spotters, Reference Points, and Targets on a map matching the game's
|
||||
grid, settable by hand, by a free-text description, or by OCR-ing a
|
||||
clipboard screenshot of the typewriter order. A solver resolves relative
|
||||
descriptions ("Bearing 293 from Alpha") into absolute coordinates once
|
||||
their dependencies are known. Single-player QoL tool — no game files
|
||||
touched, no input injected.
|
||||
|
||||
## Stack
|
||||
|
||||
- **GTK4 + libadwaita** (via PyGObject) — native GNOME look on Linux. GTK4
|
||||
itself also runs on Windows/macOS (Win32/Quartz backends), just without
|
||||
full Adwaita chrome there.
|
||||
- **Pillow / numpy** — image preprocessing for OCR.
|
||||
- **pytesseract + tesseract** — text OCR of the typewriter order.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
./run.sh
|
||||
```
|
||||
|
||||
GTK apps with this app ID are single-instance — if a run gets killed
|
||||
uncleanly it can leave a zombie registered on D-Bus and silently no-op the
|
||||
next launch. If `./run.sh` seems to do nothing, `pgrep -af ironnest_assist`
|
||||
and kill any stragglers first.
|
||||
|
||||
## Coordinate system
|
||||
|
||||
Large grid: `X` in `A`–`T` (20 cols), `Y` in `1`–`10` (10 rows, row 1 at
|
||||
the bottom). Sub-grid within a cell: `x`, `y` in `0`–`9`. **One large cell
|
||||
is 1km × 1km** — that scale is what the solver's bearing/distance math
|
||||
runs on. Quick keyboard entry in the coord dialog: type e.g. `C433`
|
||||
(letter + 3 digits) to fill and submit in one go — `0` for the `Y` digit
|
||||
means `10`.
|
||||
|
||||
### Location: coord and/or description
|
||||
|
||||
Every entity's `location` (`models.py`) independently holds:
|
||||
- `coord` — a resolved absolute position.
|
||||
- `desc_raw` + `clues` — a raw free-text description and the `Clue`s
|
||||
parsed out of it (`reference`, `bearing_deg`, `distance_km`), each
|
||||
naming another entity it's relative to.
|
||||
- `potential_coords` — set instead of `coord` when the solver found the
|
||||
position genuinely ambiguous (see below); shown on the map, never
|
||||
chained into further resolution.
|
||||
|
||||
These aren't either/or — setting a coord never erases an existing
|
||||
description (and vice versa), since a coord can arrive via OCR *after* a
|
||||
description was already on file, or a description can be added as extra
|
||||
context for an already-placed entity.
|
||||
|
||||
### Solver (`solver.py`)
|
||||
|
||||
Walks every RP/Target's clues, resolving whatever it can against
|
||||
currently-known positions, repeating until nothing new resolves (handles
|
||||
chains, e.g. `AmmoCache#3` → `AmmoCache#2` → `Alpha`/Spotters). Handles:
|
||||
|
||||
1. One clue with both bearing *and* distance from a resolved reference —
|
||||
always unique.
|
||||
2. Two bearing-only clues from different references — ray/ray
|
||||
intersection, always unique (unless parallel).
|
||||
3. A bearing-only + a distance-only clue from different references —
|
||||
ray/circle intersection. A ray can cross a circle at 0, 1, or 2 points;
|
||||
when there are 2, that's genuinely ambiguous — both candidates go into
|
||||
`potential_coords` instead of picking one, and nothing depends on them
|
||||
further.
|
||||
|
||||
Two distance-only clues (circle/circle, also up to 2 solutions) isn't
|
||||
handled — even less to disambiguate with.
|
||||
|
||||
Runs automatically after every mutation (`MainWindow._refresh()` is the
|
||||
single choke point), so newly-unblocked descriptions resolve immediately.
|
||||
|
||||
### Map overlays
|
||||
|
||||
Every entity row has an eye-icon (hide from map) and a star-icon (always
|
||||
show its bearing/distance overlay, vs. only on hover). Hovering a marker,
|
||||
or pinning it with the star, draws its clues: a yellow line for a
|
||||
bearing-only clue, a white circle (radius = distance) for a distance-only
|
||||
clue, a yellow arrow when a single clue has both. Ambiguous entities draw
|
||||
both `potential_coords` as hollow dashed markers. Targets also have an
|
||||
alive/destroyed toggle (checkmark icon) — dead ones show struck-through in
|
||||
their list and dimmed on the map.
|
||||
|
||||
## OCR
|
||||
|
||||
Two sub-pipelines, planned: **text** (typewriter orders, implemented) and
|
||||
**image** (map icons/markers, not started). Text OCR: screenshot →
|
||||
grayscale → divide by a heavily-blurred copy of itself to flatten the
|
||||
game's light-falloff vignette → threshold → `tesseract --psm 6` → parsing.
|
||||
See `src/ironnest_assist/ocr.py`.
|
||||
|
||||
Two text formats parsed, both fuzzy/typo-tolerant (`difflib` keyword
|
||||
matching, digit/letter OCR-mixup normalization `O`/`0` `I`/`l`/`1` `S`/`5`
|
||||
`B`/`8` etc., and every separator — `#`, `:`, block-terminating `.` —
|
||||
treated as just as corruptible as any other character, never matched
|
||||
literally):
|
||||
|
||||
1. **Absolute grid refs** — `IRON NEST - C4 3:3`, `Spotter#1 - F7 7:1` →
|
||||
`nest_coord`, `spotters`.
|
||||
2. **Field-intelligence blocks** — named entity header (`Target#5`,
|
||||
`AmmoCache#1:`, `Reference Point Alpha:`) followed by one or more
|
||||
`Bearing`/`Distance`/combined clue lines, terminated by a blank line or
|
||||
lone `.` → `reference_points`, `targets`, each as (raw text, parsed
|
||||
clues). The same clue grammar (`ocr.parse_clues_from_text`) also backs
|
||||
the manual "Description" tab in the coord-entry dialog.
|
||||
|
||||
- Header clipboard button (or `Ctrl+P`): universal fetch — merges
|
||||
everything recognized into the board.
|
||||
- A category's "from screenshot" actions extract only that category;
|
||||
toasts an error if the requested item isn't found in the screenshot.
|
||||
- "Load all from screenshot" / a list item's screenshot action both use
|
||||
the same merge: same name/id → update in place, new → add.
|
||||
|
||||
Known limitation: OCR on a *heavily skewed/rotated* screenshot degrades
|
||||
badly — psm 6 assumes a roughly-upright text block, and deskewing isn't
|
||||
implemented. A front-on-ish shot works well. Considered switching OCR
|
||||
engines (vision-LLM structured extraction, PaddleOCR/EasyOCR) instead of
|
||||
continuing to special-case Tesseract's misreads one at a time — staying
|
||||
on Tesseract per your call for now.
|
||||
|
||||
## Ammunition (`shells.py`)
|
||||
|
||||
`Shell` enum from High Command's field reference — description, blast
|
||||
radius in km (`None` where not yet measured), and whether it's a
|
||||
"standard" (unlocked-by-default) type. Feeds the firing-solution
|
||||
calculator later (e.g. AP is required for underground supply caches per
|
||||
the typewriter note).
|
||||
|
||||
## Firing commands panel
|
||||
|
||||
Header button (top-right, `sidebar-show-right-symbolic`) slides in a
|
||||
right-hand sidebar via `Adw.OverlaySplitView` — it shares the window's
|
||||
width with the map (narrows it), rather than overlaying on top. One
|
||||
placeholder card per board target (`firing_panel.py`): shell + quantity,
|
||||
elevation/azimuth readout, confirm/cancel — all dummy values for now,
|
||||
since the actual aiming math isn't known yet (see open questions).
|
||||
|
||||
## Status
|
||||
|
||||
Board data model, map view + overlays, exact/description coordinate
|
||||
input, save/load to JSON, text OCR for both known typewriter formats, the
|
||||
bearing/distance solver, and the firing-commands panel shell are all
|
||||
working. Still open: image OCR sub-pipeline, deskewing, circle/circle
|
||||
ambiguous case, and the actual firing-solution calculator (turret aiming
|
||||
math still unknown — the panel currently just shows placeholder cards).
|
||||
|
||||
## Open questions
|
||||
|
||||
- What inputs the firing-solution math actually needs, beyond target
|
||||
position and shell choice
|
||||
9
requirements.txt
Normal file
9
requirements.txt
Normal file
@ -0,0 +1,9 @@
|
||||
# PyGObject (GTK4 + libadwaita bindings) is installed as a system package
|
||||
# on this machine, not via pip — on Fedora: `sudo dnf install python3-gobject
|
||||
# gtk4 libadwaita`. Listed here for reference, not installed by pip.
|
||||
#
|
||||
# pygobject
|
||||
|
||||
Pillow
|
||||
numpy
|
||||
pytesseract
|
||||
5
run.sh
Executable file
5
run.sh
Executable file
@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Launch IronNest Assist.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||
exec env PYTHONPATH=src python3 -m ironnest_assist.app
|
||||
1
src/ironnest_assist/__init__.py
Normal file
1
src/ironnest_assist/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""ironnest-assist: screen-reading helper for IRON NEST: Heavy Turret Simulator."""
|
||||
881
src/ironnest_assist/app.py
Normal file
881
src/ironnest_assist/app.py
Normal file
@ -0,0 +1,881 @@
|
||||
"""IronNest Assist — GTK4/libadwaita app entry point.
|
||||
|
||||
Layout: a row of category dropdowns on top (+ a universal clipboard-fetch
|
||||
button), the map/grid filling the center. Coordinates can be set by exact
|
||||
numeric input, by a free-text relative description ("Bearing 293 from
|
||||
Alpha"), or extracted from a clipboard screenshot via the text OCR
|
||||
pipeline. A solver resolves relative descriptions into absolute
|
||||
coordinates whenever their dependencies become known (see solver.py);
|
||||
every mutation runs through _refresh(), which is the single choke point
|
||||
for that.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
gi.require_version("Gdk", "4.0")
|
||||
|
||||
from gi.repository import Adw, Gdk, Gio, GLib, Gtk # noqa: E402
|
||||
from PIL import Image # noqa: E402
|
||||
|
||||
from . import ballistics, ocr, solver # noqa: E402
|
||||
from .coord_dialog import CoordDialog # noqa: E402
|
||||
from .firing_panel import FiringPanel # noqa: E402
|
||||
from .grid_widget import GridCanvas # noqa: E402
|
||||
from .models import Board, Location, Target, TargetType # noqa: E402
|
||||
from .shells import Shell # noqa: E402
|
||||
|
||||
APP_ID = "eu.dominik-roth.IronNestAssist"
|
||||
|
||||
|
||||
def _apply_location(obj, location: Location) -> None:
|
||||
"""Apply a Location patch from CoordDialog: a resolved coord and a
|
||||
description are independent, so only touch whichever half is set,
|
||||
leaving the other (already on `obj`) alone."""
|
||||
if location.coord is not None:
|
||||
obj.coord = location.coord # preserves existing desc_raw/clues, clears potential_coords
|
||||
if location.desc_raw is not None:
|
||||
obj.location.desc_raw = location.desc_raw
|
||||
obj.location.clues = location.clues
|
||||
|
||||
|
||||
def _row(
|
||||
name: str,
|
||||
*,
|
||||
on_input,
|
||||
on_screenshot,
|
||||
on_remove=None,
|
||||
hidden=False,
|
||||
on_toggle_hidden=None,
|
||||
show_geo=False,
|
||||
on_toggle_show_geo=None,
|
||||
alive=None,
|
||||
on_toggle_alive=None,
|
||||
) -> Gtk.Widget:
|
||||
"""One list entry: a label plus input/screenshot/geo/hide[/alive][/remove]
|
||||
action buttons."""
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||
box.set_margin_top(4)
|
||||
box.set_margin_bottom(4)
|
||||
box.set_margin_start(8)
|
||||
box.set_margin_end(8)
|
||||
|
||||
label = Gtk.Label(xalign=0, hexpand=True)
|
||||
if alive is False:
|
||||
label.set_markup(f"<s>{GLib.markup_escape_text(name)}</s>")
|
||||
else:
|
||||
label.set_label(name)
|
||||
if hidden:
|
||||
label.add_css_class("dim-label")
|
||||
box.append(label)
|
||||
|
||||
input_btn = Gtk.Button(icon_name="document-edit-symbolic", tooltip_text="Set coords from input")
|
||||
input_btn.connect("clicked", lambda _b: on_input())
|
||||
box.append(input_btn)
|
||||
|
||||
shot_btn = Gtk.Button(icon_name="insert-image-symbolic", tooltip_text="Set coords from screenshot")
|
||||
shot_btn.connect("clicked", lambda _b: on_screenshot())
|
||||
box.append(shot_btn)
|
||||
|
||||
if on_toggle_show_geo is not None:
|
||||
geo_btn = Gtk.Button(
|
||||
icon_name="starred-symbolic" if show_geo else "non-starred-symbolic",
|
||||
tooltip_text="Always show bearing/distance overlay" if not show_geo
|
||||
else "Only show overlay on hover",
|
||||
)
|
||||
geo_btn.add_css_class("flat")
|
||||
geo_btn.connect("clicked", lambda _b: on_toggle_show_geo())
|
||||
box.append(geo_btn)
|
||||
|
||||
if on_toggle_alive is not None:
|
||||
alive_btn = Gtk.Button(
|
||||
icon_name="object-select-symbolic" if alive else "action-unavailable-symbolic",
|
||||
tooltip_text="Mark destroyed" if alive else "Mark alive",
|
||||
)
|
||||
alive_btn.add_css_class("flat")
|
||||
alive_btn.connect("clicked", lambda _b: on_toggle_alive())
|
||||
box.append(alive_btn)
|
||||
|
||||
if on_toggle_hidden is not None:
|
||||
hide_btn = Gtk.Button(
|
||||
icon_name="view-reveal-symbolic" if hidden else "view-conceal-symbolic",
|
||||
tooltip_text="Show on map" if hidden else "Hide from map",
|
||||
)
|
||||
hide_btn.add_css_class("flat")
|
||||
hide_btn.connect("clicked", lambda _b: on_toggle_hidden())
|
||||
box.append(hide_btn)
|
||||
|
||||
if on_remove is not None:
|
||||
rm_btn = Gtk.Button(icon_name="user-trash-symbolic", tooltip_text="Remove")
|
||||
rm_btn.add_css_class("flat")
|
||||
rm_btn.connect("clicked", lambda _b: on_remove())
|
||||
box.append(rm_btn)
|
||||
|
||||
return box
|
||||
|
||||
|
||||
def _location_status(obj) -> str:
|
||||
if obj.coord is not None:
|
||||
return obj.coord.label()
|
||||
if obj.location.potential_coords:
|
||||
return f"ambiguous ({len(obj.location.potential_coords)} candidates)"
|
||||
if obj.location.desc_raw:
|
||||
return "from description"
|
||||
return "not set"
|
||||
|
||||
|
||||
_FIRING_CARD_CSS = """
|
||||
.firing-card { transition: background-color 150ms ease, border-color 150ms ease; }
|
||||
.firing-card-hovered { background-color: alpha(@accent_color, 0.12); }
|
||||
.firing-card-selected { border: 2px solid @accent_color; }
|
||||
"""
|
||||
|
||||
|
||||
def _install_css() -> None:
|
||||
provider = Gtk.CssProvider()
|
||||
provider.load_from_string(_FIRING_CARD_CSS)
|
||||
Gtk.StyleContext.add_provider_for_display(
|
||||
Gdk.Display.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
|
||||
)
|
||||
|
||||
|
||||
class MainWindow(Adw.ApplicationWindow):
|
||||
def __init__(self, app: Adw.Application) -> None:
|
||||
super().__init__(application=app, title="IronNest Assist")
|
||||
self.set_default_size(1100, 750)
|
||||
_install_css()
|
||||
|
||||
self.board = Board()
|
||||
|
||||
self.toast_overlay = Adw.ToastOverlay()
|
||||
self.set_content(self.toast_overlay)
|
||||
|
||||
toolbar_view = Adw.ToolbarView()
|
||||
self.toast_overlay.set_child(toolbar_view)
|
||||
|
||||
header = Adw.HeaderBar()
|
||||
header.set_title_widget(Gtk.Box()) # drop the window-title label, header's crowded
|
||||
toolbar_view.add_top_bar(header)
|
||||
|
||||
save_btn = Gtk.Button(icon_name="document-save-symbolic")
|
||||
save_btn.set_tooltip_text("Save board to file")
|
||||
save_btn.connect("clicked", lambda _b: self._save_to_file())
|
||||
header.pack_start(save_btn)
|
||||
|
||||
load_btn = Gtk.Button(icon_name="document-open-symbolic")
|
||||
load_btn.set_tooltip_text("Load board from file")
|
||||
load_btn.connect("clicked", lambda _b: self._load_from_file())
|
||||
header.pack_start(load_btn)
|
||||
|
||||
clip_btn = Gtk.Button(icon_name="edit-paste-symbolic")
|
||||
clip_btn.set_tooltip_text("Fetch screenshot from clipboard (Ctrl+P)")
|
||||
clip_btn.connect("clicked", lambda _b: self._fetch_clipboard())
|
||||
header.pack_start(clip_btn)
|
||||
|
||||
header.pack_start(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL))
|
||||
|
||||
for label, popover_builder in (
|
||||
("Nest", self._build_nest_popover),
|
||||
("Spotters", self._build_spotters_popover),
|
||||
("Reference Points", self._build_rp_popover),
|
||||
("Targets", self._build_targets_popover),
|
||||
):
|
||||
header.pack_start(self._make_menu_button(label, popover_builder))
|
||||
|
||||
strike_btn = Gtk.Button(icon_name="find-location-symbolic")
|
||||
strike_btn.set_tooltip_text("Add strike")
|
||||
strike_btn.connect("clicked", lambda _b: self._add_strike())
|
||||
header.pack_start(strike_btn)
|
||||
|
||||
firing_btn = Gtk.Button(icon_name="sidebar-show-right-symbolic")
|
||||
firing_btn.set_tooltip_text("Firing commands")
|
||||
firing_btn.connect("clicked", lambda _b: self._toggle_firing_panel())
|
||||
header.pack_end(firing_btn)
|
||||
|
||||
self.cursor_label = Gtk.Label(xalign=1)
|
||||
self.cursor_label.add_css_class("dim-label")
|
||||
self.cursor_label.add_css_class("numeric")
|
||||
header.pack_end(self.cursor_label) # packed after firing_btn -> sits to its left
|
||||
|
||||
self.canvas = GridCanvas(self.board)
|
||||
self.firing_panel = FiringPanel(
|
||||
self.board,
|
||||
on_change=self._refresh,
|
||||
on_select=self._set_selection,
|
||||
on_edit_position=self._edit_target_position,
|
||||
on_set_position=self._start_target_placement,
|
||||
on_remove=self._remove_target_via_panel,
|
||||
on_toggle_hide_dead_map=self._on_toggle_hide_dead_map,
|
||||
)
|
||||
self.canvas.on_select = self._set_selection
|
||||
self.canvas.on_hover_change = self._on_map_hover_change
|
||||
self.canvas.on_cursor_move = self._on_cursor_move
|
||||
self.canvas.on_right_click = self._on_map_right_click
|
||||
|
||||
self.split_view = Adw.OverlaySplitView()
|
||||
self.split_view.set_content(self.canvas)
|
||||
self.split_view.set_sidebar(self.firing_panel)
|
||||
self.split_view.set_sidebar_position(Gtk.PackType.END)
|
||||
self.split_view.set_min_sidebar_width(280)
|
||||
self.split_view.set_max_sidebar_width(360)
|
||||
self.split_view.set_show_sidebar(False)
|
||||
toolbar_view.set_content(self.split_view)
|
||||
|
||||
controller = Gtk.ShortcutController()
|
||||
controller.add_shortcut(
|
||||
Gtk.Shortcut.new(
|
||||
Gtk.ShortcutTrigger.parse_string("<Control>p"),
|
||||
Gtk.CallbackAction.new(lambda *_a: self._fetch_clipboard() or True),
|
||||
)
|
||||
)
|
||||
self.add_controller(controller)
|
||||
|
||||
# -- generic helpers -----------------------------------------------------
|
||||
def _make_menu_button(self, label: str, build_popover) -> Gtk.MenuButton:
|
||||
"""build_popover(rebuild) returns the popover's content widget;
|
||||
rebuild() lets a row's own callback refresh the popover in place
|
||||
(e.g. after toggling hidden, or removing an item) instead of only
|
||||
refreshing next time it's reopened."""
|
||||
button = Gtk.MenuButton(label=label)
|
||||
popover = Gtk.Popover()
|
||||
popover.set_size_request(280, -1)
|
||||
button.set_popover(popover)
|
||||
|
||||
def rebuild():
|
||||
popover.set_child(build_popover(rebuild))
|
||||
|
||||
popover.connect("show", lambda _p: rebuild())
|
||||
return button
|
||||
|
||||
def toast(self, message: str) -> None:
|
||||
self.toast_overlay.add_toast(Adw.Toast(title=message, timeout=3))
|
||||
|
||||
# -- save / load -----------------------------------------------------------
|
||||
def _save_to_file(self) -> None:
|
||||
dialog = Gtk.FileDialog(initial_name="board.json")
|
||||
dialog.save(self, None, self._on_save_finish)
|
||||
|
||||
def _on_save_finish(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> None:
|
||||
try:
|
||||
gfile = dialog.save_finish(result)
|
||||
except GLib.Error as exc:
|
||||
if exc.matches(Gtk.dialog_error_quark(), Gtk.DialogError.DISMISSED):
|
||||
return
|
||||
self.toast(f"Save failed: {exc.message}")
|
||||
return
|
||||
|
||||
data = json.dumps(self.board.to_dict(), indent=2)
|
||||
try:
|
||||
gfile.replace_contents(
|
||||
data.encode("utf-8"), None, False, Gio.FileCreateFlags.NONE, None
|
||||
)
|
||||
except GLib.Error as exc:
|
||||
self.toast(f"Save failed: {exc.message}")
|
||||
return
|
||||
self.toast(f"Saved to {gfile.get_path()}")
|
||||
|
||||
def _load_from_file(self) -> None:
|
||||
dialog = Gtk.FileDialog()
|
||||
dialog.open(self, None, self._on_load_finish)
|
||||
|
||||
def _on_load_finish(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> None:
|
||||
try:
|
||||
gfile = dialog.open_finish(result)
|
||||
except GLib.Error as exc:
|
||||
if exc.matches(Gtk.dialog_error_quark(), Gtk.DialogError.DISMISSED):
|
||||
return
|
||||
self.toast(f"Load failed: {exc.message}")
|
||||
return
|
||||
|
||||
try:
|
||||
ok, contents, _etag = gfile.load_contents(None)
|
||||
data = json.loads(contents.decode("utf-8"))
|
||||
self.board.load_from_dict(data)
|
||||
except (GLib.Error, json.JSONDecodeError, KeyError, ValueError) as exc:
|
||||
self.toast(f"Load failed: {exc}")
|
||||
return
|
||||
|
||||
self._refresh()
|
||||
self.toast(f"Loaded {gfile.get_path()}")
|
||||
|
||||
# -- OCR plumbing -----------------------------------------------------------
|
||||
def _fetch_clipboard(self) -> None:
|
||||
"""Header button / Ctrl+P: universal fetch — run OCR and merge everything found."""
|
||||
self._run_ocr_from_clipboard(self._merge_all)
|
||||
|
||||
def _run_ocr_from_clipboard(self, on_parsed) -> None:
|
||||
"""Read the clipboard image, OCR it, and call on_parsed(ParsedInfo)."""
|
||||
clipboard = Gdk.Display.get_default().get_clipboard()
|
||||
clipboard.read_texture_async(None, lambda cb, res: self._on_ocr_texture_ready(res, on_parsed))
|
||||
|
||||
def _on_ocr_texture_ready(self, result: Gio.AsyncResult, on_parsed) -> None:
|
||||
clipboard = Gdk.Display.get_default().get_clipboard()
|
||||
try:
|
||||
texture = clipboard.read_texture_finish(result)
|
||||
except GLib.Error as exc:
|
||||
self.toast(f"No image on clipboard ({exc.message}).")
|
||||
return
|
||||
if texture is None:
|
||||
self.toast("Clipboard has no image. Copy a screenshot first.")
|
||||
return
|
||||
|
||||
try:
|
||||
pil_image = Image.open(io.BytesIO(texture.save_to_png_bytes().get_data()))
|
||||
info = ocr.run(pil_image)
|
||||
except Exception as exc: # OCR/parsing hiccups shouldn't crash the app
|
||||
self.toast(f"OCR failed: {exc}")
|
||||
return
|
||||
|
||||
on_parsed(info)
|
||||
|
||||
def _merge_all(self, info: "ocr.ParsedInfo") -> None:
|
||||
changed = []
|
||||
if info.nest_coord is not None:
|
||||
self.board.nest.coord = info.nest_coord
|
||||
changed.append("Nest")
|
||||
changed.extend(self._merge_spotters(info, toast=False))
|
||||
changed.extend(self._merge_reference_points(info, toast=False))
|
||||
changed.extend(self._merge_targets(info, toast=False))
|
||||
|
||||
if not changed:
|
||||
self.toast("No relevant info found in screenshot.")
|
||||
else:
|
||||
self.toast("Merged from screenshot: " + ", ".join(changed))
|
||||
self._refresh()
|
||||
|
||||
def _merge_spotters(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]:
|
||||
changed = []
|
||||
for spotter_id, coord in info.spotters.items():
|
||||
existing = next((s for s in self.board.spotters if s.id == spotter_id), None)
|
||||
if existing is not None:
|
||||
existing.coord = coord
|
||||
else:
|
||||
existing = self.board.add_spotter(coord, spotter_id)
|
||||
changed.append(existing.name)
|
||||
|
||||
if toast:
|
||||
self.toast("No spotters found in screenshot." if not changed
|
||||
else "Loaded from screenshot: " + ", ".join(changed))
|
||||
self._refresh()
|
||||
return changed
|
||||
|
||||
def _merge_parsed_location(self, existing, raw: str, clues, coord) -> None:
|
||||
"""Refresh desc/clues from a fresh OCR read, but never clobber a
|
||||
coord the entity already has — from a prior resolve, an earlier
|
||||
screenshot's Grid ref, or a manual Edit Pos override. Re-reading
|
||||
the same intel later shouldn't undo that; only apply the new coord
|
||||
if there wasn't one already."""
|
||||
existing.location.desc_raw = raw
|
||||
existing.location.clues = clues
|
||||
if existing.coord is None:
|
||||
existing.coord = coord
|
||||
|
||||
def _merge_reference_points(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]:
|
||||
changed = []
|
||||
for name, (raw, clues, coord) in info.reference_points.items():
|
||||
existing = next((rp for rp in self.board.reference_points if rp.rp_name == name), None)
|
||||
if existing is not None:
|
||||
self._merge_parsed_location(existing, raw, clues, coord)
|
||||
else:
|
||||
existing = self.board.add_reference_point(
|
||||
Location(coord=coord, desc_raw=raw, clues=clues), name=name
|
||||
)
|
||||
changed.append(existing.name)
|
||||
|
||||
if toast:
|
||||
self.toast("No reference points found in screenshot." if not changed
|
||||
else "Loaded from screenshot: " + ", ".join(changed))
|
||||
self._refresh()
|
||||
return changed
|
||||
|
||||
def _merge_targets(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]:
|
||||
changed = []
|
||||
for (target_type, target_id), (raw, clues, coord) in info.targets.items():
|
||||
existing = next(
|
||||
(t for t in self.board.targets if t.type == target_type and t.id == target_id), None
|
||||
)
|
||||
if existing is not None:
|
||||
self._merge_parsed_location(existing, raw, clues, coord)
|
||||
else:
|
||||
existing = self.board.add_target(
|
||||
target_type, Location(coord=coord, desc_raw=raw, clues=clues), id_=target_id
|
||||
)
|
||||
changed.append(existing.name)
|
||||
|
||||
# "SupplyCache#1 Destroyed." etc. Mark it dead if we already know
|
||||
# it; if this destruction report is the *first* we've heard of it
|
||||
# (never separately spotted with a position), still record it as a
|
||||
# dead, position-unknown target rather than losing the report —
|
||||
# better a target with no coord than no record it existed at all.
|
||||
for target_type, target_id in info.destroyed:
|
||||
existing = next(
|
||||
(t for t in self.board.targets if t.type == target_type and t.id == target_id), None
|
||||
)
|
||||
is_new = existing is None
|
||||
if existing is None:
|
||||
existing = self.board.add_target(target_type, id_=target_id)
|
||||
if existing.alive:
|
||||
existing.alive = False
|
||||
suffix = " (destroyed, position unknown)" if is_new else " (destroyed)"
|
||||
changed.append(f"{existing.name}{suffix}")
|
||||
|
||||
if toast:
|
||||
self.toast("No targets found in screenshot." if not changed
|
||||
else "Loaded from screenshot: " + ", ".join(changed))
|
||||
self._refresh()
|
||||
return changed
|
||||
|
||||
def _set_nest_from_screenshot_info(self, info: "ocr.ParsedInfo") -> None:
|
||||
if info.nest_coord is None:
|
||||
self.toast("Nest position not found in screenshot.")
|
||||
return
|
||||
self.board.nest.coord = info.nest_coord
|
||||
self._refresh()
|
||||
self.toast(f"Nest set to {info.nest_coord.label()} from screenshot.")
|
||||
|
||||
def _set_spotter_from_screenshot_info(self, info: "ocr.ParsedInfo", spotter) -> None:
|
||||
coord = info.spotters.get(spotter.id)
|
||||
if coord is None:
|
||||
self.toast(f"{spotter.name} not found in screenshot.")
|
||||
return
|
||||
spotter.coord = coord
|
||||
self._refresh()
|
||||
self.toast(f"{spotter.name} set to {coord.label()} from screenshot.")
|
||||
|
||||
def _set_rp_from_screenshot_info(self, info: "ocr.ParsedInfo", rp) -> None:
|
||||
result = info.reference_points.get(rp.rp_name)
|
||||
if result is None:
|
||||
self.toast(f"{rp.name} not found in screenshot.")
|
||||
return
|
||||
raw, clues, coord = result
|
||||
self._merge_parsed_location(rp, raw, clues, coord)
|
||||
self._refresh()
|
||||
self.toast(f"{rp.name} set from screenshot.")
|
||||
|
||||
def _set_target_from_screenshot_info(self, info: "ocr.ParsedInfo", target) -> None:
|
||||
result = info.targets.get((target.type, target.id))
|
||||
if result is None:
|
||||
self.toast(f"{target.name} not found in screenshot.")
|
||||
return
|
||||
raw, clues, coord = result
|
||||
self._merge_parsed_location(target, raw, clues, coord)
|
||||
self._refresh()
|
||||
self.toast(f"{target.name} set from screenshot.")
|
||||
|
||||
def _open_coord_dialog(
|
||||
self, *, title, on_submit, show_id=False, show_type=False, id_placeholder=None,
|
||||
initial_location=None, initial_id=None, initial_type=None,
|
||||
):
|
||||
dialog = CoordDialog(
|
||||
title=title,
|
||||
on_submit=on_submit,
|
||||
show_id=show_id,
|
||||
show_type=show_type,
|
||||
id_placeholder=id_placeholder,
|
||||
initial_location=initial_location,
|
||||
initial_id=initial_id,
|
||||
initial_type=initial_type,
|
||||
)
|
||||
dialog.present(self)
|
||||
|
||||
def _refresh(self) -> None:
|
||||
"""Single choke point: re-run the solver, then redraw. Every
|
||||
mutation goes through here so newly-unblocked descriptions get a
|
||||
chance to resolve immediately."""
|
||||
solver.resolve_board(self.board)
|
||||
removed = solver.dedupe_generic_targets(self.board)
|
||||
if removed and isinstance(self.canvas.selected, Target) and self.canvas.selected not in self.board.targets:
|
||||
self._set_selection(None) # the selected generic target just got merged away
|
||||
self.canvas.refresh()
|
||||
self.firing_panel.refresh()
|
||||
|
||||
def _on_toggle_hide_dead_map(self, value: bool) -> None:
|
||||
self.canvas.hide_dead_from_map = value
|
||||
self._refresh()
|
||||
|
||||
def _toggle_firing_panel(self) -> None:
|
||||
self.split_view.set_show_sidebar(not self.split_view.get_show_sidebar())
|
||||
|
||||
def _set_selection(self, obj, point=None) -> None:
|
||||
"""Shared by both the map (click a marker) and the firing panel
|
||||
(click a card) so clicking either keeps the other in sync. `point`
|
||||
disambiguates which candidate of an ambiguous target was clicked."""
|
||||
self.canvas.set_selected(obj, point)
|
||||
self.firing_panel.set_selected(obj if isinstance(obj, Target) else None, point)
|
||||
if isinstance(obj, Target) and not self.split_view.get_show_sidebar():
|
||||
self.split_view.set_show_sidebar(True)
|
||||
|
||||
def _on_map_hover_change(self, obj, point=None) -> None:
|
||||
self.firing_panel.set_hovered(obj if isinstance(obj, Target) else None, point)
|
||||
|
||||
def _on_cursor_move(self, point_km) -> None:
|
||||
if point_km is None:
|
||||
self.cursor_label.set_label("")
|
||||
return
|
||||
coord = solver.point_to_coord(point_km)
|
||||
coord_text = coord.label() if coord is not None else "off map"
|
||||
nest = self.board.nest
|
||||
if nest.coord is not None:
|
||||
az = ballistics.bearing_deg_point(nest.coord.as_fraction(), point_km)
|
||||
dist = ballistics.distance_km_point(nest.coord.as_fraction(), point_km)
|
||||
self.cursor_label.set_label(f"AZ {az:05.1f}° {dist:5.2f}km {coord_text}")
|
||||
else:
|
||||
self.cursor_label.set_label(coord_text)
|
||||
|
||||
# -- Nest -----------------------------------------------------------------
|
||||
def _build_nest_popover(self, rebuild) -> Gtk.Widget:
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||
box.set_margin_top(6)
|
||||
box.set_margin_bottom(6)
|
||||
|
||||
nest = self.board.nest
|
||||
box.append(_row(
|
||||
f"Nest — {_location_status(nest)}",
|
||||
on_input=lambda: self._open_coord_dialog(
|
||||
title="Set Nest coordinates",
|
||||
on_submit=lambda loc, _id, _t: self._apply_and_refresh(nest, loc),
|
||||
initial_location=nest.location,
|
||||
),
|
||||
on_screenshot=lambda: self._run_ocr_from_clipboard(self._set_nest_from_screenshot_info),
|
||||
hidden=nest.hidden,
|
||||
on_toggle_hidden=lambda: self._toggle_hidden(nest, rebuild),
|
||||
show_geo=nest.show_geo_desc,
|
||||
on_toggle_show_geo=lambda: self._toggle_show_geo(nest, rebuild),
|
||||
))
|
||||
return box
|
||||
|
||||
# -- Spotters --------------------------------------------------------------
|
||||
def _build_spotters_popover(self, rebuild) -> Gtk.Widget:
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
box.set_margin_top(6)
|
||||
box.set_margin_bottom(6)
|
||||
|
||||
load_btn = Gtk.Button(label="Load all from screenshot")
|
||||
load_btn.set_margin_start(8)
|
||||
load_btn.set_margin_end(8)
|
||||
load_btn.set_margin_bottom(4)
|
||||
load_btn.connect("clicked", lambda _b: self._run_ocr_from_clipboard(self._merge_spotters))
|
||||
box.append(load_btn)
|
||||
box.append(Gtk.Separator())
|
||||
|
||||
for sp in list(self.board.spotters):
|
||||
box.append(_row(
|
||||
f"{sp.name} — {_location_status(sp)}",
|
||||
on_input=lambda sp=sp: self._open_coord_dialog(
|
||||
title=f"Set {sp.name} coordinates",
|
||||
on_submit=lambda loc, _id, _t, sp=sp: self._apply_and_refresh(sp, loc),
|
||||
initial_location=sp.location,
|
||||
),
|
||||
on_screenshot=lambda sp=sp: self._run_ocr_from_clipboard(
|
||||
lambda info, sp=sp: self._set_spotter_from_screenshot_info(info, sp)
|
||||
),
|
||||
on_remove=lambda sp=sp: self._remove_spotter(sp, rebuild),
|
||||
hidden=sp.hidden,
|
||||
on_toggle_hidden=lambda sp=sp: self._toggle_hidden(sp, rebuild),
|
||||
show_geo=sp.show_geo_desc,
|
||||
on_toggle_show_geo=lambda sp=sp: self._toggle_show_geo(sp, rebuild),
|
||||
))
|
||||
|
||||
box.append(Gtk.Separator())
|
||||
box.append(_row(
|
||||
"Add spotter",
|
||||
on_input=lambda: self._open_coord_dialog(
|
||||
title="Add spotter",
|
||||
on_submit=lambda loc, id_, _t: self._add_spotter(loc, id_),
|
||||
show_id=True,
|
||||
id_placeholder=f"ID (blank = auto, next: {self.board.next_spotter_id()})",
|
||||
),
|
||||
on_screenshot=lambda: self._run_ocr_from_clipboard(self._merge_spotters),
|
||||
))
|
||||
return box
|
||||
|
||||
def _add_spotter(self, location: Location, id_text: str | None) -> None:
|
||||
parsed_id = None
|
||||
if id_text:
|
||||
try:
|
||||
parsed_id = int(id_text)
|
||||
except ValueError:
|
||||
self.toast(f"Spotter ID must be a whole number, got {id_text!r}.")
|
||||
return
|
||||
self.board.add_spotter(location, parsed_id)
|
||||
self._refresh()
|
||||
|
||||
def _remove_spotter(self, sp, rebuild) -> None:
|
||||
self.board.remove_spotter(sp)
|
||||
self._refresh()
|
||||
rebuild()
|
||||
|
||||
# -- Reference Points --------------------------------------------------------
|
||||
def _build_rp_popover(self, rebuild) -> Gtk.Widget:
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
box.set_margin_top(6)
|
||||
box.set_margin_bottom(6)
|
||||
|
||||
load_btn = Gtk.Button(label="Load all from screenshot")
|
||||
load_btn.set_margin_start(8)
|
||||
load_btn.set_margin_end(8)
|
||||
load_btn.set_margin_bottom(4)
|
||||
load_btn.connect("clicked", lambda _b: self._run_ocr_from_clipboard(self._merge_reference_points))
|
||||
box.append(load_btn)
|
||||
box.append(Gtk.Separator())
|
||||
|
||||
for rp in list(self.board.reference_points):
|
||||
box.append(_row(
|
||||
f"{rp.name} — {_location_status(rp)}",
|
||||
on_input=lambda rp=rp: self._open_coord_dialog(
|
||||
title=f"Set {rp.name} coordinates",
|
||||
on_submit=lambda loc, _id, _t, rp=rp: self._apply_and_refresh(rp, loc),
|
||||
initial_location=rp.location,
|
||||
),
|
||||
on_screenshot=lambda rp=rp: self._run_ocr_from_clipboard(
|
||||
lambda info, rp=rp: self._set_rp_from_screenshot_info(info, rp)
|
||||
),
|
||||
on_remove=lambda rp=rp: self._remove_rp(rp, rebuild),
|
||||
hidden=rp.hidden,
|
||||
on_toggle_hidden=lambda rp=rp: self._toggle_hidden(rp, rebuild),
|
||||
show_geo=rp.show_geo_desc,
|
||||
on_toggle_show_geo=lambda rp=rp: self._toggle_show_geo(rp, rebuild),
|
||||
))
|
||||
|
||||
box.append(Gtk.Separator())
|
||||
box.append(_row(
|
||||
"Add RP",
|
||||
on_input=lambda: self._open_coord_dialog(
|
||||
title="Add reference point",
|
||||
on_submit=lambda loc, _id, _t: self._add_rp(loc),
|
||||
),
|
||||
on_screenshot=lambda: self._run_ocr_from_clipboard(self._merge_reference_points),
|
||||
))
|
||||
return box
|
||||
|
||||
def _add_rp(self, location: Location) -> None:
|
||||
self.board.add_reference_point(location)
|
||||
self._refresh()
|
||||
|
||||
def _remove_rp(self, rp, rebuild) -> None:
|
||||
self.board.remove_reference_point(rp)
|
||||
self._refresh()
|
||||
rebuild()
|
||||
|
||||
# -- Targets -----------------------------------------------------------------
|
||||
def _build_targets_popover(self, rebuild) -> Gtk.Widget:
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
box.set_margin_top(6)
|
||||
box.set_margin_bottom(6)
|
||||
|
||||
load_btn = Gtk.Button(label="Load all from screenshot")
|
||||
load_btn.set_margin_start(8)
|
||||
load_btn.set_margin_end(8)
|
||||
load_btn.set_margin_bottom(4)
|
||||
load_btn.connect("clicked", lambda _b: self._run_ocr_from_clipboard(self._merge_targets))
|
||||
box.append(load_btn)
|
||||
box.append(Gtk.Separator())
|
||||
|
||||
for t in list(self.board.targets):
|
||||
box.append(_row(
|
||||
f"{t.name} — {_location_status(t)}",
|
||||
on_input=lambda t=t: self._open_coord_dialog(
|
||||
title=f"Set {t.name} coordinates",
|
||||
on_submit=lambda loc, _id, _t2, t=t: self._apply_and_refresh(t, loc),
|
||||
initial_location=t.location,
|
||||
),
|
||||
on_screenshot=lambda t=t: self._run_ocr_from_clipboard(
|
||||
lambda info, t=t: self._set_target_from_screenshot_info(info, t)
|
||||
),
|
||||
on_remove=lambda t=t: self._remove_target(t, rebuild),
|
||||
hidden=t.hidden,
|
||||
on_toggle_hidden=lambda t=t: self._toggle_hidden(t, rebuild),
|
||||
show_geo=t.show_geo_desc,
|
||||
on_toggle_show_geo=lambda t=t: self._toggle_show_geo(t, rebuild),
|
||||
alive=t.alive,
|
||||
on_toggle_alive=lambda t=t: self._toggle_alive(t, rebuild),
|
||||
))
|
||||
|
||||
box.append(Gtk.Separator())
|
||||
box.append(_row(
|
||||
"Add target",
|
||||
on_input=lambda: self._open_coord_dialog(
|
||||
title="Add target",
|
||||
on_submit=lambda loc, id_, type_: self._add_target(loc, id_, type_),
|
||||
show_id=True,
|
||||
show_type=True,
|
||||
),
|
||||
on_screenshot=lambda: self._run_ocr_from_clipboard(self._merge_targets),
|
||||
))
|
||||
return box
|
||||
|
||||
def _add_target(self, location: Location, id_, type_) -> None:
|
||||
self.board.add_target(type_ or TargetType.UNKNOWN, location, id_)
|
||||
self._refresh()
|
||||
|
||||
def _add_strike(self) -> None:
|
||||
"""Two-step add: pick a shell (for blast radius) first, then click
|
||||
the map to place it — a Strike is just a Target with
|
||||
TargetType.STRIKE and an explicit shell, and its position is set
|
||||
by clicking, not typing in coordinates."""
|
||||
dialog = Adw.Dialog(title="Add Strike", content_width=340, content_height=200)
|
||||
toolbar_view = Adw.ToolbarView()
|
||||
dialog.set_child(toolbar_view)
|
||||
toolbar_view.add_top_bar(Adw.HeaderBar())
|
||||
|
||||
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16,
|
||||
margin_top=16, margin_bottom=16, margin_start=16, margin_end=16)
|
||||
group = Adw.PreferencesGroup(title="Shell (blast radius)")
|
||||
shells = list(Shell)
|
||||
shell_row = Adw.ComboRow(title="Shell", model=Gtk.StringList.new([s.name for s in shells]))
|
||||
shell_row.set_selected(shells.index(Shell.HCHE))
|
||||
group.add(shell_row)
|
||||
outer.append(group)
|
||||
|
||||
next_btn = Gtk.Button(label="Next: click the map to place it")
|
||||
next_btn.add_css_class("suggested-action")
|
||||
next_btn.add_css_class("pill")
|
||||
next_btn.set_halign(Gtk.Align.CENTER)
|
||||
|
||||
def on_next(_b):
|
||||
chosen_shell = shells[shell_row.get_selected()]
|
||||
dialog.close()
|
||||
self.canvas.start_placement(
|
||||
lambda coord: self._add_strike_at(coord, chosen_shell),
|
||||
preview_radius_km=chosen_shell.blast_radius_km,
|
||||
)
|
||||
self.toast(f"Click the map to place the strike ({chosen_shell.name}) — Esc to cancel.")
|
||||
|
||||
next_btn.connect("clicked", on_next)
|
||||
outer.append(next_btn)
|
||||
toolbar_view.set_content(outer)
|
||||
dialog.present(self)
|
||||
|
||||
def _add_strike_at(self, coord, shell: Shell) -> None:
|
||||
target = self.board.add_target(TargetType.STRIKE, coord)
|
||||
target.shell = shell
|
||||
self.board.reorder_target(target, 0) # new strikes go to the front of the list
|
||||
self._refresh()
|
||||
|
||||
def _start_target_placement(self, target) -> None:
|
||||
"""Firing card's "set position on map" button: click-to-place/
|
||||
reposition, previewing the target's blast radius if its shell has
|
||||
a known one (mirrors the Add Strike flow, generalized to any
|
||||
target)."""
|
||||
self.canvas.start_placement(
|
||||
lambda coord: self._apply_and_refresh(target, Location.from_coord(coord)),
|
||||
preview_radius_km=target.effective_shell.blast_radius_km,
|
||||
)
|
||||
self.toast(f"Click the map to place {target.name} — Esc to cancel.")
|
||||
|
||||
def _on_map_right_click(self, coord, x: float, y: float) -> None:
|
||||
"""Right-click anywhere on the map: quick-add a Target or Strike
|
||||
right there, no dialog — for when you already know exactly where
|
||||
you're pointing and don't need to type coordinates."""
|
||||
popover = Gtk.Popover()
|
||||
popover.set_parent(self.canvas)
|
||||
popover.set_pointing_to(Gdk.Rectangle(x=int(x), y=int(y), width=1, height=1))
|
||||
popover.connect("closed", lambda _p: popover.unparent())
|
||||
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
|
||||
margin_top=6, margin_bottom=6, margin_start=6, margin_end=6)
|
||||
|
||||
def add_target(_b):
|
||||
self.board.add_target(TargetType.UNKNOWN, coord)
|
||||
self._refresh()
|
||||
popover.popdown()
|
||||
|
||||
def add_strike(_b):
|
||||
target = self.board.add_target(TargetType.STRIKE, coord)
|
||||
self.board.reorder_target(target, 0) # new strikes go to the front of the list
|
||||
self._refresh()
|
||||
popover.popdown()
|
||||
|
||||
target_btn = Gtk.Button(label=f"Add target at {coord.label()}")
|
||||
target_btn.add_css_class("flat")
|
||||
target_btn.connect("clicked", add_target)
|
||||
box.append(target_btn)
|
||||
|
||||
strike_btn = Gtk.Button(label=f"Add strike at {coord.label()}")
|
||||
strike_btn.add_css_class("flat")
|
||||
strike_btn.connect("clicked", add_strike)
|
||||
box.append(strike_btn)
|
||||
|
||||
popover.set_child(box)
|
||||
popover.popup()
|
||||
|
||||
def _remove_target(self, target, rebuild) -> None:
|
||||
self.board.remove_target(target)
|
||||
self._refresh()
|
||||
rebuild()
|
||||
|
||||
def _remove_target_via_panel(self, target) -> None:
|
||||
"""Same as _remove_target, but for the firing panel's own delete
|
||||
button (Strikes) — no popover `rebuild` callback to call there."""
|
||||
self.board.remove_target(target)
|
||||
self._refresh()
|
||||
|
||||
def _toggle_alive(self, target, rebuild) -> None:
|
||||
target.alive = not target.alive
|
||||
self._refresh()
|
||||
rebuild()
|
||||
|
||||
# -- shared -------------------------------------------------------------
|
||||
def _apply_and_refresh(self, obj, location: Location) -> None:
|
||||
_apply_location(obj, location)
|
||||
self._refresh()
|
||||
|
||||
def _edit_target_position(self, target) -> None:
|
||||
"""Firing card's "Edit pos" button: manual coord overrides whatever
|
||||
the solver had (definitive or ambiguous) — once set it's sticky,
|
||||
_merge_parsed_location won't touch it on a later re-screenshot.
|
||||
Also lets you change id/type here, prefilled with the current
|
||||
values — blank id means "leave it as-is", not "auto-assign new"."""
|
||||
self._open_coord_dialog(
|
||||
title=f"Edit {target.name}",
|
||||
on_submit=lambda loc, id_, type_: self._apply_target_edit(target, loc, id_, type_),
|
||||
initial_location=target.location,
|
||||
show_id=True,
|
||||
show_type=True,
|
||||
id_placeholder=f"ID (current: {target.id})",
|
||||
initial_id=target.id,
|
||||
initial_type=target.type,
|
||||
)
|
||||
|
||||
def _apply_target_edit(self, target, location: Location, id_, type_) -> None:
|
||||
if id_:
|
||||
target.id = id_
|
||||
if type_ is not None:
|
||||
target.type = type_
|
||||
self._apply_and_refresh(target, location)
|
||||
|
||||
def _toggle_hidden(self, obj, rebuild) -> None:
|
||||
obj.hidden = not obj.hidden
|
||||
self._refresh()
|
||||
rebuild()
|
||||
|
||||
def _toggle_show_geo(self, obj, rebuild) -> None:
|
||||
obj.show_geo_desc = not obj.show_geo_desc
|
||||
self._refresh()
|
||||
rebuild()
|
||||
|
||||
|
||||
class IronNestApp(Adw.Application):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(application_id=APP_ID)
|
||||
|
||||
def do_activate(self) -> None:
|
||||
win = self.props.active_window
|
||||
if win is None:
|
||||
win = MainWindow(self)
|
||||
win.present()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
app = IronNestApp()
|
||||
return app.run(None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
46
src/ironnest_assist/ballistics.py
Normal file
46
src/ironnest_assist/ballistics.py
Normal file
@ -0,0 +1,46 @@
|
||||
"""Firing-solution math, from High Command's gunnery tables.
|
||||
|
||||
Distances/bearings reuse the same board-units-are-km convention as
|
||||
solver.py. Powder charge controls elevation: more charge, lower arc for
|
||||
the same distance, up to MAX_POWDER_CHARGE; min_powder_charge() is the
|
||||
least charge that can still reach a given distance at all.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from .models import Coord
|
||||
|
||||
MAX_POWDER_CHARGE = 6
|
||||
|
||||
|
||||
def distance_km_point(a: tuple[float, float], b: tuple[float, float]) -> float:
|
||||
return math.hypot(b[0] - a[0], b[1] - a[1])
|
||||
|
||||
|
||||
def bearing_deg_point(a: tuple[float, float], b: tuple[float, float]) -> float:
|
||||
"""Compass bearing from a to b: 0 = north (+row), 90 = east (+col),
|
||||
matching solver.py's convention — the inverse of
|
||||
solver.point_from_bearing_distance()."""
|
||||
return math.degrees(math.atan2(b[0] - a[0], b[1] - a[1])) % 360
|
||||
|
||||
|
||||
def distance_km(a: Coord, b: Coord) -> float:
|
||||
return distance_km_point(a.as_fraction(), b.as_fraction())
|
||||
|
||||
|
||||
def bearing_deg(a: Coord, b: Coord) -> float:
|
||||
return bearing_deg_point(a.as_fraction(), b.as_fraction())
|
||||
|
||||
|
||||
def min_powder_charge(dist_km: float, eps: float = 1e-9) -> int:
|
||||
n = dist_km / 5
|
||||
rounded = round(n)
|
||||
if abs(n - rounded) < eps:
|
||||
return max(1, rounded)
|
||||
return math.ceil(n)
|
||||
|
||||
|
||||
def elevation_deg(dist_km: float, charges: int) -> float:
|
||||
return 12 * dist_km / charges
|
||||
279
src/ironnest_assist/coord_dialog.py
Normal file
279
src/ironnest_assist/coord_dialog.py
Normal file
@ -0,0 +1,279 @@
|
||||
"""Modal dialog for entering a Coord, or a free-text relative description.
|
||||
|
||||
Two ways to specify a location, matching the two tabs:
|
||||
- "Exact" — X/Y/x/y fields (+ id/type when adding a target).
|
||||
- "Description" — free-form text box, parsed with the same
|
||||
Bearing/Distance clue grammar the OCR pipeline uses
|
||||
(ocr.parse_clues_from_text). Prefilled with whatever
|
||||
description is already stored, if any.
|
||||
|
||||
Either tab calls on_submit with a Location — from_coord() for Exact,
|
||||
from_desc() for Description — so the caller applies just that half
|
||||
without clobbering the other (a coord and a description can coexist).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
gi.require_version("Gdk", "4.0")
|
||||
from gi.repository import Adw, Gdk, Gtk # noqa: E402
|
||||
|
||||
from . import ocr
|
||||
from .models import LARGE_X, Coord, Location, TargetType
|
||||
|
||||
|
||||
class CoordDialog(Adw.Dialog):
|
||||
"""Emits a Location (and, if enabled, id/type) via on_submit."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
title: str,
|
||||
on_submit: Callable[[Location, str | None, TargetType | None], None],
|
||||
show_id: bool = False,
|
||||
show_type: bool = False,
|
||||
id_placeholder: str | None = None,
|
||||
initial_location: Location | None = None,
|
||||
initial_id: str | None = None,
|
||||
initial_type: TargetType | None = None,
|
||||
) -> None:
|
||||
super().__init__(title=title, content_width=420, content_height=580)
|
||||
self._on_submit = on_submit
|
||||
self._show_id = show_id
|
||||
self._show_type = show_type
|
||||
self._id_placeholder = id_placeholder or "ID (blank = auto)"
|
||||
self._initial_location = initial_location or Location()
|
||||
self._initial_id = initial_id
|
||||
self._initial_type = initial_type
|
||||
|
||||
toolbar_view = Adw.ToolbarView()
|
||||
self.set_child(toolbar_view)
|
||||
toolbar_view.add_top_bar(Adw.HeaderBar())
|
||||
|
||||
stack = Adw.ViewStack()
|
||||
switcher = Adw.ViewSwitcherBar(stack=stack, reveal=True)
|
||||
|
||||
page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
page.append(stack)
|
||||
page.append(switcher)
|
||||
toolbar_view.set_content(page)
|
||||
|
||||
stack.add_titled_with_icon(
|
||||
self._build_exact_tab(), "exact", "Exact", "input-keyboard-symbolic"
|
||||
)
|
||||
stack.add_titled_with_icon(
|
||||
self._build_desc_tab(), "desc", "Description", "text-x-generic-symbolic"
|
||||
)
|
||||
|
||||
# Quick keyboard entry: type e.g. "C433" (X digit×3) to fill and
|
||||
# submit in one go. Y takes '0' to mean 10. Any letter A-T restarts
|
||||
# the sequence, so a typo just means retyping the letter.
|
||||
self._kb_stage = 0
|
||||
key_controller = Gtk.EventControllerKey()
|
||||
key_controller.connect("key-pressed", self._on_key_pressed)
|
||||
self.add_controller(key_controller)
|
||||
|
||||
# Grab keyboard focus onto the dialog itself as soon as it's shown —
|
||||
# otherwise no descendant is focused (we made the picker buttons
|
||||
# non-focusable) so key events never reach our controller at all.
|
||||
self.set_focusable(True)
|
||||
self.connect("map", lambda *_a: self.grab_focus())
|
||||
|
||||
def _make_picker(self, labels, initial_index: int, on_select) -> tuple[Gtk.FlowBox, list]:
|
||||
"""A wrapping row of toggle buttons acting as a radio group."""
|
||||
flow = Gtk.FlowBox()
|
||||
flow.set_selection_mode(Gtk.SelectionMode.NONE)
|
||||
flow.set_homogeneous(True)
|
||||
flow.set_row_spacing(4)
|
||||
flow.set_column_spacing(4)
|
||||
flow.set_max_children_per_line(10)
|
||||
flow.set_min_children_per_line(5)
|
||||
|
||||
buttons = []
|
||||
group_leader = None
|
||||
for i, lbl in enumerate(labels):
|
||||
btn = Gtk.ToggleButton(label=str(lbl))
|
||||
btn.set_size_request(34, 34)
|
||||
btn.set_focusable(False) # keep keyboard focus on the dialog, not the grid
|
||||
if group_leader is None:
|
||||
group_leader = btn
|
||||
else:
|
||||
btn.set_group(group_leader)
|
||||
if i == initial_index:
|
||||
btn.set_active(True)
|
||||
|
||||
def _on_toggled(b, i=i):
|
||||
if b.get_active():
|
||||
on_select(i)
|
||||
|
||||
btn.connect("toggled", _on_toggled)
|
||||
flow.append(btn)
|
||||
buttons.append(btn)
|
||||
return flow, buttons
|
||||
|
||||
def _picker_group(self, title: str, picker: Gtk.FlowBox) -> Gtk.Widget:
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||
heading = Gtk.Label(label=title, xalign=0)
|
||||
heading.add_css_class("heading")
|
||||
box.append(heading)
|
||||
box.append(picker)
|
||||
return box
|
||||
|
||||
def _build_exact_tab(self) -> Gtk.Widget:
|
||||
outer = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
spacing=16,
|
||||
margin_top=16,
|
||||
margin_bottom=16,
|
||||
margin_start=16,
|
||||
margin_end=16,
|
||||
)
|
||||
|
||||
initial = self._initial_location.coord
|
||||
init_X_idx = LARGE_X.index(initial.X) if initial else 0
|
||||
init_Y_idx = (initial.Y - 1) if initial else 0
|
||||
init_x = initial.x if initial else 0
|
||||
init_y = initial.y if initial else 0
|
||||
|
||||
self._X_idx = init_X_idx
|
||||
self._Y_val = init_Y_idx + 1
|
||||
self._x_val = init_x
|
||||
self._y_val = init_y
|
||||
|
||||
X_flow, self._X_buttons = self._make_picker(
|
||||
list(LARGE_X), init_X_idx, lambda i: setattr(self, "_X_idx", i)
|
||||
)
|
||||
Y_flow, self._Y_buttons = self._make_picker(
|
||||
range(1, 11), init_Y_idx, lambda i: setattr(self, "_Y_val", i + 1)
|
||||
)
|
||||
x_flow, self._x_buttons = self._make_picker(
|
||||
range(0, 10), init_x, lambda i: setattr(self, "_x_val", i)
|
||||
)
|
||||
y_flow, self._y_buttons = self._make_picker(
|
||||
range(0, 10), init_y, lambda i: setattr(self, "_y_val", i)
|
||||
)
|
||||
outer.append(self._picker_group("X (A–T)", X_flow))
|
||||
outer.append(self._picker_group("Y (1–10)", Y_flow))
|
||||
outer.append(self._picker_group("x (0–9)", x_flow))
|
||||
outer.append(self._picker_group("y (0–9)", y_flow))
|
||||
|
||||
self.row_id = None
|
||||
self.row_type = None
|
||||
if self._show_id or self._show_type:
|
||||
extra_group = Adw.PreferencesGroup(title="Identity")
|
||||
if self._show_id:
|
||||
self.row_id = Adw.EntryRow(title=self._id_placeholder)
|
||||
if self._initial_id is not None:
|
||||
self.row_id.set_text(str(self._initial_id))
|
||||
extra_group.add(self.row_id)
|
||||
if self._show_type:
|
||||
self.row_type = Adw.ComboRow(
|
||||
title="Type",
|
||||
model=Gtk.StringList.new([t.short for t in TargetType]),
|
||||
)
|
||||
if self._initial_type is not None:
|
||||
self.row_type.set_selected(list(TargetType).index(self._initial_type))
|
||||
extra_group.add(self.row_type)
|
||||
outer.append(extra_group)
|
||||
|
||||
submit = Gtk.Button(label="Set coordinates")
|
||||
submit.add_css_class("suggested-action")
|
||||
submit.add_css_class("pill")
|
||||
submit.set_halign(Gtk.Align.CENTER)
|
||||
submit.connect("clicked", self._on_submit_clicked)
|
||||
outer.append(submit)
|
||||
|
||||
scroller = Gtk.ScrolledWindow(child=outer)
|
||||
return scroller
|
||||
|
||||
def _build_desc_tab(self) -> Gtk.Widget:
|
||||
outer = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
spacing=12,
|
||||
margin_top=16,
|
||||
margin_bottom=16,
|
||||
margin_start=16,
|
||||
margin_end=16,
|
||||
)
|
||||
outer.append(Gtk.Label(
|
||||
label="Paste or type a description — lines like 'Bearing 293 "
|
||||
"from Alpha' or 'Distance 13.59km from Spotter#1' are "
|
||||
"parsed into clues; everything else is kept as context.",
|
||||
wrap=True,
|
||||
xalign=0,
|
||||
))
|
||||
self._desc_view = Gtk.TextView(vexpand=True, wrap_mode=Gtk.WrapMode.WORD)
|
||||
self._desc_view.add_css_class("card")
|
||||
if self._initial_location.desc_raw:
|
||||
self._desc_view.get_buffer().set_text(self._initial_location.desc_raw)
|
||||
scroller = Gtk.ScrolledWindow(child=self._desc_view, vexpand=True)
|
||||
outer.append(scroller)
|
||||
|
||||
parse_btn = Gtk.Button(label="Parse description")
|
||||
parse_btn.add_css_class("suggested-action")
|
||||
parse_btn.add_css_class("pill")
|
||||
parse_btn.set_halign(Gtk.Align.CENTER)
|
||||
parse_btn.connect("clicked", self._on_desc_submit_clicked)
|
||||
outer.append(parse_btn)
|
||||
return outer
|
||||
|
||||
def _id_and_type(self) -> tuple[str | None, TargetType | None]:
|
||||
id_ = self.row_id.get_text().strip() or None if self.row_id is not None else None
|
||||
type_ = list(TargetType)[self.row_type.get_selected()] if self.row_type is not None else None
|
||||
return id_, type_
|
||||
|
||||
def _on_submit_clicked(self, _button: Gtk.Button) -> None:
|
||||
coord = Coord(X=LARGE_X[self._X_idx], Y=self._Y_val, x=self._x_val, y=self._y_val)
|
||||
id_, type_ = self._id_and_type()
|
||||
self._on_submit(Location.from_coord(coord), id_, type_)
|
||||
self.close()
|
||||
|
||||
def _on_desc_submit_clicked(self, _button: Gtk.Button) -> None:
|
||||
buf = self._desc_view.get_buffer()
|
||||
text = buf.get_text(buf.get_start_iter(), buf.get_end_iter(), True)
|
||||
clues = ocr.parse_clues_from_text(text)
|
||||
id_, type_ = self._id_and_type()
|
||||
self._on_submit(Location.from_desc(text, clues), id_, type_)
|
||||
self.close()
|
||||
|
||||
def _on_key_pressed(self, _controller, keyval, _keycode, _state) -> bool:
|
||||
unicode_val = Gdk.keyval_to_unicode(keyval)
|
||||
if not unicode_val:
|
||||
return False
|
||||
ch = chr(unicode_val)
|
||||
|
||||
if ch.isalpha():
|
||||
letter = ch.upper()
|
||||
if letter not in LARGE_X:
|
||||
return False
|
||||
self._X_buttons[LARGE_X.index(letter)].set_active(True)
|
||||
self._kb_stage = 1
|
||||
return True
|
||||
|
||||
if ch.isdigit():
|
||||
digit = int(ch)
|
||||
if self._kb_stage == 0:
|
||||
return False # need X first
|
||||
if self._kb_stage == 1:
|
||||
self._Y_buttons[(10 if digit == 0 else digit) - 1].set_active(True)
|
||||
self._kb_stage = 2
|
||||
elif self._kb_stage == 2:
|
||||
self._x_buttons[digit].set_active(True)
|
||||
self._kb_stage = 3
|
||||
elif self._kb_stage == 3:
|
||||
self._y_buttons[digit].set_active(True)
|
||||
self._kb_stage = 0
|
||||
# Don't auto-submit when there's an id/type field still to
|
||||
# fill in (Add spotter / Add target) — the 4-digit sequence
|
||||
# only ever fills the coordinate, so submitting immediately
|
||||
# would lock in id/type before the user's touched them.
|
||||
if not (self._show_id or self._show_type):
|
||||
self._on_submit_clicked(None)
|
||||
return True
|
||||
|
||||
return False
|
||||
435
src/ironnest_assist/firing_panel.py
Normal file
435
src/ironnest_assist/firing_panel.py
Normal file
@ -0,0 +1,435 @@
|
||||
"""Firing-commands sidebar: one card per target (or one per candidate
|
||||
position, for an ambiguous target), shown alongside the map in an
|
||||
Adw.OverlaySplitView (opening it narrows the map, doesn't overlay it).
|
||||
Opened/closed from the main header's toggle button — no close control of
|
||||
its own, so no header bar here, just the sort/filter toolbar.
|
||||
|
||||
Card layout:
|
||||
Target#5 [edit] [L/R/—] [alive]
|
||||
ELEV AZ
|
||||
48.42° 31.2°
|
||||
12.10km
|
||||
AP ▾ [charge segments 1-6] 3
|
||||
|
||||
Elevation/azimuth are real (ballistics.py), computed from the Nest to
|
||||
whichever coord the card represents. Shell defaults per target type
|
||||
(Target.effective_shell — AP for FDC/AmmoCache, HE otherwise) but is
|
||||
editable per-target via the shell button, which also drives the map's
|
||||
blast-radius overlay when this target is selected (grid_widget.py).
|
||||
|
||||
Cards are drag-reorderable — `self.board.targets`' own list order is the
|
||||
persisted order and doubles as the sort's tie-break (see refresh()).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Gdk", "4.0")
|
||||
from gi.repository import Gdk, GObject, Gtk # noqa: E402
|
||||
|
||||
from . import ballistics
|
||||
from .models import Board, Target, TargetType
|
||||
from .shells import Shell
|
||||
|
||||
_SELECTED_CSS = "firing-card-selected"
|
||||
_HOVERED_CSS = "firing-card-hovered"
|
||||
|
||||
_SHOW_DEAD_STATES = ["hide", "show", "sort_later"]
|
||||
_SHOW_DEAD_ICONS = {
|
||||
"hide": "view-conceal-symbolic",
|
||||
"show": "view-reveal-symbolic",
|
||||
"sort_later": "view-list-symbolic",
|
||||
}
|
||||
_SHOW_DEAD_LABELS = {
|
||||
"hide": "Dead targets: hidden",
|
||||
"show": "Dead targets: shown",
|
||||
"sort_later": "Dead targets: sorted last",
|
||||
}
|
||||
|
||||
_ASSIGNMENT_STATES = ["unassigned", "left", "right"]
|
||||
_ASSIGNMENT_LABELS = {"unassigned": "—", "left": "L", "right": "R"}
|
||||
_ASSIGNMENT_TOOLTIPS = {
|
||||
"unassigned": "Unassigned (click to assign left gun)",
|
||||
"left": "Assigned: left gun (click to assign right gun)",
|
||||
"right": "Assigned: right gun (click to unassign)",
|
||||
}
|
||||
|
||||
|
||||
class FiringPanel(Gtk.Box):
|
||||
"""Right-hand sidebar content: sort/filter toolbar + scrollable cards."""
|
||||
|
||||
def __init__(
|
||||
self, board: Board, *, on_change, on_select, on_edit_position, on_set_position, on_remove,
|
||||
on_toggle_hide_dead_map,
|
||||
) -> None:
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.board = board
|
||||
self.on_change = on_change
|
||||
self.on_select = on_select
|
||||
self.on_edit_position = on_edit_position
|
||||
self.on_set_position = on_set_position
|
||||
self.on_remove = on_remove
|
||||
self.on_toggle_hide_dead_map = on_toggle_hide_dead_map
|
||||
self.selected: Target | None = None
|
||||
self.selected_point = None
|
||||
self.hovered: Target | None = None
|
||||
self.hovered_point = None
|
||||
|
||||
self.show_dead = "sort_later" # "hide" | "show" | "sort_later"
|
||||
self.hide_dead_from_map = False # off by default — the map's own filter, separate from sort/hide-in-list
|
||||
|
||||
# target -> [(card widget, point)] (usually 1; several for an ambiguous target)
|
||||
self._cards_by_target: dict[Target, list[tuple[Gtk.Widget, object]]] = {}
|
||||
|
||||
self.append(self._build_toolbar())
|
||||
|
||||
self._list_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
||||
self._list_box.set_margin_top(4)
|
||||
self._list_box.set_margin_bottom(10)
|
||||
self._list_box.set_margin_start(10)
|
||||
self._list_box.set_margin_end(10)
|
||||
scroller = Gtk.ScrolledWindow(child=self._list_box, vexpand=True)
|
||||
self.append(scroller)
|
||||
|
||||
self.refresh()
|
||||
|
||||
# -- sort/filter toolbar -----------------------------------------------------
|
||||
def _build_toolbar(self) -> Gtk.Widget:
|
||||
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
|
||||
row.set_margin_top(10)
|
||||
row.set_margin_bottom(6)
|
||||
row.set_margin_start(10)
|
||||
row.set_margin_end(10)
|
||||
|
||||
self._show_dead_btn = Gtk.Button()
|
||||
self._show_dead_btn.connect("clicked", self._on_show_dead_clicked)
|
||||
row.append(self._show_dead_btn)
|
||||
self._update_show_dead_button()
|
||||
|
||||
self._hide_dead_map_btn = Gtk.ToggleButton()
|
||||
self._hide_dead_map_btn.connect("toggled", self._on_hide_dead_map_toggled)
|
||||
row.append(self._hide_dead_map_btn)
|
||||
self._update_hide_dead_map_button()
|
||||
|
||||
return row
|
||||
|
||||
def _update_show_dead_button(self) -> None:
|
||||
self._show_dead_btn.set_icon_name(_SHOW_DEAD_ICONS[self.show_dead])
|
||||
self._show_dead_btn.set_tooltip_text(f"{_SHOW_DEAD_LABELS[self.show_dead]} (click to cycle)")
|
||||
|
||||
def _on_show_dead_clicked(self, _btn) -> None:
|
||||
idx = _SHOW_DEAD_STATES.index(self.show_dead)
|
||||
self.show_dead = _SHOW_DEAD_STATES[(idx + 1) % len(_SHOW_DEAD_STATES)]
|
||||
self._update_show_dead_button()
|
||||
self.refresh()
|
||||
|
||||
def _update_hide_dead_map_button(self) -> None:
|
||||
self._hide_dead_map_btn.set_icon_name(
|
||||
"view-conceal-symbolic" if self.hide_dead_from_map else "view-reveal-symbolic"
|
||||
)
|
||||
label = "Dead targets hidden from map" if self.hide_dead_from_map else "Dead targets shown on map"
|
||||
self._hide_dead_map_btn.set_tooltip_text(f"{label} (click to toggle; selecting one still shows it)")
|
||||
|
||||
def _on_hide_dead_map_toggled(self, btn) -> None:
|
||||
self.hide_dead_from_map = btn.get_active()
|
||||
self._update_hide_dead_map_button()
|
||||
self.on_toggle_hide_dead_map(self.hide_dead_from_map)
|
||||
|
||||
# -- selection / hover highlight (lightweight — no rebuild) -------------------
|
||||
def set_selected(self, target, point=None) -> None:
|
||||
if target is self.selected and point == self.selected_point:
|
||||
return
|
||||
self._restyle(self.selected, self.selected_point, _SELECTED_CSS, False)
|
||||
self.selected, self.selected_point = target, point
|
||||
self._restyle(self.selected, self.selected_point, _SELECTED_CSS, True)
|
||||
|
||||
def set_hovered(self, target, point=None) -> None:
|
||||
if target is self.hovered and point == self.hovered_point:
|
||||
return
|
||||
self._restyle(self.hovered, self.hovered_point, _HOVERED_CSS, False)
|
||||
self.hovered, self.hovered_point = target, point
|
||||
self._restyle(self.hovered, self.hovered_point, _HOVERED_CSS, True)
|
||||
|
||||
def _restyle(self, target, point, css_class: str, add: bool) -> None:
|
||||
"""point=None means "the whole target" (every one of its cards);
|
||||
otherwise only the card for that specific ambiguous candidate —
|
||||
without this, both candidate cards light up identically and you
|
||||
can't tell which one was actually picked."""
|
||||
for card, card_point in self._cards_by_target.get(target, []):
|
||||
if point is None or card_point == point:
|
||||
(card.add_css_class if add else card.remove_css_class)(css_class)
|
||||
|
||||
# -- rebuild --------------------------------------------------------------------
|
||||
def refresh(self) -> None:
|
||||
while (child := self._list_box.get_first_child()) is not None:
|
||||
self._list_box.remove(child)
|
||||
self._cards_by_target = {}
|
||||
|
||||
targets = list(self.board.targets)
|
||||
if self.show_dead == "hide":
|
||||
targets = [t for t in targets if t.alive]
|
||||
|
||||
def sort_key(t: Target):
|
||||
dead_last = 1 if (self.show_dead == "sort_later" and not t.alive) else 0
|
||||
strike_first = 0 if t.type is TargetType.STRIKE else 1
|
||||
return (dead_last, strike_first)
|
||||
|
||||
# Stable sort: ties keep board order, which is exactly what drag
|
||||
# reordering (Board.reorder_target) manipulates.
|
||||
targets.sort(key=sort_key)
|
||||
|
||||
if not targets:
|
||||
placeholder = Gtk.Label(label="No targets yet.", wrap=True)
|
||||
placeholder.add_css_class("dim-label")
|
||||
placeholder.set_margin_top(24)
|
||||
self._list_box.append(placeholder)
|
||||
return
|
||||
|
||||
prev_was_dead_group = False
|
||||
for target in targets:
|
||||
in_dead_group = self.show_dead == "sort_later" and not target.alive
|
||||
if in_dead_group and not prev_was_dead_group:
|
||||
self._list_box.append(Gtk.Separator(margin_top=4, margin_bottom=4))
|
||||
prev_was_dead_group = in_dead_group
|
||||
|
||||
cards = self._build_cards(target) # list of (card, point)
|
||||
self._cards_by_target[target] = cards
|
||||
for card, point in cards:
|
||||
if target is self.selected and (self.selected_point is None or point == self.selected_point):
|
||||
card.add_css_class(_SELECTED_CSS)
|
||||
if target is self.hovered and (self.hovered_point is None or point == self.hovered_point):
|
||||
card.add_css_class(_HOVERED_CSS)
|
||||
self._list_box.append(card)
|
||||
|
||||
def _build_cards(self, target: Target) -> list[tuple[Gtk.Widget, object]]:
|
||||
if target.coord is not None:
|
||||
return [(self._build_card(target, target.coord), target.coord)]
|
||||
if target.location.potential_coords:
|
||||
return [
|
||||
(self._build_card(target, coord, ambiguous_index=i + 1), coord)
|
||||
for i, coord in enumerate(target.location.potential_coords)
|
||||
]
|
||||
return [(self._build_unresolved_card(target), None)]
|
||||
|
||||
def _build_card_shell(self, target: Target, point=None) -> tuple[Gtk.Box, Gtk.Box]:
|
||||
"""Card frame + top row (name, edit, assignment, alive) common to
|
||||
every card kind. `point` is the specific coord this card represents
|
||||
(None for the "position unknown" card) — passed back on select so
|
||||
the map can highlight/arrow exactly this candidate, not every one
|
||||
of them."""
|
||||
card = Gtk.Box()
|
||||
card.add_css_class("card")
|
||||
card.add_css_class("firing-card")
|
||||
|
||||
click = Gtk.GestureClick()
|
||||
click.connect("released", lambda *_a: self.on_select(target, point))
|
||||
card.add_controller(click)
|
||||
|
||||
self._add_drag_reorder(card, target)
|
||||
|
||||
inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||
inner.set_margin_top(10)
|
||||
inner.set_margin_bottom(10)
|
||||
inner.set_margin_start(12)
|
||||
inner.set_margin_end(12)
|
||||
card.append(inner)
|
||||
|
||||
top_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
|
||||
name_label = Gtk.Label(label=target.name, xalign=0, hexpand=True)
|
||||
name_label.add_css_class("heading")
|
||||
if not target.alive:
|
||||
name_label.add_css_class("dim-label")
|
||||
top_row.append(name_label)
|
||||
|
||||
if target.type is TargetType.STRIKE:
|
||||
# A strike is placed by clicking the map (set_pos_btn below);
|
||||
# typing in coordinates to "edit" one doesn't fit that — delete
|
||||
# and re-place instead.
|
||||
delete_btn = Gtk.Button(icon_name="user-trash-symbolic", tooltip_text="Delete strike")
|
||||
delete_btn.add_css_class("flat")
|
||||
delete_btn.connect("clicked", lambda _b: self.on_remove(target))
|
||||
top_row.append(delete_btn)
|
||||
else:
|
||||
edit_btn = Gtk.Button(icon_name="document-edit-symbolic", tooltip_text="Edit position (type in coords)")
|
||||
edit_btn.add_css_class("flat")
|
||||
edit_btn.connect("clicked", lambda _b: self.on_edit_position(target))
|
||||
top_row.append(edit_btn)
|
||||
|
||||
set_pos_btn = Gtk.Button(icon_name="find-location-symbolic", tooltip_text="Set position on map")
|
||||
set_pos_btn.add_css_class("flat")
|
||||
set_pos_btn.connect("clicked", lambda _b: self.on_set_position(target))
|
||||
top_row.append(set_pos_btn)
|
||||
|
||||
assign_btn = Gtk.Button(label=_ASSIGNMENT_LABELS[target.assignment])
|
||||
assign_btn.add_css_class("flat")
|
||||
assign_btn.set_tooltip_text(_ASSIGNMENT_TOOLTIPS[target.assignment])
|
||||
assign_btn.connect("clicked", lambda _b: self._cycle_assignment(target))
|
||||
top_row.append(assign_btn)
|
||||
|
||||
alive_btn = Gtk.Button(
|
||||
icon_name="object-select-symbolic" if target.alive else "action-unavailable-symbolic",
|
||||
tooltip_text="Mark destroyed" if target.alive else "Mark alive",
|
||||
)
|
||||
alive_btn.add_css_class("flat")
|
||||
alive_btn.connect("clicked", lambda _b: self._toggle_alive(target))
|
||||
top_row.append(alive_btn)
|
||||
|
||||
inner.append(top_row)
|
||||
if not target.alive:
|
||||
card.set_opacity(0.55)
|
||||
return card, inner
|
||||
|
||||
def _add_drag_reorder(self, card: Gtk.Widget, target: Target) -> None:
|
||||
drag_source = Gtk.DragSource()
|
||||
drag_source.set_actions(Gdk.DragAction.MOVE)
|
||||
|
||||
def on_prepare(_src, _x, _y, target=target):
|
||||
return Gdk.ContentProvider.new_for_value(GObject.Value(GObject.TYPE_PYOBJECT, target))
|
||||
|
||||
drag_source.connect("prepare", on_prepare)
|
||||
drag_source.connect("drag-begin", lambda *_a: card.add_css_class("firing-card-dragging"))
|
||||
drag_source.connect("drag-end", lambda *_a: card.remove_css_class("firing-card-dragging"))
|
||||
card.add_controller(drag_source)
|
||||
|
||||
drop_target = Gtk.DropTarget.new(GObject.TYPE_PYOBJECT, Gdk.DragAction.MOVE)
|
||||
|
||||
def on_drop(_dt, dragged, _x, _y, drop_onto=target):
|
||||
if dragged is drop_onto:
|
||||
return False
|
||||
self._reorder(dragged, drop_onto)
|
||||
return True
|
||||
|
||||
drop_target.connect("drop", on_drop)
|
||||
card.add_controller(drop_target)
|
||||
|
||||
def _reorder(self, dragged: Target, drop_onto: Target) -> None:
|
||||
targets = self.board.targets
|
||||
if dragged not in targets or drop_onto not in targets:
|
||||
return
|
||||
self.board.reorder_target(dragged, targets.index(drop_onto))
|
||||
self.on_change()
|
||||
|
||||
def _build_unresolved_card(self, target: Target) -> Gtk.Widget:
|
||||
card, inner = self._build_card_shell(target)
|
||||
note = Gtk.Label(label="Position unknown", xalign=0, wrap=True)
|
||||
note.add_css_class("dim-label")
|
||||
inner.append(note)
|
||||
return card
|
||||
|
||||
def _build_card(self, target: Target, coord, ambiguous_index: int | None = None) -> Gtk.Widget:
|
||||
card, inner = self._build_card_shell(target, coord)
|
||||
|
||||
if ambiguous_index is not None:
|
||||
tag = Gtk.Label(label=f"AMBIGUOUS — candidate {ambiguous_index}", xalign=0)
|
||||
tag.add_css_class("caption")
|
||||
tag.add_css_class("warning")
|
||||
inner.append(tag)
|
||||
|
||||
nest = self.board.nest
|
||||
if nest.coord is None:
|
||||
note = Gtk.Label(label="Nest position unknown", xalign=0, wrap=True)
|
||||
note.add_css_class("dim-label")
|
||||
inner.append(note)
|
||||
return card
|
||||
|
||||
dist = ballistics.distance_km(nest.coord, coord)
|
||||
az = ballistics.bearing_deg(nest.coord, coord)
|
||||
min_charge = ballistics.min_powder_charge(dist)
|
||||
charges = target.powder_charges or min_charge
|
||||
charges = max(min_charge, min(charges, ballistics.MAX_POWDER_CHARGE))
|
||||
|
||||
readouts = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=28)
|
||||
elev_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
||||
elev_caption = Gtk.Label(label="ELEV", xalign=0)
|
||||
elev_caption.add_css_class("caption")
|
||||
elev_caption.add_css_class("dim-label")
|
||||
elev_value = Gtk.Label(label=f"{ballistics.elevation_deg(dist, charges):.2f}°", xalign=0)
|
||||
elev_value.add_css_class("title-3")
|
||||
dist_label = Gtk.Label(label=f"{dist:.2f}km", xalign=0)
|
||||
dist_label.add_css_class("caption")
|
||||
dist_label.add_css_class("dim-label")
|
||||
elev_box.append(elev_caption)
|
||||
elev_box.append(elev_value)
|
||||
elev_box.append(dist_label)
|
||||
readouts.append(elev_box)
|
||||
|
||||
az_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
||||
az_caption = Gtk.Label(label="AZ", xalign=0)
|
||||
az_caption.add_css_class("caption")
|
||||
az_caption.add_css_class("dim-label")
|
||||
az_value = Gtk.Label(label=f"{az:.1f}°", xalign=0)
|
||||
az_value.add_css_class("title-3")
|
||||
az_box.append(az_caption)
|
||||
az_box.append(az_value)
|
||||
readouts.append(az_box)
|
||||
inner.append(readouts)
|
||||
|
||||
inner.append(self._build_charge_row(target, dist, min_charge, charges, elev_value))
|
||||
return card
|
||||
|
||||
def _build_charge_row(self, target: Target, dist_km: float, min_charge: int,
|
||||
charges: int, elev_value_label: Gtk.Label) -> Gtk.Widget:
|
||||
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
|
||||
|
||||
shell_btn = Gtk.MenuButton(label=target.effective_shell.name)
|
||||
shell_btn.add_css_class("flat")
|
||||
shell_btn.set_tooltip_text("Change shell (blast radius)")
|
||||
popover = Gtk.Popover()
|
||||
shell_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
|
||||
margin_top=6, margin_bottom=6, margin_start=6, margin_end=6)
|
||||
for s in Shell:
|
||||
radius = f"{s.blast_radius_km}km" if s.blast_radius_km is not None else "unknown radius"
|
||||
btn = Gtk.Button(label=f"{s.name} — {s.description} ({radius})")
|
||||
btn.add_css_class("flat")
|
||||
btn.get_child().set_xalign(0)
|
||||
btn.connect("clicked", lambda _b, s=s: self._pick_shell(target, s, popover))
|
||||
shell_list.append(btn)
|
||||
popover.set_child(shell_list)
|
||||
shell_btn.set_popover(popover)
|
||||
row.append(shell_btn)
|
||||
|
||||
segments: list[Gtk.Button] = []
|
||||
count_label = Gtk.Label(label=str(charges))
|
||||
count_label.set_margin_start(4)
|
||||
|
||||
def apply_fill(value: int) -> None:
|
||||
for i, seg in enumerate(segments, start=1):
|
||||
seg.remove_css_class("suggested-action")
|
||||
seg.remove_css_class("flat")
|
||||
seg.add_css_class("suggested-action" if i <= value else "flat")
|
||||
count_label.set_label(str(value))
|
||||
|
||||
def on_pick(n: int) -> None:
|
||||
target.powder_charges = n
|
||||
apply_fill(n)
|
||||
elev_value_label.set_label(f"{ballistics.elevation_deg(dist_km, n):.2f}°")
|
||||
|
||||
for n in range(1, ballistics.MAX_POWDER_CHARGE + 1):
|
||||
seg = Gtk.Button(label=" ")
|
||||
seg.set_size_request(14, 14)
|
||||
seg.add_css_class("circular")
|
||||
seg.set_sensitive(n >= min_charge)
|
||||
seg.set_tooltip_text(f"{n} charge{'s' if n != 1 else ''}")
|
||||
seg.connect("clicked", lambda _b, n=n: on_pick(n))
|
||||
segments.append(seg)
|
||||
row.append(seg)
|
||||
|
||||
apply_fill(charges)
|
||||
row.append(count_label)
|
||||
return row
|
||||
|
||||
def _cycle_assignment(self, target: Target) -> None:
|
||||
idx = _ASSIGNMENT_STATES.index(target.assignment)
|
||||
target.assignment = _ASSIGNMENT_STATES[(idx + 1) % len(_ASSIGNMENT_STATES)]
|
||||
self.on_change()
|
||||
|
||||
def _toggle_alive(self, target: Target) -> None:
|
||||
target.alive = not target.alive
|
||||
self.on_change()
|
||||
|
||||
def _pick_shell(self, target: Target, shell: Shell, popover: Gtk.Popover) -> None:
|
||||
target.shell = shell
|
||||
popover.popdown()
|
||||
self.on_change()
|
||||
533
src/ironnest_assist/grid_widget.py
Normal file
533
src/ironnest_assist/grid_widget.py
Normal file
@ -0,0 +1,533 @@
|
||||
"""GridCanvas: draws the 20x10 map, every placed entity, ambiguous
|
||||
solver candidates, and geo-description overlays (bearing/distance clues)
|
||||
for whatever's hovered or pinned via show_geo_desc. Also handles
|
||||
click-to-select and hover notification so the firing-commands panel can
|
||||
stay in sync with the map (see app.py)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Gdk", "4.0")
|
||||
from gi.repository import Gdk, Gtk # noqa: E402
|
||||
|
||||
from . import solver
|
||||
from .models import LARGE_X, Board, Target
|
||||
|
||||
COLS, ROWS = 20, 10
|
||||
|
||||
MARGIN_LEFT = 34
|
||||
MARGIN_TOP = 30
|
||||
MARGIN_RIGHT = 50
|
||||
MARGIN_BOTTOM = 30
|
||||
|
||||
LABEL_PAD = 8 # gap between a marker and its name label
|
||||
|
||||
HOVER_RADIUS_PX = 12
|
||||
OVERLAY_RAY_LENGTH_KM = 30.0 # long enough to cross the 20x10 map from any origin
|
||||
|
||||
CATEGORY_COLOR = {
|
||||
"nest": (0.35, 0.60, 0.95),
|
||||
"spotter": (0.35, 0.78, 0.40),
|
||||
"rp": (0.95, 0.78, 0.20),
|
||||
"target": (0.92, 0.30, 0.28),
|
||||
}
|
||||
|
||||
BG = (0.13, 0.12, 0.10)
|
||||
GRID_LINE = (1.0, 1.0, 1.0, 0.20)
|
||||
LABEL = (0.88, 0.86, 0.80)
|
||||
COORD_LABEL = (0.60, 0.58, 0.54)
|
||||
YELLOW = (0.95, 0.85, 0.20)
|
||||
WHITE = (1.0, 1.0, 1.0)
|
||||
FIRING_ARROW = (0.95, 0.15, 0.15)
|
||||
SELECTION_RING = (1.0, 1.0, 1.0)
|
||||
BLAST_RADIUS = (0.95, 0.40, 0.10)
|
||||
PLACEMENT_PREVIEW = (0.95, 0.85, 0.20)
|
||||
|
||||
|
||||
class GridCanvas(Gtk.DrawingArea):
|
||||
def __init__(self, board: Board) -> None:
|
||||
super().__init__()
|
||||
self.board = board
|
||||
self.hovered = None
|
||||
self.hovered_point = None # which candidate, when obj has more than one point
|
||||
self.selected = None
|
||||
self.selected_point = None # which candidate, when obj has more than one point
|
||||
self.on_select = None # callback(obj | None, point | None), fired on click
|
||||
self.on_hover_change = None # callback(obj | None, point | None), fired on hover change
|
||||
self.on_cursor_move = None # callback((col, row) km | None), fired on every motion/leave
|
||||
self.on_right_click = None # callback(Coord, x, y), fired on right-click (unless placing)
|
||||
self.hide_dead_from_map = False # off by default; toggled from the firing panel toolbar
|
||||
|
||||
# Placement mode: while armed, the next left-click calls
|
||||
# placement_callback(Coord) instead of doing the normal
|
||||
# select/hit-test, and (if placement_preview_radius_km is set) a
|
||||
# circle of that radius follows the cursor as a preview.
|
||||
self.placement_callback = None
|
||||
self.placement_preview_radius_km = None
|
||||
self._placement_cursor_km = None
|
||||
|
||||
self.set_hexpand(True)
|
||||
self.set_vexpand(True)
|
||||
self.set_draw_func(self._draw)
|
||||
|
||||
motion = Gtk.EventControllerMotion()
|
||||
motion.connect("motion", self._on_motion)
|
||||
motion.connect("leave", self._on_leave)
|
||||
self.add_controller(motion)
|
||||
|
||||
click = Gtk.GestureClick()
|
||||
click.connect("released", self._on_click)
|
||||
self.add_controller(click)
|
||||
|
||||
right_click = Gtk.GestureClick()
|
||||
right_click.set_button(Gdk.BUTTON_SECONDARY)
|
||||
right_click.connect("released", self._on_right_click)
|
||||
self.add_controller(right_click)
|
||||
|
||||
keys = Gtk.EventControllerKey()
|
||||
keys.connect("key-pressed", self._on_key_pressed)
|
||||
self.set_focusable(True)
|
||||
self.add_controller(keys)
|
||||
|
||||
def refresh(self) -> None:
|
||||
self.queue_draw()
|
||||
|
||||
# -- placement mode -----------------------------------------------------------
|
||||
def start_placement(self, callback, preview_radius_km=None) -> None:
|
||||
self.placement_callback = callback
|
||||
self.placement_preview_radius_km = preview_radius_km
|
||||
self.set_cursor_from_name("crosshair")
|
||||
self.queue_draw()
|
||||
|
||||
def cancel_placement(self) -> None:
|
||||
if self.placement_callback is None:
|
||||
return
|
||||
self.placement_callback = None
|
||||
self.placement_preview_radius_km = None
|
||||
self.set_cursor_from_name(None)
|
||||
self.queue_draw()
|
||||
|
||||
def _on_key_pressed(self, _controller, keyval, _keycode, _state) -> bool:
|
||||
if keyval == Gdk.KEY_Escape and self.placement_callback is not None:
|
||||
self.cancel_placement()
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_selected(self, obj, point=None) -> None:
|
||||
if obj is not self.selected or point != self.selected_point:
|
||||
self.selected = obj
|
||||
self.selected_point = point
|
||||
self.queue_draw()
|
||||
|
||||
# -- geometry -------------------------------------------------------------
|
||||
def _cell_size(self, width: int, height: int) -> tuple[float, float]:
|
||||
grid_w = max(width - MARGIN_LEFT - MARGIN_RIGHT, 1)
|
||||
grid_h = max(height - MARGIN_TOP - MARGIN_BOTTOM, 1)
|
||||
return grid_w / COLS, grid_h / ROWS
|
||||
|
||||
def _km_to_px(self, point_km, cell_w, cell_h, grid_h) -> tuple[float, float]:
|
||||
col, row = point_km
|
||||
return MARGIN_LEFT + col * cell_w, MARGIN_TOP + grid_h - row * cell_h
|
||||
|
||||
def _px_to_km(self, x, y, cell_w, cell_h, grid_h) -> tuple[float, float]:
|
||||
return (x - MARGIN_LEFT) / cell_w, (grid_h - (y - MARGIN_TOP)) / cell_h
|
||||
|
||||
def _excluded_from_map(self, obj) -> bool:
|
||||
"""True if `obj` should be dropped from the map view entirely —
|
||||
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
|
||||
still drawn (darkened) so it stays reachable/un-hideable."""
|
||||
if obj is self.selected:
|
||||
return False
|
||||
if obj.hidden:
|
||||
return True
|
||||
if self.hide_dead_from_map and isinstance(obj, Target) and not obj.alive:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _all_positions(self):
|
||||
"""Yield (obj, coord) for every point drawn on the map, including
|
||||
each ambiguous candidate separately (hover/click targets each of
|
||||
them individually, but they all resolve to the same obj). Hidden
|
||||
entities, and (if toggled) dead targets, are excluded from the map
|
||||
entirely unless they're the current selection (so they can still
|
||||
be un-hidden/interacted with once selected some other way, e.g.
|
||||
from the firing panel)."""
|
||||
for _category, obj in self.board.placed_entities_all():
|
||||
if self._excluded_from_map(obj):
|
||||
continue
|
||||
yield obj, obj.coord
|
||||
for _category, obj in self.board.ambiguous_entities_all():
|
||||
if self._excluded_from_map(obj):
|
||||
continue
|
||||
for candidate in obj.location.potential_coords:
|
||||
yield obj, candidate
|
||||
|
||||
def _hit_test(self, x: float, y: float):
|
||||
"""Returns (obj, coord) of the nearest marker within range, or
|
||||
(None, None) — coord disambiguates which candidate of an
|
||||
ambiguous obj was actually hit, since it can have several points."""
|
||||
cell_w, cell_h = self._cell_size(self.get_width(), self.get_height())
|
||||
grid_h = cell_h * ROWS
|
||||
best_obj, best_coord, best_dist = None, None, HOVER_RADIUS_PX
|
||||
for obj, coord in self._all_positions():
|
||||
px, py = self._km_to_px(coord.as_fraction(), cell_w, cell_h, grid_h)
|
||||
dist = math.hypot(px - x, py - y)
|
||||
if dist < best_dist:
|
||||
best_dist, best_obj, best_coord = dist, obj, coord
|
||||
return best_obj, best_coord
|
||||
|
||||
# -- hover / click ------------------------------------------------------------
|
||||
def _on_motion(self, _controller, x: float, y: float) -> None:
|
||||
cell_w, cell_h = self._cell_size(self.get_width(), self.get_height())
|
||||
grid_h = cell_h * ROWS
|
||||
cursor_km = self._px_to_km(x, y, cell_w, cell_h, grid_h)
|
||||
|
||||
if self.on_cursor_move is not None:
|
||||
self.on_cursor_move(cursor_km)
|
||||
|
||||
if self.placement_callback is not None:
|
||||
self._placement_cursor_km = cursor_km
|
||||
self.queue_draw()
|
||||
return # no hover/select while placing — the map's just a target picker right now
|
||||
|
||||
hit, coord = self._hit_test(x, y)
|
||||
if hit is not self.hovered or coord != self.hovered_point:
|
||||
self.hovered = hit
|
||||
self.hovered_point = coord
|
||||
self.queue_draw()
|
||||
if self.on_hover_change is not None:
|
||||
self.on_hover_change(hit, coord)
|
||||
|
||||
def _on_leave(self, _controller) -> None:
|
||||
if self.on_cursor_move is not None:
|
||||
self.on_cursor_move(None)
|
||||
if self.placement_callback is not None:
|
||||
self._placement_cursor_km = None
|
||||
self.queue_draw()
|
||||
if self.hovered is not None:
|
||||
self.hovered = None
|
||||
self.hovered_point = None
|
||||
self.queue_draw()
|
||||
if self.on_hover_change is not None:
|
||||
self.on_hover_change(None, None)
|
||||
|
||||
def _on_click(self, _gesture, _n_press, x: float, y: float) -> None:
|
||||
if self.placement_callback is not None:
|
||||
cell_w, cell_h = self._cell_size(self.get_width(), self.get_height())
|
||||
grid_h = cell_h * ROWS
|
||||
coord = solver.point_to_coord(self._px_to_km(x, y, cell_w, cell_h, grid_h))
|
||||
callback = self.placement_callback
|
||||
self.cancel_placement()
|
||||
if coord is not None:
|
||||
callback(coord)
|
||||
return
|
||||
|
||||
hit, coord = self._hit_test(x, y)
|
||||
self.set_selected(hit, coord)
|
||||
if self.on_select is not None:
|
||||
self.on_select(hit, coord)
|
||||
|
||||
def _on_right_click(self, _gesture, _n_press, x: float, y: float) -> None:
|
||||
if self.placement_callback is not None:
|
||||
self.cancel_placement()
|
||||
return
|
||||
if self.on_right_click is None:
|
||||
return
|
||||
cell_w, cell_h = self._cell_size(self.get_width(), self.get_height())
|
||||
grid_h = cell_h * ROWS
|
||||
coord = solver.point_to_coord(self._px_to_km(x, y, cell_w, cell_h, grid_h))
|
||||
if coord is not None:
|
||||
self.on_right_click(coord, x, y)
|
||||
|
||||
# -- drawing ----------------------------------------------------------------
|
||||
def _draw(self, _area, cr, width, height) -> None:
|
||||
cr.set_source_rgb(*BG)
|
||||
cr.paint()
|
||||
|
||||
cell_w, cell_h = self._cell_size(width, height)
|
||||
grid_w, grid_h = cell_w * COLS, cell_h * ROWS
|
||||
|
||||
cr.set_source_rgba(*GRID_LINE)
|
||||
cr.set_line_width(1)
|
||||
for c in range(COLS + 1):
|
||||
x = MARGIN_LEFT + c * cell_w
|
||||
cr.move_to(x, MARGIN_TOP)
|
||||
cr.line_to(x, MARGIN_TOP + grid_h)
|
||||
for r in range(ROWS + 1):
|
||||
y = MARGIN_TOP + grid_h - r * cell_h
|
||||
cr.move_to(MARGIN_LEFT, y)
|
||||
cr.line_to(MARGIN_LEFT + grid_w, y)
|
||||
cr.stroke()
|
||||
|
||||
cr.set_source_rgb(*LABEL)
|
||||
cr.set_font_size(11)
|
||||
for i, letter in enumerate(LARGE_X):
|
||||
x = MARGIN_LEFT + i * cell_w + cell_w / 2 - 4
|
||||
cr.move_to(x, MARGIN_TOP - 10)
|
||||
cr.show_text(letter)
|
||||
for r in range(ROWS):
|
||||
y = MARGIN_TOP + grid_h - r * cell_h - cell_h / 2 + 4
|
||||
cr.move_to(4, y)
|
||||
cr.show_text(str(r + 1))
|
||||
|
||||
self._draw_geo_overlays(cr, cell_w, cell_h, grid_h)
|
||||
self._draw_firing_arrows(cr, cell_w, cell_h, grid_h)
|
||||
self._draw_blast_radius(cr, cell_w, cell_h, grid_h)
|
||||
self._draw_placement_preview(cr, cell_w, cell_h, grid_h)
|
||||
|
||||
for category, obj in self.board.placed_entities_all():
|
||||
if self._excluded_from_map(obj):
|
||||
continue # hidden/dead-and-toggled-off entities are removed, not just darkened
|
||||
self._draw_marker(cr, obj.coord.as_fraction(), CATEGORY_COLOR[category],
|
||||
obj.name, cell_w, cell_h, grid_h, width, height,
|
||||
dim=(category == "target" and not obj.alive) or obj.hidden,
|
||||
selected=(obj is self.selected), coord=obj.coord)
|
||||
|
||||
for category, obj in self.board.ambiguous_entities_all():
|
||||
if self._excluded_from_map(obj):
|
||||
continue
|
||||
color = CATEGORY_COLOR[category]
|
||||
for i, candidate in enumerate(obj.location.potential_coords):
|
||||
is_selected = obj is self.selected and candidate == self.selected_point
|
||||
self._draw_marker(cr, candidate.as_fraction(), color,
|
||||
f"{obj.name}? ({i + 1})", cell_w, cell_h, grid_h, width, height,
|
||||
hollow=True, dim=obj.hidden or (category == "target" and not obj.alive),
|
||||
selected=is_selected, coord=candidate)
|
||||
|
||||
def _draw_marker(self, cr, point_km, color, label, cell_w, cell_h, grid_h,
|
||||
canvas_width, canvas_height, *, hollow=False, dim=False,
|
||||
selected=False, coord=None) -> None:
|
||||
x, y = self._km_to_px(point_km, cell_w, cell_h, grid_h)
|
||||
r, g, b = color
|
||||
alpha = 0.45 if dim else 1.0
|
||||
|
||||
if selected:
|
||||
cr.new_path()
|
||||
cr.set_source_rgba(*SELECTION_RING, 0.9)
|
||||
cr.set_line_width(2)
|
||||
cr.arc(x, y, 9, 0, 2 * math.pi)
|
||||
cr.stroke()
|
||||
|
||||
if hollow:
|
||||
cr.new_path() # cairo's arc() draws a line from any stale current
|
||||
cr.set_source_rgba(r, g, b, alpha) # point (e.g. the last label's
|
||||
cr.set_line_width(1.5) # show_text position) to the arc's start —
|
||||
cr.set_dash([3, 2]) # this is what stops that connector line.
|
||||
cr.arc(x, y, 5.5, 0, 2 * math.pi)
|
||||
cr.stroke()
|
||||
cr.set_dash([])
|
||||
else:
|
||||
cr.new_path()
|
||||
cr.set_source_rgba(r, g, b, alpha)
|
||||
cr.arc(x, y, 5.5, 0, 2 * math.pi)
|
||||
cr.fill()
|
||||
cr.new_path()
|
||||
cr.set_source_rgba(0, 0, 0, 0.6 * alpha)
|
||||
cr.arc(x, y, 5.5, 0, 2 * math.pi)
|
||||
cr.set_line_width(1)
|
||||
cr.stroke()
|
||||
|
||||
cr.set_font_size(11)
|
||||
text_width = cr.text_extents(label).width
|
||||
coord_text = coord.label() if coord is not None else None
|
||||
coord_width = cr.text_extents(coord_text).width if coord_text else 0
|
||||
|
||||
# Flip to the left of the marker if the label would run past the
|
||||
# right edge; clamp vertically so it doesn't clip top/bottom either.
|
||||
label_x = x + LABEL_PAD
|
||||
if label_x + max(text_width, coord_width) > canvas_width - 4:
|
||||
label_x = x - LABEL_PAD - max(text_width, coord_width)
|
||||
label_y = max(10, min(y - 7, canvas_height - 20))
|
||||
|
||||
cr.set_source_rgba(*LABEL, alpha)
|
||||
cr.move_to(label_x, label_y)
|
||||
cr.show_text(label)
|
||||
|
||||
if coord_text:
|
||||
cr.set_font_size(9)
|
||||
cr.set_source_rgba(*COORD_LABEL, alpha)
|
||||
cr.move_to(label_x, label_y + 12)
|
||||
cr.show_text(coord_text)
|
||||
cr.set_font_size(11)
|
||||
|
||||
def _draw_firing_arrows(self, cr, cell_w, cell_h, grid_h) -> None:
|
||||
"""Red arrow(s) Nest -> Target, for whatever's hovered or selected.
|
||||
Points at exactly the hovered/selected candidate when one is known
|
||||
(mouse over/click on a specific ambiguous marker) rather than every
|
||||
candidate of that target — same reasoning as the selection ring:
|
||||
drawing to all of them makes it impossible to tell which is which."""
|
||||
nest = self.board.nest
|
||||
if nest.coord is None:
|
||||
return
|
||||
nest_km = nest.coord.as_fraction()
|
||||
|
||||
points: list = []
|
||||
for target, point in ((self.hovered, self.hovered_point), (self.selected, self.selected_point)):
|
||||
if not isinstance(target, Target):
|
||||
continue
|
||||
if target.coord is not None:
|
||||
points.append(target.coord)
|
||||
elif point is not None:
|
||||
points.append(point)
|
||||
else:
|
||||
points.extend(target.location.potential_coords)
|
||||
|
||||
seen = set()
|
||||
for candidate in points:
|
||||
if candidate in seen:
|
||||
continue
|
||||
seen.add(candidate)
|
||||
tx, ty = self._km_to_px(candidate.as_fraction(), cell_w, cell_h, grid_h)
|
||||
nx, ny = self._km_to_px(nest_km, cell_w, cell_h, grid_h)
|
||||
cr.set_source_rgb(*FIRING_ARROW)
|
||||
cr.set_line_width(2)
|
||||
self._draw_arrow(cr, nx, ny, tx, ty)
|
||||
|
||||
def _draw_blast_radius(self, cr, cell_w, cell_h, grid_h) -> None:
|
||||
"""When a Target is selected, its effective shell's blast radius —
|
||||
selection only, not hover (unlike the geo overlays/firing arrow),
|
||||
per spec. Uses the specific selected candidate point if the target
|
||||
is ambiguous; skipped entirely if there's no known point yet, or
|
||||
the shell's blast radius isn't known."""
|
||||
if not isinstance(self.selected, Target):
|
||||
return
|
||||
target = self.selected
|
||||
point = target.coord if target.coord is not None else self.selected_point
|
||||
if point is None:
|
||||
return
|
||||
radius_km = target.effective_shell.blast_radius_km
|
||||
if radius_km is None:
|
||||
return
|
||||
|
||||
x, y = self._km_to_px(point.as_fraction(), cell_w, cell_h, grid_h)
|
||||
rx, ry = cell_w * radius_km, cell_h * radius_km
|
||||
self._draw_ellipse(cr, x, y, rx, ry)
|
||||
cr.set_source_rgba(*BLAST_RADIUS, 0.18)
|
||||
cr.fill_preserve()
|
||||
cr.set_source_rgba(*BLAST_RADIUS, 0.85)
|
||||
cr.set_line_width(2)
|
||||
cr.stroke()
|
||||
|
||||
def _draw_placement_preview(self, cr, cell_w, cell_h, grid_h) -> None:
|
||||
"""While armed to place/reposition something, a small crosshair dot
|
||||
follows the cursor, plus a blast-radius preview circle if one was
|
||||
given (e.g. placing a Strike — see its shell before you commit)."""
|
||||
if self.placement_callback is None or self._placement_cursor_km is None:
|
||||
return
|
||||
x, y = self._km_to_px(self._placement_cursor_km, cell_w, cell_h, grid_h)
|
||||
|
||||
if self.placement_preview_radius_km is not None:
|
||||
rx, ry = cell_w * self.placement_preview_radius_km, cell_h * self.placement_preview_radius_km
|
||||
self._draw_ellipse(cr, x, y, rx, ry)
|
||||
cr.set_source_rgba(*PLACEMENT_PREVIEW, 0.15)
|
||||
cr.fill_preserve()
|
||||
cr.set_source_rgba(*PLACEMENT_PREVIEW, 0.8)
|
||||
cr.set_line_width(1.5)
|
||||
cr.set_dash([3, 2])
|
||||
cr.stroke()
|
||||
cr.set_dash([])
|
||||
|
||||
cr.new_path()
|
||||
cr.set_source_rgba(*PLACEMENT_PREVIEW, 0.9)
|
||||
cr.set_line_width(1.5)
|
||||
cr.move_to(x - 7, y)
|
||||
cr.line_to(x + 7, y)
|
||||
cr.move_to(x, y - 7)
|
||||
cr.line_to(x, y + 7)
|
||||
cr.stroke()
|
||||
|
||||
def _draw_geo_overlays(self, cr, cell_w, cell_h, grid_h) -> None:
|
||||
to_show = []
|
||||
for category, obj in self.board.placed_entities():
|
||||
if obj is self.hovered or obj.show_geo_desc:
|
||||
to_show.append(obj)
|
||||
for category, obj in self.board.ambiguous_entities():
|
||||
if obj is self.hovered or obj.show_geo_desc:
|
||||
to_show.append(obj)
|
||||
|
||||
for obj in to_show:
|
||||
for clue in obj.location.clues:
|
||||
ref = self.board.find_by_name(clue.reference)
|
||||
if ref is None or ref.coord is None:
|
||||
continue
|
||||
ref_km = ref.coord.as_fraction()
|
||||
rx, ry = self._km_to_px(ref_km, cell_w, cell_h, grid_h)
|
||||
|
||||
if clue.bearing_deg is not None and clue.distance_km is not None:
|
||||
target_km = solver.point_from_bearing_distance(ref_km, clue.bearing_deg, clue.distance_km)
|
||||
tx, ty = self._km_to_px(target_km, cell_w, cell_h, grid_h)
|
||||
cr.set_source_rgb(*YELLOW)
|
||||
cr.set_line_width(2)
|
||||
self._draw_arrow(cr, rx, ry, tx, ty)
|
||||
elif clue.bearing_deg is not None:
|
||||
far_km = solver.point_from_bearing_distance(ref_km, clue.bearing_deg, OVERLAY_RAY_LENGTH_KM)
|
||||
fx, fy = self._km_to_px(far_km, cell_w, cell_h, grid_h)
|
||||
cr.set_source_rgb(*YELLOW)
|
||||
cr.set_line_width(1.5)
|
||||
cr.move_to(rx, ry)
|
||||
cr.line_to(fx, fy)
|
||||
cr.stroke()
|
||||
elif clue.distance_km is not None:
|
||||
radius_x, radius_y = cell_w * clue.distance_km, cell_h * clue.distance_km
|
||||
self._draw_ellipse(cr, rx, ry, radius_x, radius_y)
|
||||
cr.set_source_rgba(*WHITE, 0.85)
|
||||
cr.set_line_width(1.5)
|
||||
cr.stroke()
|
||||
|
||||
# Radius indicator: a line from center to an actual point
|
||||
# on the circle — the intersection with another of this
|
||||
# entity's clues if one pairs with it (same geometry the
|
||||
# solver would use; picks the nearer of two candidates),
|
||||
# else straight up as a last-resort fallback with nothing
|
||||
# to intersect against yet.
|
||||
radius_target_km = None
|
||||
for other in obj.location.clues:
|
||||
if other is clue or other.bearing_deg is None or other.distance_km is not None:
|
||||
continue
|
||||
other_ref = self.board.find_by_name(other.reference)
|
||||
if other_ref is None or other_ref.coord is None:
|
||||
continue
|
||||
points = solver.ray_circle_intersections(
|
||||
other_ref.coord.as_fraction(), other.bearing_deg, ref_km, clue.distance_km
|
||||
)
|
||||
if points:
|
||||
radius_target_km = points[0]
|
||||
break
|
||||
if radius_target_km is None:
|
||||
radius_target_km = (ref_km[0], ref_km[1] + clue.distance_km)
|
||||
|
||||
tx, ty = self._km_to_px(radius_target_km, cell_w, cell_h, grid_h)
|
||||
cr.new_path()
|
||||
cr.set_source_rgba(*WHITE, 0.85)
|
||||
cr.set_line_width(1)
|
||||
cr.set_dash([1.5, 2.5])
|
||||
cr.move_to(rx, ry)
|
||||
cr.line_to(tx, ty)
|
||||
cr.stroke()
|
||||
cr.set_dash([])
|
||||
|
||||
def _draw_arrow(self, cr, x0, y0, x1, y1, head_size=8) -> None:
|
||||
cr.new_path()
|
||||
cr.move_to(x0, y0)
|
||||
cr.line_to(x1, y1)
|
||||
cr.stroke()
|
||||
angle = math.atan2(y1 - y0, x1 - x0)
|
||||
cr.new_path()
|
||||
for delta in (math.pi * 5 / 6, -math.pi * 5 / 6):
|
||||
cr.move_to(x1, y1)
|
||||
cr.line_to(x1 + head_size * math.cos(angle + delta), y1 + head_size * math.sin(angle + delta))
|
||||
cr.stroke()
|
||||
|
||||
def _draw_ellipse(self, cr, cx, cy, rx, ry, steps=72) -> None:
|
||||
# Points computed explicitly (not via cr.scale) so the stroke width
|
||||
# stays uniform regardless of rx/ry — a scaled CTM would stretch it.
|
||||
cr.new_path()
|
||||
cr.move_to(cx + rx, cy)
|
||||
for i in range(1, steps + 1):
|
||||
theta = 2 * math.pi * i / steps
|
||||
cr.line_to(cx + rx * math.cos(theta), cy + ry * math.sin(theta))
|
||||
526
src/ironnest_assist/models.py
Normal file
526
src/ironnest_assist/models.py
Normal file
@ -0,0 +1,526 @@
|
||||
"""Board data model: coordinates and the entities placed on the map.
|
||||
|
||||
Coordinate system (matches the in-game map):
|
||||
Large grid: X in A..T (20 cols), Y in 1..10 (10 rows), row 1 at the bottom.
|
||||
Sub-grid within a cell: x, y in 0..9.
|
||||
|
||||
Not everything is known as an absolute grid coordinate. The typewriter
|
||||
also hands out *relative* fixes — "Bearing 293 from Alpha", "Distance
|
||||
13.59km from Spotter#1" — that only resolve to a Coord once whatever
|
||||
they're relative to is itself known, and that can chain (AmmoCache#3 is
|
||||
relative to AmmoCache#2, which is itself relative to Alpha and Spotter#1).
|
||||
A Location captures both cases: either a resolved Coord, or a raw
|
||||
description plus the Clues parsed out of it — each Clue naming another
|
||||
entity, so the Clues across the board form a dependency graph a future
|
||||
solver walks (topologically, anchored at entities with a resolved Coord)
|
||||
to work out everything else. No solver yet — this just defines the shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import string
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
from .shells import Shell
|
||||
|
||||
LARGE_X = string.ascii_uppercase[:20] # A..T
|
||||
LARGE_Y = range(1, 11) # 1..10
|
||||
|
||||
NATO_ALPHABET = [
|
||||
"Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot", "Golf", "Hotel",
|
||||
"India", "Juliett", "Kilo", "Lima", "Mike", "November", "Oscar", "Papa",
|
||||
"Quebec", "Romeo", "Sierra", "Tango", "Uniform", "Victor", "Whiskey",
|
||||
"X-ray", "Yankee", "Zulu",
|
||||
]
|
||||
|
||||
|
||||
class TargetType(Enum):
|
||||
SUPPLY_CACHE = "Supply Cache"
|
||||
UNKNOWN = "Target" # generic contact, spotted but not yet identified
|
||||
FDC = "FDC" # Fire Direction Center — coordinates enemy counter-battery fire
|
||||
INFANTRY = "Infantry" # hostile ground troops
|
||||
MECHANIZED = "Mechanized" # hostile armored/vehicle unit
|
||||
HOSTILE_ARTILLERY = "Hostile Artillery"
|
||||
HOSTILE_TANK = "Hostile Tank"
|
||||
STRIKE = "Strike" # a planned impact point, not an enemy contact
|
||||
|
||||
@property
|
||||
def short(self) -> str:
|
||||
"""Compact form used in item names, e.g. 'SupplyCache' / 'FDC'."""
|
||||
return self.value.replace(" ", "")
|
||||
|
||||
|
||||
# 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
|
||||
# "SupplyCache" name and was dropped in favor of it.
|
||||
_TARGET_TYPE_MIGRATIONS = {"AMMO_CACHE": "SUPPLY_CACHE"}
|
||||
|
||||
|
||||
def _migrate_target_type(name: str) -> TargetType:
|
||||
return TargetType[_TARGET_TYPE_MIGRATIONS.get(name, name)]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Coord:
|
||||
X: str # 'A'..'T'
|
||||
Y: int # 1..10
|
||||
x: int # 0..9
|
||||
y: int # 0..9
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.X not in LARGE_X:
|
||||
raise ValueError(f"X must be one of A..T, got {self.X!r}")
|
||||
if self.Y not in LARGE_Y:
|
||||
raise ValueError(f"Y must be 1..10, got {self.Y!r}")
|
||||
if not (0 <= self.x <= 9):
|
||||
raise ValueError(f"x must be 0..9, got {self.x!r}")
|
||||
if not (0 <= self.y <= 9):
|
||||
raise ValueError(f"y must be 0..9, got {self.y!r}")
|
||||
|
||||
def as_fraction(self) -> tuple[float, float]:
|
||||
"""Position in board units: col in [0,20], row in [0,10], sub-cell centered."""
|
||||
col = LARGE_X.index(self.X) + (self.x + 0.5) / 10
|
||||
row = (self.Y - 1) + (self.y + 0.5) / 10
|
||||
return col, row
|
||||
|
||||
def label(self) -> str:
|
||||
return f"{self.X}{self.Y} {self.x}:{self.y}"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"X": self.X, "Y": self.Y, "x": self.x, "y": self.y}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Coord":
|
||||
return cls(X=d["X"], Y=d["Y"], x=d["x"], y=d["y"])
|
||||
|
||||
|
||||
def _coord_to_dict(coord: Coord | None) -> dict | None:
|
||||
return coord.to_dict() if coord is not None else None
|
||||
|
||||
|
||||
def _coord_from_dict(d: dict | None) -> Coord | None:
|
||||
return Coord.from_dict(d) if d is not None else None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Clue:
|
||||
"""One relative-position reading: bearing and/or distance from another
|
||||
named entity (e.g. 'Spotter#1', 'Alpha', 'AmmoCache#2'). At least one
|
||||
of bearing/distance is set; a single clue with both fully determines a
|
||||
position given the reference, two clues (from different references)
|
||||
need triangulating."""
|
||||
|
||||
reference: str
|
||||
bearing_deg: float | None = None
|
||||
distance_km: float | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"reference": self.reference,
|
||||
"bearing_deg": self.bearing_deg,
|
||||
"distance_km": self.distance_km,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Clue":
|
||||
return cls(
|
||||
reference=d["reference"],
|
||||
bearing_deg=d.get("bearing_deg"),
|
||||
distance_km=d.get("distance_km"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Location:
|
||||
"""Where something is. `coord` is the resolved absolute position —
|
||||
known directly for entities given in grid form (Nest, Spotters), or
|
||||
filled in later by a solver once every clue's reference is resolved.
|
||||
`desc_raw` + `clues` hold a relative description before/instead of
|
||||
that resolution. `potential_coords` holds a solver result that was
|
||||
genuinely ambiguous (e.g. a bearing ray crossing a distance circle
|
||||
twice) — shown on the map as candidates, never treated as resolved
|
||||
and never used to resolve anything else."""
|
||||
|
||||
coord: Coord | None = None
|
||||
desc_raw: str | None = None
|
||||
clues: list[Clue] = field(default_factory=list)
|
||||
potential_coords: list[Coord] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def is_resolved(self) -> bool:
|
||||
return self.coord is not None
|
||||
|
||||
@property
|
||||
def depends_on(self) -> list[str]:
|
||||
"""Names of other entities this location's clues are relative to."""
|
||||
return [c.reference for c in self.clues]
|
||||
|
||||
@classmethod
|
||||
def from_coord(cls, coord: Coord | None) -> "Location":
|
||||
return cls(coord=coord)
|
||||
|
||||
@classmethod
|
||||
def from_desc(cls, raw: str, clues: list[Clue] | None = None) -> "Location":
|
||||
return cls(desc_raw=raw, clues=list(clues) if clues else [])
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"coord": _coord_to_dict(self.coord),
|
||||
"desc_raw": self.desc_raw,
|
||||
"clues": [c.to_dict() for c in self.clues],
|
||||
"potential_coords": [c.to_dict() for c in self.potential_coords],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict | None) -> "Location":
|
||||
if not d:
|
||||
return cls()
|
||||
return cls(
|
||||
coord=_coord_from_dict(d.get("coord")),
|
||||
desc_raw=d.get("desc_raw"),
|
||||
clues=[Clue.from_dict(c) for c in d.get("clues", [])],
|
||||
potential_coords=[Coord.from_dict(c) for c in d.get("potential_coords", [])],
|
||||
)
|
||||
|
||||
|
||||
def _as_location(value: "Location | Coord | None") -> Location:
|
||||
return value if isinstance(value, Location) else Location.from_coord(value)
|
||||
|
||||
|
||||
@dataclass(eq=False) # identity equality/hash — these are mutable, used as dict keys/set members
|
||||
class Nest:
|
||||
location: Location = field(default_factory=Location)
|
||||
name: str = "Nest"
|
||||
hidden: bool = False
|
||||
show_geo_desc: bool = False
|
||||
|
||||
@property
|
||||
def coord(self) -> Coord | None:
|
||||
return self.location.coord
|
||||
|
||||
@coord.setter
|
||||
def coord(self, value: Coord | None) -> None:
|
||||
# Set the resolved position without clobbering any desc_raw/clues
|
||||
# already stored on this Location (provenance: how it got there).
|
||||
# potential_coords is left alone too, not cleared — a coord takes
|
||||
# priority over it everywhere it matters (placed_entities() /
|
||||
# ambiguous_entities() / firing panel cards all check coord first),
|
||||
# so it just goes inert rather than being deleted.
|
||||
self.location.coord = value
|
||||
|
||||
|
||||
@dataclass(eq=False) # identity equality/hash — these are mutable, used as dict keys/set members
|
||||
class Spotter:
|
||||
id: int
|
||||
location: Location = field(default_factory=Location)
|
||||
hidden: bool = False
|
||||
show_geo_desc: bool = False
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return f"Spotter#{self.id}"
|
||||
|
||||
@property
|
||||
def coord(self) -> Coord | None:
|
||||
return self.location.coord
|
||||
|
||||
@coord.setter
|
||||
def coord(self, value: Coord | None) -> None:
|
||||
# Set the resolved position without clobbering any desc_raw/clues
|
||||
# already stored on this Location (provenance: how it got there).
|
||||
# potential_coords is left alone too, not cleared — a coord takes
|
||||
# priority over it everywhere it matters (placed_entities() /
|
||||
# ambiguous_entities() / firing panel cards all check coord first),
|
||||
# so it just goes inert rather than being deleted.
|
||||
self.location.coord = value
|
||||
|
||||
|
||||
@dataclass(eq=False) # identity equality/hash — these are mutable, used as dict keys/set members
|
||||
class ReferencePoint:
|
||||
rp_name: str
|
||||
location: Location = field(default_factory=Location)
|
||||
hidden: bool = False
|
||||
show_geo_desc: bool = False
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.rp_name
|
||||
|
||||
@property
|
||||
def coord(self) -> Coord | None:
|
||||
return self.location.coord
|
||||
|
||||
@coord.setter
|
||||
def coord(self, value: Coord | None) -> None:
|
||||
# Set the resolved position without clobbering any desc_raw/clues
|
||||
# already stored on this Location (provenance: how it got there).
|
||||
# potential_coords is left alone too, not cleared — a coord takes
|
||||
# priority over it everywhere it matters (placed_entities() /
|
||||
# ambiguous_entities() / firing panel cards all check coord first),
|
||||
# so it just goes inert rather than being deleted.
|
||||
self.location.coord = value
|
||||
|
||||
|
||||
@dataclass(eq=False) # identity equality/hash — these are mutable, used as dict keys/set members
|
||||
class Target:
|
||||
type: TargetType
|
||||
id: str
|
||||
location: Location = field(default_factory=Location)
|
||||
hidden: bool = False
|
||||
show_geo_desc: bool = False
|
||||
alive: bool = True
|
||||
# None = use the computed minimum for the current distance; only set
|
||||
# once the user picks a value explicitly (see firing_panel.py).
|
||||
powder_charges: int | None = None
|
||||
# None = use effective_shell's type-based default; only set once the
|
||||
# user picks one explicitly.
|
||||
shell: Shell | None = None
|
||||
# Which gun this target is assigned to, if any — "unassigned" | "left" | "right".
|
||||
assignment: str = "unassigned"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return f"{self.type.short}#{self.id}"
|
||||
|
||||
_AP_DEFAULT_TYPES = (TargetType.FDC, TargetType.SUPPLY_CACHE)
|
||||
|
||||
@property
|
||||
def effective_shell(self) -> Shell:
|
||||
if self.shell is not None:
|
||||
return self.shell
|
||||
if self.type in self._AP_DEFAULT_TYPES:
|
||||
return Shell.AP
|
||||
if self.type is TargetType.STRIKE:
|
||||
return Shell.HCHE
|
||||
return Shell.HE
|
||||
|
||||
@property
|
||||
def coord(self) -> Coord | None:
|
||||
return self.location.coord
|
||||
|
||||
@coord.setter
|
||||
def coord(self, value: Coord | None) -> None:
|
||||
# Set the resolved position without clobbering any desc_raw/clues
|
||||
# already stored on this Location (provenance: how it got there).
|
||||
# potential_coords is left alone too, not cleared — a coord takes
|
||||
# priority over it everywhere it matters (placed_entities() /
|
||||
# ambiguous_entities() / firing panel cards all check coord first),
|
||||
# so it just goes inert rather than being deleted.
|
||||
self.location.coord = value
|
||||
|
||||
|
||||
SAVE_FORMAT_VERSION = 2
|
||||
|
||||
|
||||
class Board:
|
||||
"""Holds everything placed on the map and the naming rules for new items."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.nest = Nest()
|
||||
self.spotters: list[Spotter] = []
|
||||
self.reference_points: list[ReferencePoint] = []
|
||||
self.targets: list[Target] = []
|
||||
self._spotter_seq = 0
|
||||
|
||||
# -- lookup by name, for resolving clue references ----------------------
|
||||
def find_by_name(self, name: str):
|
||||
if self.nest.name == name:
|
||||
return self.nest
|
||||
for sp in self.spotters:
|
||||
if sp.name == name:
|
||||
return sp
|
||||
for rp in self.reference_points:
|
||||
if rp.name == name:
|
||||
return rp
|
||||
for t in self.targets:
|
||||
if t.name == name:
|
||||
return t
|
||||
return None
|
||||
|
||||
# -- spotters --------------------------------------------------------
|
||||
def next_spotter_id(self) -> int:
|
||||
return self._spotter_seq + 1
|
||||
|
||||
def add_spotter(self, location: Location | Coord | None = None, id_: int | None = None) -> Spotter:
|
||||
if id_ is None:
|
||||
id_ = self.next_spotter_id()
|
||||
self._spotter_seq = max(self._spotter_seq, id_)
|
||||
sp = Spotter(id=id_, location=_as_location(location))
|
||||
self.spotters.append(sp)
|
||||
return sp
|
||||
|
||||
def remove_spotter(self, spotter: Spotter) -> None:
|
||||
self.spotters.remove(spotter)
|
||||
|
||||
# -- reference points -------------------------------------------------
|
||||
def add_reference_point(
|
||||
self, location: Location | Coord | None = None, name: str | None = None
|
||||
) -> ReferencePoint:
|
||||
if name is None:
|
||||
used = {rp.rp_name for rp in self.reference_points}
|
||||
name = next(n for n in NATO_ALPHABET if n not in used)
|
||||
rp = ReferencePoint(rp_name=name, location=_as_location(location))
|
||||
self.reference_points.append(rp)
|
||||
return rp
|
||||
|
||||
def remove_reference_point(self, rp: ReferencePoint) -> None:
|
||||
self.reference_points.remove(rp)
|
||||
|
||||
# -- targets ------------------------------------------------------------
|
||||
def add_target(
|
||||
self,
|
||||
type_: TargetType,
|
||||
location: Location | Coord | None = None,
|
||||
id_: str | None = None,
|
||||
) -> Target:
|
||||
if not id_:
|
||||
used = {t.id for t in self.targets if t.type == type_}
|
||||
id_ = next(c for c in string.ascii_uppercase if c not in used)
|
||||
t = Target(type=type_, id=id_, location=_as_location(location))
|
||||
self.targets.append(t)
|
||||
return t
|
||||
|
||||
def remove_target(self, target: Target) -> None:
|
||||
self.targets.remove(target)
|
||||
|
||||
def reorder_target(self, target: Target, new_index: int) -> None:
|
||||
"""Manual drag-order: `self.targets`' list order is itself the
|
||||
persisted order (saved/loaded as a plain JSON array), and is what
|
||||
the firing panel's sort falls back to as a tie-break once its
|
||||
other sort options are applied."""
|
||||
self.targets.remove(target)
|
||||
new_index = max(0, min(new_index, len(self.targets)))
|
||||
self.targets.insert(new_index, target)
|
||||
|
||||
# -- drawing helper ----------------------------------------------------
|
||||
def placed_entities(self):
|
||||
"""Yield (category, obj) for everything with a *resolved* coord that
|
||||
isn't hidden. Entities that only have a relative description (or
|
||||
only ambiguous potential_coords) aren't drawable this way — see
|
||||
ambiguous_entities(). See placed_entities_all()/ambiguous_entities_all()
|
||||
for the unfiltered versions (needed so the map can still show a
|
||||
hidden entity, darkened, while it's selected)."""
|
||||
for category, obj in self.placed_entities_all():
|
||||
if not obj.hidden:
|
||||
yield category, obj
|
||||
|
||||
def placed_entities_all(self):
|
||||
"""Like placed_entities(), but includes hidden entities too."""
|
||||
if self.nest.coord is not None:
|
||||
yield "nest", self.nest
|
||||
for sp in self.spotters:
|
||||
if sp.coord is not None:
|
||||
yield "spotter", sp
|
||||
for rp in self.reference_points:
|
||||
if rp.coord is not None:
|
||||
yield "rp", rp
|
||||
for t in self.targets:
|
||||
if t.coord is not None:
|
||||
yield "target", t
|
||||
|
||||
def ambiguous_entities(self):
|
||||
"""Yield (category, obj) for RPs/Targets that resolved to two or
|
||||
more equally-valid potential_coords instead of one definitive
|
||||
coord."""
|
||||
for category, obj in self.ambiguous_entities_all():
|
||||
if not obj.hidden:
|
||||
yield category, obj
|
||||
|
||||
def ambiguous_entities_all(self):
|
||||
"""Like ambiguous_entities(), but includes hidden entities too."""
|
||||
for rp in self.reference_points:
|
||||
if rp.coord is None and rp.location.potential_coords:
|
||||
yield "rp", rp
|
||||
for t in self.targets:
|
||||
if t.coord is None and t.location.potential_coords:
|
||||
yield "target", t
|
||||
|
||||
# -- save / load ---------------------------------------------------------
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"version": SAVE_FORMAT_VERSION,
|
||||
"nest": {
|
||||
"location": self.nest.location.to_dict(),
|
||||
"hidden": self.nest.hidden,
|
||||
"show_geo_desc": self.nest.show_geo_desc,
|
||||
},
|
||||
"spotters": [
|
||||
{
|
||||
"id": sp.id,
|
||||
"location": sp.location.to_dict(),
|
||||
"hidden": sp.hidden,
|
||||
"show_geo_desc": sp.show_geo_desc,
|
||||
}
|
||||
for sp in self.spotters
|
||||
],
|
||||
"reference_points": [
|
||||
{
|
||||
"name": rp.rp_name,
|
||||
"location": rp.location.to_dict(),
|
||||
"hidden": rp.hidden,
|
||||
"show_geo_desc": rp.show_geo_desc,
|
||||
}
|
||||
for rp in self.reference_points
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"type": t.type.name,
|
||||
"id": t.id,
|
||||
"location": t.location.to_dict(),
|
||||
"hidden": t.hidden,
|
||||
"show_geo_desc": t.show_geo_desc,
|
||||
"alive": t.alive,
|
||||
"powder_charges": t.powder_charges,
|
||||
"shell": t.shell.name if t.shell is not None else None,
|
||||
"assignment": t.assignment,
|
||||
}
|
||||
for t in self.targets
|
||||
],
|
||||
}
|
||||
|
||||
def load_from_dict(self, data: dict) -> None:
|
||||
"""Replace all current state with what's in `data` (in place, so
|
||||
anything holding a reference to this Board keeps working)."""
|
||||
nest_data = data.get("nest", {})
|
||||
self.nest = Nest(
|
||||
location=Location.from_dict(nest_data.get("location")),
|
||||
hidden=nest_data.get("hidden", False),
|
||||
show_geo_desc=nest_data.get("show_geo_desc", False),
|
||||
)
|
||||
|
||||
self.spotters = [
|
||||
Spotter(
|
||||
id=sp["id"],
|
||||
location=Location.from_dict(sp.get("location")),
|
||||
hidden=sp.get("hidden", False),
|
||||
show_geo_desc=sp.get("show_geo_desc", False),
|
||||
)
|
||||
for sp in data.get("spotters", [])
|
||||
]
|
||||
self._spotter_seq = max((sp.id for sp in self.spotters), default=0)
|
||||
|
||||
self.reference_points = [
|
||||
ReferencePoint(
|
||||
rp_name=rp["name"],
|
||||
location=Location.from_dict(rp.get("location")),
|
||||
hidden=rp.get("hidden", False),
|
||||
show_geo_desc=rp.get("show_geo_desc", False),
|
||||
)
|
||||
for rp in data.get("reference_points", [])
|
||||
]
|
||||
|
||||
self.targets = [
|
||||
Target(
|
||||
type=_migrate_target_type(t["type"]),
|
||||
id=t["id"],
|
||||
location=Location.from_dict(t.get("location")),
|
||||
hidden=t.get("hidden", False),
|
||||
show_geo_desc=t.get("show_geo_desc", False),
|
||||
alive=t.get("alive", True),
|
||||
powder_charges=t.get("powder_charges"),
|
||||
shell=Shell[t["shell"]] if t.get("shell") else None,
|
||||
assignment=t.get("assignment", "unassigned"),
|
||||
)
|
||||
for t in data.get("targets", [])
|
||||
]
|
||||
463
src/ironnest_assist/ocr.py
Normal file
463
src/ironnest_assist/ocr.py
Normal file
@ -0,0 +1,463 @@
|
||||
"""Text OCR pipeline: screenshot -> cleaned-up text -> parsed board info.
|
||||
|
||||
Only the text sub-pipeline is implemented. There's no image/icon
|
||||
recognition sub-pipeline yet (spotting markers, ship icons, etc.) — that's
|
||||
a separate future pipeline, out of scope here.
|
||||
|
||||
Preprocessing matters more than the regexes: the typewriter photo has an
|
||||
uneven vignette (in-game light falloff) that a single global threshold
|
||||
can't handle — it either loses faint corners or blobs-out dark ones. We
|
||||
flatten that by dividing by a heavily blurred copy of itself (crude local
|
||||
background normalization) before thresholding, which recovers text in the
|
||||
darkened areas reliably.
|
||||
|
||||
Parsing is deliberately fuzzy: OCR on an in-game screenshot will misread
|
||||
the odd character (keywords slightly garbled, '0'/'O', '1'/'I'/'l',
|
||||
'5'/'S', '8'/'B' confused, stray specks turning ':' into ';' or '.'). We
|
||||
match keywords by similarity rather than exact spelling, and normalize
|
||||
digit-shaped letters before parsing numbers, so a couple of misrecognized
|
||||
characters don't drop an otherwise-good line.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import numpy as np
|
||||
import pytesseract
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
from .models import Clue, Coord, TargetType
|
||||
|
||||
# --- preprocessing -----------------------------------------------------------
|
||||
|
||||
_BLUR_RADIUS = 35
|
||||
_THRESHOLD = 200
|
||||
|
||||
|
||||
def preprocess(image: Image.Image) -> Image.Image:
|
||||
gray = image.convert("L")
|
||||
arr = np.asarray(gray, dtype=np.float32)
|
||||
background = np.asarray(gray.filter(ImageFilter.GaussianBlur(_BLUR_RADIUS)), dtype=np.float32)
|
||||
normalized = np.clip(arr / (background + 1e-3) * 255.0, 0, 255).astype(np.uint8)
|
||||
normalized_img = Image.fromarray(normalized)
|
||||
return normalized_img.point(lambda p: 255 if p > _THRESHOLD else 0)
|
||||
|
||||
|
||||
def ocr_text(image: Image.Image) -> str:
|
||||
return pytesseract.image_to_string(preprocess(image), config="--psm 6")
|
||||
|
||||
|
||||
# --- fuzzy keyword matching ---------------------------------------------------
|
||||
|
||||
NEST_KEYWORD = "IRON NEST"
|
||||
SPOTTER_KEYWORD = "SPOTTER"
|
||||
_FUZZY_THRESHOLD = 0.65
|
||||
|
||||
|
||||
def _fuzzy_locate(line: str, keyword: str, threshold: float = _FUZZY_THRESHOLD) -> tuple[int, int] | None:
|
||||
"""Span of the best approximate match of `keyword` in `line`, or None."""
|
||||
line_u = line.upper()
|
||||
kw = keyword.upper()
|
||||
n = len(kw)
|
||||
best_ratio = 0.0
|
||||
best_span = None
|
||||
for size in range(max(n - 2, 1), n + 3):
|
||||
for start in range(0, max(len(line_u) - size, 0) + 1):
|
||||
end = start + size
|
||||
ratio = difflib.SequenceMatcher(None, line_u[start:end], kw).ratio()
|
||||
if ratio > best_ratio:
|
||||
best_ratio = ratio
|
||||
best_span = (start, end)
|
||||
return best_span if best_ratio >= threshold else None
|
||||
|
||||
|
||||
def _fuzzy_contains(line: str, keyword: str, threshold: float = _FUZZY_THRESHOLD) -> bool:
|
||||
"""True if some run of characters in `line` approximately matches `keyword`."""
|
||||
return _fuzzy_locate(line, keyword, threshold) is not None
|
||||
|
||||
|
||||
# --- coordinate / id extraction, tolerant of digit<->letter OCR mixups --------
|
||||
|
||||
_DIGIT_CLASS = r"[0-9OoIiLlSsBbZzGg]"
|
||||
_DIGIT_FIX = str.maketrans({
|
||||
"O": "0", "o": "0",
|
||||
"I": "1", "i": "1", "L": "1", "l": "1",
|
||||
"S": "5", "s": "5",
|
||||
"B": "8", "b": "8",
|
||||
"Z": "2", "z": "2",
|
||||
"G": "6", "g": "6",
|
||||
})
|
||||
_SEP = r"\s*[-–—]\s*"
|
||||
_COORD_RE = re.compile(
|
||||
rf"([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})"
|
||||
)
|
||||
# No literal '#' required — it's just as OCR-corruptible as anything else
|
||||
# (missing entirely, or misread as e.g. 'H'). We instead anchor to *where*
|
||||
# the fuzzy keyword match ended and take the first run of digit-shaped
|
||||
# characters after that, skipping over whatever separator survived.
|
||||
_ID_RE = re.compile(rf"({_DIGIT_CLASS}+)")
|
||||
|
||||
# Not seen in a real screenshot yet, so no extraction for these — add a
|
||||
# keyword + extractor here (plus a field below and a case in parse_text)
|
||||
# once we know the format:
|
||||
# - reference points
|
||||
# - targets
|
||||
|
||||
|
||||
def _fix_digits(s: str) -> str:
|
||||
return s.translate(_DIGIT_FIX)
|
||||
|
||||
|
||||
def _fix_id_digits(raw: str) -> str:
|
||||
"""Like _fix_digits, but for id-length runs specifically: also
|
||||
collapses a 2-character run where one char is a genuine digit and the
|
||||
other a look-alike letter that maps to the *same* digit — e.g. '1l' or
|
||||
'S5' both fix to '11'/'55', but a real 2-digit id wouldn't plausibly
|
||||
render as one numeral plus one letter of the identical value; that
|
||||
shape is the signature of OCR ghosting a single thin glyph twice
|
||||
(seen repeatedly: 'AmmoCache#l1' -> #1, 'HostileTank#S5' -> #5).
|
||||
A genuine two-digit id (both chars already real digits, e.g. '11')
|
||||
is left alone."""
|
||||
fixed = _fix_digits(raw)
|
||||
if len(raw) == 2 and len(set(fixed)) == 1:
|
||||
has_digit = any(c.isdigit() for c in raw)
|
||||
has_letter = any(c.isalpha() for c in raw)
|
||||
if has_digit and has_letter:
|
||||
return fixed[0]
|
||||
return fixed
|
||||
|
||||
|
||||
# A target can also be spotted with an absolute grid ref directly
|
||||
# ("Target#10 Spotted. Grid Q3 9:0") instead of/alongside bearing/distance
|
||||
# clues — same coordinate shape as _COORD_RE, just anchored after "Grid".
|
||||
_GRID_COORD_RE = re.compile(
|
||||
rf"Grid\s+([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _extract_grid_coord(text: str) -> Coord | None:
|
||||
m = _GRID_COORD_RE.search(text)
|
||||
if not m:
|
||||
return None
|
||||
letter, y, x, yy = m.groups()
|
||||
try:
|
||||
return Coord(X=letter.upper(), Y=int(_fix_digits(y)), x=int(_fix_digits(x)), y=int(_fix_digits(yy)))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_coord(line: str) -> Coord | None:
|
||||
m = _COORD_RE.search(line)
|
||||
if not m:
|
||||
return None
|
||||
letter, y, x, yy = m.groups()
|
||||
try:
|
||||
return Coord(
|
||||
X=letter.upper(),
|
||||
Y=int(_fix_digits(y)),
|
||||
x=int(_fix_digits(x)),
|
||||
y=int(_fix_digits(yy)),
|
||||
)
|
||||
except ValueError:
|
||||
return None # out-of-range numbers -> not actually a coordinate
|
||||
|
||||
|
||||
def _extract_leading_id(remainder: str) -> int | None:
|
||||
"""First digit-shaped run in `remainder` (text after the keyword match)."""
|
||||
m = _ID_RE.search(remainder)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return int(_fix_id_digits(m.group(1)))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
# --- "field intelligence" blocks: relative Bearing/Distance descriptions ------
|
||||
#
|
||||
# Target#5 Spotted. 088, 12.10km from Spotter#1
|
||||
# Reference Point Alpha:
|
||||
# Bearing 094 from Spotter#1
|
||||
# Distance 13.26km from Spotter#2
|
||||
# .
|
||||
# AmmoCache#3:
|
||||
# Bearing 217 & Distance 10.48km from AmmoCache#2
|
||||
#
|
||||
# Each named entity is a block of one or more clue lines, terminated by a
|
||||
# blank line or a lone '.'. Degree signs, colons, and the 'km' unit are all
|
||||
# treated as optional/lossy — OCR drops them unpredictably.
|
||||
|
||||
_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
|
||||
# drops it or renders it as noise (seen: '€'). Up to 2 junk characters
|
||||
# between the type word and its digits is enough slack without risking a
|
||||
# false match elsewhere.
|
||||
# ^ the junk-class run is REQUIRED (1-2 chars, not 0-2): the digit class
|
||||
# deliberately overlaps the alphabet (g/s/i/l/o/... look like digits), so
|
||||
# with a 0-width separator allowed, "Bearing" backtracks into itself —
|
||||
# word="Bearin", "digit"=its own trailing 'g' — and falsely matches as a
|
||||
# header. Requiring real punctuation between word and digits (true of
|
||||
# every observed header: '#', a misread substitute, ...) rules that out,
|
||||
# and also stops a bare "Word 094" clue line (space only) from matching.
|
||||
_NAMED_HEADER_RE = re.compile(rf"^([A-Za-z]+)[^A-Za-z0-9\s]{{1,2}}({_DIGIT_CLASS}+)\s*:?\s*(.*)$")
|
||||
_BLOCK_END_RE = re.compile(r"^[.\s]{1,6}$")
|
||||
_LEADING_NOISE_RE = re.compile(r"^[^A-Za-z]{1,3}(?=[A-Za-z])")
|
||||
|
||||
# Reference capture is (\S+), not (.+): references are always a single
|
||||
# token with no spaces, and being non-greedy this way is what lets
|
||||
# finditer() find more than one clue per line/block — "Bearing 118 from
|
||||
# Spotter#2 & Bearing 125 from Spotter#1" needs two separate matches, and
|
||||
# a greedy (.+) would let the first one swallow the rest of the string.
|
||||
#
|
||||
# _GAP sits right before "from": plain whitespace normally, but an OCR
|
||||
# line-wrap can drop a stray junk token right at the break ("Bearing 125°"
|
||||
# / "P; from Spotter#1") — and that junk can itself contain a letter (the
|
||||
# 'P' above), so this isn't just non-alnum noise like the header-bullet
|
||||
# case; tolerate any single short token, not just punctuation.
|
||||
_GAP = r"[\s]*(?:\S{1,3}\s*)?"
|
||||
_CLUE_COMBINED_RE = re.compile(
|
||||
rf"Bearing\s*(\d{{1,3}})\s*°?\s*&\s*Distance\s*([\d.]+)\s*k?m?{_GAP}from\s+(\S+)", re.IGNORECASE
|
||||
)
|
||||
_CLUE_INLINE_RE = re.compile(
|
||||
rf"(\d{{1,3}})\s*°?\s*,\s*([\d.]+)\s*k?m{_GAP}from\s+(\S+)", re.IGNORECASE
|
||||
)
|
||||
_CLUE_BEARING_RE = re.compile(rf"Bearing\s*(\d{{1,3}})\s*°?{_GAP}from\s+(\S+)", re.IGNORECASE)
|
||||
_CLUE_DISTANCE_RE = re.compile(rf"Distance\s*([\d.]+)\s*k?m?{_GAP}from\s+(\S+)", re.IGNORECASE)
|
||||
|
||||
_TYPE_BY_SHORT = {t.short: t for t in TargetType}
|
||||
# The game's typewriter has used "AmmoCache" for what's now modeled as
|
||||
# SupplyCache — treat it as the same type rather than dropping the target.
|
||||
_TYPE_WORD_ALIASES = {"AmmoCache": "SupplyCache"}
|
||||
|
||||
|
||||
_REF_NAMED_RE = re.compile(rf"^([A-Za-z]+)[^A-Za-z0-9\s]{{1,2}}({_DIGIT_CLASS}+)")
|
||||
|
||||
|
||||
def _clean_reference(raw: str) -> str:
|
||||
"""Leading name-shaped token, dropping trailing OCR noise (stray dots,
|
||||
double spaces, etc.) — reference names never contain spaces. Named
|
||||
references ('Spotter#1', 'AmmoCache#2') get their digit part fixed up
|
||||
and their separator normalized to '#'; plain word references ('Alpha')
|
||||
are left untouched — don't run digit-fixing over them or real letters
|
||||
like the 'l' in 'Alpha' get corrupted into '1'."""
|
||||
token = re.match(r"\S+", raw.strip())
|
||||
token = token.group(0) if token else raw.strip()
|
||||
|
||||
named = _REF_NAMED_RE.match(token)
|
||||
if named:
|
||||
word, num = named.groups()
|
||||
word = _TYPE_WORD_ALIASES.get(word, word)
|
||||
return f"{word}#{_fix_id_digits(num)}"
|
||||
|
||||
return re.sub(r"[^\w-]+$", "", token) # trim trailing punctuation off a plain name
|
||||
|
||||
|
||||
# Priority order matters: try the "both bearing and distance" shape before
|
||||
# the single-value shapes, so e.g. "Bearing 217 & Distance 10.48km from X"
|
||||
# becomes one clue, not a spurious extra bearing-only one from the same
|
||||
# text. `\s` already matches a literal newline, so this bridges an OCR
|
||||
# line-wrap ("...Bearing 125°" / "from Spotter#1" split across two lines)
|
||||
# without needing the caller to rejoin anything.
|
||||
_CLUE_PATTERNS = (
|
||||
(_CLUE_COMBINED_RE, lambda m: (float(m.group(1)), float(m.group(2)), m.group(3))),
|
||||
(_CLUE_INLINE_RE, lambda m: (float(m.group(1)), float(m.group(2)), m.group(3))),
|
||||
(_CLUE_BEARING_RE, lambda m: (float(m.group(1)), None, m.group(2))),
|
||||
(_CLUE_DISTANCE_RE, lambda m: (None, float(m.group(1)), m.group(2))),
|
||||
)
|
||||
|
||||
|
||||
def _parse_all_clues(text: str) -> list[Clue]:
|
||||
"""Every Bearing/Distance clue found anywhere in `text` — a block can
|
||||
have several (one per clue line, or more than one on a single line
|
||||
joined with '&')."""
|
||||
clues: list[Clue] = []
|
||||
consumed: list[tuple[int, int]] = []
|
||||
|
||||
def overlaps(span: tuple[int, int]) -> bool:
|
||||
return any(span[0] < e and span[1] > s for s, e in consumed)
|
||||
|
||||
for pattern, extract in _CLUE_PATTERNS:
|
||||
for m in pattern.finditer(text):
|
||||
span = m.span()
|
||||
if overlaps(span):
|
||||
continue
|
||||
consumed.append(span)
|
||||
bearing, distance, ref = extract(m)
|
||||
clues.append(Clue(reference=_clean_reference(ref), bearing_deg=bearing, distance_km=distance))
|
||||
return clues
|
||||
|
||||
|
||||
def parse_clues_from_text(text: str) -> list[Clue]:
|
||||
"""Parse every Bearing/Distance clue found in free-form text — used
|
||||
for manually-typed descriptions in the coord dialog, sharing the
|
||||
exact same clue grammar as the OCR'd intel blocks."""
|
||||
return _parse_all_clues(text)
|
||||
|
||||
|
||||
def _resolve_target_type(type_word: str) -> TargetType | None:
|
||||
"""Exact match on the type word (after aliasing), falling back to
|
||||
fuzzy (OCR can garble the type word itself, e.g. 'AmmoCoche')."""
|
||||
type_word = _TYPE_WORD_ALIASES.get(type_word, type_word)
|
||||
if type_word in _TYPE_BY_SHORT:
|
||||
return _TYPE_BY_SHORT[type_word]
|
||||
best, best_ratio = None, 0.0
|
||||
for short, target_type in _TYPE_BY_SHORT.items():
|
||||
ratio = difflib.SequenceMatcher(None, type_word.upper(), short.upper()).ratio()
|
||||
if ratio > best_ratio:
|
||||
best, best_ratio = target_type, ratio
|
||||
return best if best_ratio >= _FUZZY_THRESHOLD else None
|
||||
|
||||
|
||||
def parse_intel_blocks(text: str) -> list[dict]:
|
||||
"""Parse 'field intelligence' blocks into a list of dicts with keys
|
||||
kind ('rp' | 'named'), name, type_word, id, raw, clues, coord. Entries
|
||||
with neither a clue nor a grid coord are dropped (nothing to store).
|
||||
|
||||
Clues are extracted once per block, from the whole joined block text,
|
||||
at flush time — not accumulated line-by-line while scanning. That's
|
||||
what lets a clue split across an OCR line-wrap ("...Bearing 125°" /
|
||||
"from Spotter#1" on separate lines) or two clues on one line
|
||||
("Bearing X from A & Bearing Y from B") both resolve correctly. A
|
||||
target can also carry an absolute grid ref directly ("Grid Q3 9:0")
|
||||
instead of/alongside clues."""
|
||||
entries: list[dict] = []
|
||||
current: dict | None = None
|
||||
|
||||
def flush():
|
||||
nonlocal current
|
||||
if current is not None:
|
||||
joined = "\n".join(current["raw"])
|
||||
current["clues"] = _parse_all_clues(joined)
|
||||
current["coord"] = _extract_grid_coord(joined)
|
||||
if current["clues"] or current["coord"] is not None:
|
||||
current["raw"] = joined
|
||||
entries.append(current)
|
||||
current = None
|
||||
|
||||
for raw_line in text.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue # blank lines are just visual spacing here, not a section boundary
|
||||
if _BLOCK_END_RE.match(line):
|
||||
flush()
|
||||
continue
|
||||
|
||||
# The '.' block-separator bullet often survives OCR as 1-3 stray
|
||||
# junk characters glued onto the *next* line ('i AmmoCache#1:',
|
||||
# '* AmmoCache#2:') instead of its own line, which would otherwise
|
||||
# defeat the column-0-anchored header regexes below. Try the line
|
||||
# as-is first, and only if that fails, retry with its first
|
||||
# whitespace-delimited token stripped (covers symbol junk *and*
|
||||
# a misread bullet that happened to OCR as a stray letter) — but
|
||||
# only when that leading token is bullet-length (<=3 chars), else
|
||||
# a genuine wrapped clue continuation like "from Spotter#2" gets
|
||||
# its "from" stripped and "Spotter#2" misread as a new header.
|
||||
first_token_m = re.match(r"\S+", line)
|
||||
strip_first_token = (
|
||||
re.sub(r"^\S+\s+", "", line, count=1)
|
||||
if first_token_m and len(first_token_m.group(0)) <= 3
|
||||
else line
|
||||
)
|
||||
candidates = (line, _LEADING_NOISE_RE.sub("", line), strip_first_token)
|
||||
|
||||
rp_m = next((m for c in candidates if (m := _RP_HEADER_RE.search(c))), None)
|
||||
if rp_m:
|
||||
flush()
|
||||
current = {"kind": "rp", "name": rp_m.group(1), "type_word": None, "id": None,
|
||||
"raw": [line], "clues": []}
|
||||
continue
|
||||
|
||||
named_m = next((m for c in candidates if (m := _NAMED_HEADER_RE.match(c))), None)
|
||||
if named_m:
|
||||
flush()
|
||||
type_word, num, inline = named_m.groups()
|
||||
num = _fix_id_digits(num)
|
||||
current = {"kind": "named", "name": f"{type_word}#{num}", "type_word": type_word,
|
||||
"id": num, "raw": [line], "clues": []}
|
||||
continue
|
||||
|
||||
if current is not None:
|
||||
current["raw"].append(line)
|
||||
|
||||
flush()
|
||||
return entries
|
||||
|
||||
|
||||
# Destruction reports are standalone one-liners, not tied to a block:
|
||||
# "SupplyCache#2 Destroyed. Additional Requisition Granted."
|
||||
# "Direct Hit! HostileTank#3 Destroyed."
|
||||
# Just need "<Type>#<id>" immediately followed by "Destroyed" — the
|
||||
# "Direct Hit!" prefix (or its absence) doesn't matter, search() finds
|
||||
# the name+Destroyed pair anywhere in the line either way.
|
||||
_DESTROYED_RE = re.compile(
|
||||
rf"([A-Za-z]+)[^A-Za-z0-9\s]{{1,2}}({_DIGIT_CLASS}+)\s*Destroyed", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def parse_destroyed(text: str) -> set[tuple[TargetType, str]]:
|
||||
destroyed = set()
|
||||
for m in _DESTROYED_RE.finditer(text):
|
||||
type_word, num = m.groups()
|
||||
target_type = _resolve_target_type(type_word)
|
||||
if target_type is None:
|
||||
continue
|
||||
destroyed.add((target_type, _fix_id_digits(num)))
|
||||
return destroyed
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedInfo:
|
||||
nest_coord: Coord | None = None
|
||||
spotters: dict[int, Coord] = field(default_factory=dict)
|
||||
# name -> (raw description, clues, absolute coord if given directly)
|
||||
reference_points: dict[str, tuple[str, list[Clue], Coord | None]] = field(default_factory=dict)
|
||||
# (type, id) -> (raw description, clues, absolute coord if given directly)
|
||||
targets: dict[tuple[TargetType, str], tuple[str, list[Clue], Coord | None]] = field(default_factory=dict)
|
||||
# (type, id) of targets reported destroyed
|
||||
destroyed: set[tuple[TargetType, str]] = field(default_factory=set)
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
return not (self.nest_coord or self.spotters or self.reference_points
|
||||
or self.targets or self.destroyed)
|
||||
|
||||
|
||||
def parse_text(text: str) -> ParsedInfo:
|
||||
info = ParsedInfo()
|
||||
|
||||
for raw_line in text.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if info.nest_coord is None and _fuzzy_contains(line, NEST_KEYWORD):
|
||||
coord = _extract_coord(line)
|
||||
if coord is not None:
|
||||
info.nest_coord = coord
|
||||
continue
|
||||
|
||||
spotter_span = _fuzzy_locate(line, SPOTTER_KEYWORD)
|
||||
if spotter_span is not None:
|
||||
spotter_id = _extract_leading_id(line[spotter_span[1]:])
|
||||
coord = _extract_coord(line)
|
||||
if spotter_id is not None and coord is not None:
|
||||
info.spotters[spotter_id] = coord
|
||||
|
||||
for entry in parse_intel_blocks(text):
|
||||
if entry["kind"] == "rp":
|
||||
info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"])
|
||||
continue
|
||||
target_type = _resolve_target_type(entry["type_word"])
|
||||
if target_type is None:
|
||||
continue
|
||||
info.targets[(target_type, entry["id"])] = (entry["raw"], entry["clues"], entry["coord"])
|
||||
|
||||
info.destroyed = parse_destroyed(text)
|
||||
return info
|
||||
|
||||
|
||||
def run(image: Image.Image) -> ParsedInfo:
|
||||
return parse_text(ocr_text(image))
|
||||
34
src/ironnest_assist/shells.py
Normal file
34
src/ironnest_assist/shells.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""Shell (ammunition) types and their known properties.
|
||||
|
||||
From High Command's field reference. Blast radius is None where unknown —
|
||||
5 of 12 shells (42%) are "standard" (available without special unlock/
|
||||
research), and all three shells with a known blast radius are standard
|
||||
ones; the rest are still unmeasured. Feeds into the firing-solution
|
||||
calculator later (ordnance choice affects what's needed to make a shot
|
||||
count, e.g. AP required for underground supply caches per the typewriter).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Shell(Enum):
|
||||
# description blast_radius_km standard
|
||||
AP = ("Armor-piercing", 0.05, True)
|
||||
HE = ("High-explosive fragmentation", 0.25, True)
|
||||
HCHE = ("High-capacity bursting charge", 0.50, True)
|
||||
STAR = ("Illumination star", None, True)
|
||||
SMK = ("White phosphorus smoke", None, True)
|
||||
MSTD = ("Mustard (YX) blister agent", None, False)
|
||||
TEAR = ("Lachrymatory (CN) irritant", None, False)
|
||||
PRPG = ("Agitation leaflet propaganda", None, False)
|
||||
PHGN = ("Phosgene (CG) choking gas", None, False)
|
||||
INCN = ("Thermite incendiary", None, False)
|
||||
ATMC = ("Experimental uranium nucleus-splitting annihilation", None, False)
|
||||
CLMN = ("Cluster mine dispersal", None, False)
|
||||
|
||||
def __init__(self, description: str, blast_radius_km: float | None, standard: bool) -> None:
|
||||
self.description = description
|
||||
self.blast_radius_km = blast_radius_km # None = not yet measured
|
||||
self.standard = standard # available without a special unlock/research
|
||||
244
src/ironnest_assist/solver.py
Normal file
244
src/ironnest_assist/solver.py
Normal file
@ -0,0 +1,244 @@
|
||||
"""Resolve relative Clues (bearing/distance from another entity) into
|
||||
absolute Coords.
|
||||
|
||||
Geometry lives in "board units" = km: one large grid cell is 1km x 1km, and
|
||||
Coord.as_fraction() already returns (col, row) in exactly those units, so
|
||||
no extra scale factor is needed. Bearing is compass-style: 0 = north
|
||||
(+row, since row increases upward on the map same as in-game), 90 = east
|
||||
(+col), clockwise.
|
||||
|
||||
Solvable shapes, in priority order (matches everything seen in real
|
||||
typewriter data so far):
|
||||
1. One clue with both bearing and distance from a resolved reference
|
||||
-> direct polar projection. Always unique.
|
||||
2. Two bearing-only clues from different resolved references
|
||||
-> ray/ray intersection. Always unique (unless parallel).
|
||||
3. A bearing-only clue + a distance-only clue (different references)
|
||||
-> ray/circle intersection. A ray can cross a circle at 0, 1, or 2
|
||||
points.
|
||||
4. Two distance-only clues from different references
|
||||
-> circle/circle intersection. Two circles can cross at 0, 1, or 2
|
||||
points.
|
||||
Cases 3 and 4 surface a 2-point result as `potential` rather than a
|
||||
resolved `coord` — nothing here picks a "more likely" one of the two, so
|
||||
nothing downstream is allowed to depend on it either.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .models import LARGE_X, Board, Coord, Location, TargetType
|
||||
|
||||
Point = tuple[float, float] # (col, row) in km
|
||||
|
||||
|
||||
@dataclass
|
||||
class SolveResult:
|
||||
coord: Coord | None = None
|
||||
potential: list[Coord] = field(default_factory=list)
|
||||
|
||||
|
||||
def bearing_distance_to_delta(bearing_deg: float, distance_km: float) -> Point:
|
||||
rad = math.radians(bearing_deg)
|
||||
return distance_km * math.sin(rad), distance_km * math.cos(rad)
|
||||
|
||||
|
||||
def point_from_bearing_distance(origin: Point, bearing_deg: float, distance_km: float) -> Point:
|
||||
dcol, drow = bearing_distance_to_delta(bearing_deg, distance_km)
|
||||
return origin[0] + dcol, origin[1] + drow
|
||||
|
||||
|
||||
def ray_circle_intersections(
|
||||
origin: Point, bearing_deg: float, center: Point, radius_km: float
|
||||
) -> list[Point]:
|
||||
"""All points at distance >= 0 along the bearing ray from `origin` that
|
||||
lie on the circle of `radius_km` around `center`, nearest first. 0, 1,
|
||||
or 2 points."""
|
||||
rad = math.radians(bearing_deg)
|
||||
dx, dy = math.sin(rad), math.cos(rad)
|
||||
ox, oy = origin[0] - center[0], origin[1] - center[1]
|
||||
|
||||
b = 2 * (dx * ox + dy * oy)
|
||||
c = ox * ox + oy * oy - radius_km * radius_km
|
||||
disc = b * b - 4 * c
|
||||
if disc < 0:
|
||||
return []
|
||||
|
||||
sqrt_disc = math.sqrt(disc)
|
||||
ts = sorted(t for t in ((-b - sqrt_disc) / 2, (-b + sqrt_disc) / 2) if t >= 1e-9)
|
||||
if len(ts) == 2 and abs(ts[0] - ts[1]) < 1e-6:
|
||||
ts = ts[:1] # tangent: two roots collapse to one point
|
||||
return [(origin[0] + dx * t, origin[1] + dy * t) for t in ts]
|
||||
|
||||
|
||||
def circle_circle_intersections(
|
||||
center_a: Point, radius_a: float, center_b: Point, radius_b: float
|
||||
) -> list[Point]:
|
||||
"""Points where two circles cross, arbitrary order. 0, 1, or 2 points;
|
||||
also 0 for coincident circles (infinitely many "intersections" —
|
||||
nothing useful to return)."""
|
||||
ax, ay = center_a
|
||||
bx, by = center_b
|
||||
dx, dy = bx - ax, by - ay
|
||||
d = math.hypot(dx, dy)
|
||||
|
||||
if d < 1e-9:
|
||||
return [] # same center — either no solution (r differs) or infinite (r same); neither is useful
|
||||
if d > radius_a + radius_b + 1e-9 or d < abs(radius_a - radius_b) - 1e-9:
|
||||
return [] # too far apart, or one circle nested inside the other with no crossing
|
||||
|
||||
a = (radius_a**2 - radius_b**2 + d**2) / (2 * d)
|
||||
h = math.sqrt(max(radius_a**2 - a**2, 0.0))
|
||||
px, py = ax + a * dx / d, ay + a * dy / d
|
||||
|
||||
if h < 1e-9:
|
||||
return [(px, py)] # tangent circles: one touching point
|
||||
|
||||
perp_x, perp_y = -dy / d, dx / d
|
||||
return [(px + h * perp_x, py + h * perp_y), (px - h * perp_x, py - h * perp_y)]
|
||||
|
||||
|
||||
def ray_ray_intersection(
|
||||
origin_a: Point, bearing_a: float, origin_b: Point, bearing_b: float
|
||||
) -> Point | None:
|
||||
rad_a, rad_b = math.radians(bearing_a), math.radians(bearing_b)
|
||||
dax, day = math.sin(rad_a), math.cos(rad_a)
|
||||
dbx, dby = math.sin(rad_b), math.cos(rad_b)
|
||||
|
||||
denom = dax * dby - day * dbx
|
||||
if abs(denom) < 1e-9:
|
||||
return None # parallel bearings, no unique intersection
|
||||
|
||||
ex, ey = origin_b[0] - origin_a[0], origin_b[1] - origin_a[1]
|
||||
t = (ex * dby - ey * dbx) / denom
|
||||
return origin_a[0] + dax * t, origin_a[1] + day * t
|
||||
|
||||
|
||||
def point_to_coord(point: Point) -> Coord | None:
|
||||
"""(col, row) km -> Coord, or None if it's meaningfully off the 20x10
|
||||
map (rather than just a hair over from rounding)."""
|
||||
col, row = point
|
||||
if not (-0.5 <= col <= 20.5 and -0.5 <= row <= 10.5):
|
||||
return None
|
||||
|
||||
col = min(max(col, 0.0), 19.999)
|
||||
row = min(max(row, 0.0), 9.999)
|
||||
|
||||
x_idx = int(col)
|
||||
x = round((col - x_idx) * 10)
|
||||
if x > 9:
|
||||
x, x_idx = 0, min(x_idx + 1, 19)
|
||||
|
||||
Y = int(row) + 1
|
||||
y = round((row - (Y - 1)) * 10)
|
||||
if y > 9:
|
||||
y, Y = 0, min(Y + 1, 10)
|
||||
|
||||
return Coord(X=LARGE_X[x_idx], Y=Y, x=x, y=y)
|
||||
|
||||
|
||||
def _entity_point(board: Board, name: str) -> Point | None:
|
||||
obj = board.find_by_name(name)
|
||||
if obj is None or obj.coord is None:
|
||||
return None
|
||||
return obj.coord.as_fraction()
|
||||
|
||||
|
||||
def solve_location(location: Location, board: Board) -> SolveResult:
|
||||
"""Try to resolve `location` from its clues, given everything currently
|
||||
resolved on `board`. Returns a definitive `coord`, or `potential`
|
||||
candidates when the geometry is genuinely ambiguous, or neither if
|
||||
there's not enough resolved info yet."""
|
||||
if location.coord is not None:
|
||||
return SolveResult(coord=location.coord)
|
||||
|
||||
resolved = [(clue, pt) for clue in location.clues if (pt := _entity_point(board, clue.reference)) is not None]
|
||||
if not resolved:
|
||||
return SolveResult()
|
||||
|
||||
for clue, pt in resolved:
|
||||
if clue.bearing_deg is not None and clue.distance_km is not None:
|
||||
coord = point_to_coord(point_from_bearing_distance(pt, clue.bearing_deg, clue.distance_km))
|
||||
if coord is not None:
|
||||
return SolveResult(coord=coord)
|
||||
|
||||
bearings = [(c, p) for c, p in resolved if c.bearing_deg is not None and c.distance_km is None]
|
||||
distances = [(c, p) for c, p in resolved if c.distance_km is not None and c.bearing_deg is None]
|
||||
|
||||
if len(bearings) >= 2:
|
||||
(c1, p1), (c2, p2) = bearings[0], bearings[1]
|
||||
point = ray_ray_intersection(p1, c1.bearing_deg, p2, c2.bearing_deg)
|
||||
if point is not None:
|
||||
coord = point_to_coord(point)
|
||||
if coord is not None:
|
||||
return SolveResult(coord=coord)
|
||||
|
||||
if bearings and distances:
|
||||
(cb, pb), (cd, pd) = bearings[0], distances[0]
|
||||
points = ray_circle_intersections(pb, cb.bearing_deg, pd, cd.distance_km)
|
||||
coords = [c for p in points if (c := point_to_coord(p)) is not None]
|
||||
if len(coords) == 1:
|
||||
return SolveResult(coord=coords[0])
|
||||
if len(coords) >= 2:
|
||||
return SolveResult(potential=coords) # genuinely ambiguous
|
||||
|
||||
if len(distances) >= 2:
|
||||
(c1, p1), (c2, p2) = distances[0], distances[1]
|
||||
points = circle_circle_intersections(p1, c1.distance_km, p2, c2.distance_km)
|
||||
coords = [c for p in points if (c := point_to_coord(p)) is not None]
|
||||
if len(coords) == 1:
|
||||
return SolveResult(coord=coords[0])
|
||||
if len(coords) >= 2:
|
||||
return SolveResult(potential=coords) # genuinely ambiguous
|
||||
|
||||
return SolveResult()
|
||||
|
||||
|
||||
def resolve_board(board: Board) -> list[str]:
|
||||
"""Resolve every not-yet-resolved RP/Target whose clues can currently
|
||||
be satisfied, repeating until a fixed point (handles dependency
|
||||
chains like AmmoCache#3 -> AmmoCache#2 -> Alpha/Spotters). Ambiguous
|
||||
results are recorded as `potential_coords` and never feed further
|
||||
resolution. Returns the names of everything newly *resolved* (not
|
||||
counting ones that only became ambiguous) this call."""
|
||||
newly_resolved: list[str] = []
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for entities in (board.reference_points, board.targets):
|
||||
for obj in entities:
|
||||
if obj.coord is not None or obj.location.potential_coords:
|
||||
continue # already resolved, or stuck ambiguous — don't reprocess
|
||||
result = solve_location(obj.location, board)
|
||||
if result.coord is not None:
|
||||
obj.coord = result.coord
|
||||
newly_resolved.append(obj.name)
|
||||
changed = True
|
||||
elif result.potential:
|
||||
obj.location.potential_coords = result.potential
|
||||
return newly_resolved
|
||||
|
||||
|
||||
def dedupe_generic_targets(board: Board) -> list[str]:
|
||||
"""A target is often first spotted before it's identified, coming in
|
||||
as the generic TargetType.UNKNOWN ("Target#N"). If a later report
|
||||
identifies it with a specific type and it resolves to the *exact
|
||||
same* position as an already-known specific target, it's not a new
|
||||
contact — it's the same one being spotted, just described more
|
||||
precisely. Drop the redundant generic entry, keep the specific one.
|
||||
Strikes are our own planned impacts, not enemy contacts, and never
|
||||
participate. Run this after resolve_board(), since positions may
|
||||
only become comparable once resolved. Returns the names removed."""
|
||||
removed: list[str] = []
|
||||
unknowns = [t for t in board.targets if t.type is TargetType.UNKNOWN and t.coord is not None]
|
||||
specifics = [
|
||||
t for t in board.targets
|
||||
if t.type not in (TargetType.UNKNOWN, TargetType.STRIKE) and t.coord is not None
|
||||
]
|
||||
for generic in unknowns:
|
||||
if any(generic.coord == specific.coord for specific in specifics):
|
||||
board.remove_target(generic)
|
||||
removed.append(generic.name)
|
||||
return removed
|
||||
Loading…
Reference in New Issue
Block a user