From 8bec273b3f8b6e559d4e931cc324f80f6837f624 Mon Sep 17 00:00:00 2001 From: Dominik Roth Date: Sat, 8 Aug 2026 20:01:50 +0200 Subject: [PATCH] Initial commit: IRON NEST Assist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 4 + README.md | 154 +++++ requirements.txt | 9 + run.sh | 5 + src/ironnest_assist/__init__.py | 1 + src/ironnest_assist/app.py | 881 ++++++++++++++++++++++++++++ src/ironnest_assist/ballistics.py | 46 ++ src/ironnest_assist/coord_dialog.py | 279 +++++++++ src/ironnest_assist/firing_panel.py | 435 ++++++++++++++ src/ironnest_assist/grid_widget.py | 533 +++++++++++++++++ src/ironnest_assist/models.py | 526 +++++++++++++++++ src/ironnest_assist/ocr.py | 463 +++++++++++++++ src/ironnest_assist/shells.py | 34 ++ src/ironnest_assist/solver.py | 244 ++++++++ 14 files changed, 3614 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 requirements.txt create mode 100755 run.sh create mode 100644 src/ironnest_assist/__init__.py create mode 100644 src/ironnest_assist/app.py create mode 100644 src/ironnest_assist/ballistics.py create mode 100644 src/ironnest_assist/coord_dialog.py create mode 100644 src/ironnest_assist/firing_panel.py create mode 100644 src/ironnest_assist/grid_widget.py create mode 100644 src/ironnest_assist/models.py create mode 100644 src/ironnest_assist/ocr.py create mode 100644 src/ironnest_assist/shells.py create mode 100644 src/ironnest_assist/solver.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..609fcd4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +captures/*.png +.venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..cae2cde --- /dev/null +++ b/README.md @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..02fb921 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..bacd21f --- /dev/null +++ b/run.sh @@ -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 diff --git a/src/ironnest_assist/__init__.py b/src/ironnest_assist/__init__.py new file mode 100644 index 0000000..450ac18 --- /dev/null +++ b/src/ironnest_assist/__init__.py @@ -0,0 +1 @@ +"""ironnest-assist: screen-reading helper for IRON NEST: Heavy Turret Simulator.""" diff --git a/src/ironnest_assist/app.py b/src/ironnest_assist/app.py new file mode 100644 index 0000000..ccd557d --- /dev/null +++ b/src/ironnest_assist/app.py @@ -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"{GLib.markup_escape_text(name)}") + 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("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()) diff --git a/src/ironnest_assist/ballistics.py b/src/ironnest_assist/ballistics.py new file mode 100644 index 0000000..ee3fc2a --- /dev/null +++ b/src/ironnest_assist/ballistics.py @@ -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 diff --git a/src/ironnest_assist/coord_dialog.py b/src/ironnest_assist/coord_dialog.py new file mode 100644 index 0000000..f3a06c8 --- /dev/null +++ b/src/ironnest_assist/coord_dialog.py @@ -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 diff --git a/src/ironnest_assist/firing_panel.py b/src/ironnest_assist/firing_panel.py new file mode 100644 index 0000000..ead098d --- /dev/null +++ b/src/ironnest_assist/firing_panel.py @@ -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() diff --git a/src/ironnest_assist/grid_widget.py b/src/ironnest_assist/grid_widget.py new file mode 100644 index 0000000..9224f5c --- /dev/null +++ b/src/ironnest_assist/grid_widget.py @@ -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)) diff --git a/src/ironnest_assist/models.py b/src/ironnest_assist/models.py new file mode 100644 index 0000000..f077e3e --- /dev/null +++ b/src/ironnest_assist/models.py @@ -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", []) + ] diff --git a/src/ironnest_assist/ocr.py b/src/ironnest_assist/ocr.py new file mode 100644 index 0000000..6f18c6d --- /dev/null +++ b/src/ironnest_assist/ocr.py @@ -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 "#" 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)) diff --git a/src/ironnest_assist/shells.py b/src/ironnest_assist/shells.py new file mode 100644 index 0000000..95cf1b8 --- /dev/null +++ b/src/ironnest_assist/shells.py @@ -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 diff --git a/src/ironnest_assist/solver.py b/src/ironnest_assist/solver.py new file mode 100644 index 0000000..c7c3a8d --- /dev/null +++ b/src/ironnest_assist/solver.py @@ -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