Initial commit: IRON NEST Assist

GTK4/libadwaita desktop helper for IRON NEST: Heavy Turret Simulator.
Reads clipboard screenshots of the game's typewriter orders via Tesseract
OCR, parses absolute/relative entity positions, geometrically resolves
relative bearing/distance clues into map coordinates, and provides a
firing-commands sidebar with real ballistics (elevation/azimuth/powder
charge). Screen-reading only — no game files touched, no input injected.

- models.py: Board/Nest/Spotter/ReferencePoint/Target data model
- ocr.py: Tesseract preprocessing + typewriter-text parsing
- solver.py: bearing/distance geometric resolution (4 solvable shapes)
  plus position-based dedup for generic contacts later identified more
  specifically at the same resolved coord
- ballistics.py / shells.py: elevation/azimuth/charge math, shell types
- grid_widget.py: interactive map canvas (hidden entities and,
  optionally, dead targets are excluded from the map view entirely
  unless selected)
- firing_panel.py: drag-reorderable firing-command sidebar
- app.py: main window wiring it all together

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Dominik Moritz Roth 2026-08-08 20:01:50 +02:00
commit 8bec273b3f
14 changed files with 3614 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
__pycache__/
*.pyc
captures/*.png
.venv/

154
README.md Normal file
View File

@ -0,0 +1,154 @@
# ironnest-assist
Screen-reading helper for **IRON NEST: Heavy Turret Simulator**. Tracks the
Nest, Spotters, Reference Points, and Targets on a map matching the game's
grid, settable by hand, by a free-text description, or by OCR-ing a
clipboard screenshot of the typewriter order. A solver resolves relative
descriptions ("Bearing 293 from Alpha") into absolute coordinates once
their dependencies are known. Single-player QoL tool — no game files
touched, no input injected.
## Stack
- **GTK4 + libadwaita** (via PyGObject) — native GNOME look on Linux. GTK4
itself also runs on Windows/macOS (Win32/Quartz backends), just without
full Adwaita chrome there.
- **Pillow / numpy** — image preprocessing for OCR.
- **pytesseract + tesseract** — text OCR of the typewriter order.
## Run
```bash
./run.sh
```
GTK apps with this app ID are single-instance — if a run gets killed
uncleanly it can leave a zombie registered on D-Bus and silently no-op the
next launch. If `./run.sh` seems to do nothing, `pgrep -af ironnest_assist`
and kill any stragglers first.
## Coordinate system
Large grid: `X` in `A``T` (20 cols), `Y` in `1``10` (10 rows, row 1 at
the bottom). Sub-grid within a cell: `x`, `y` in `0``9`. **One large cell
is 1km × 1km** — that scale is what the solver's bearing/distance math
runs on. Quick keyboard entry in the coord dialog: type e.g. `C433`
(letter + 3 digits) to fill and submit in one go — `0` for the `Y` digit
means `10`.
### Location: coord and/or description
Every entity's `location` (`models.py`) independently holds:
- `coord` — a resolved absolute position.
- `desc_raw` + `clues` — a raw free-text description and the `Clue`s
parsed out of it (`reference`, `bearing_deg`, `distance_km`), each
naming another entity it's relative to.
- `potential_coords` — set instead of `coord` when the solver found the
position genuinely ambiguous (see below); shown on the map, never
chained into further resolution.
These aren't either/or — setting a coord never erases an existing
description (and vice versa), since a coord can arrive via OCR *after* a
description was already on file, or a description can be added as extra
context for an already-placed entity.
### Solver (`solver.py`)
Walks every RP/Target's clues, resolving whatever it can against
currently-known positions, repeating until nothing new resolves (handles
chains, e.g. `AmmoCache#3``AmmoCache#2``Alpha`/Spotters). Handles:
1. One clue with both bearing *and* distance from a resolved reference —
always unique.
2. Two bearing-only clues from different references — ray/ray
intersection, always unique (unless parallel).
3. A bearing-only + a distance-only clue from different references —
ray/circle intersection. A ray can cross a circle at 0, 1, or 2 points;
when there are 2, that's genuinely ambiguous — both candidates go into
`potential_coords` instead of picking one, and nothing depends on them
further.
Two distance-only clues (circle/circle, also up to 2 solutions) isn't
handled — even less to disambiguate with.
Runs automatically after every mutation (`MainWindow._refresh()` is the
single choke point), so newly-unblocked descriptions resolve immediately.
### Map overlays
Every entity row has an eye-icon (hide from map) and a star-icon (always
show its bearing/distance overlay, vs. only on hover). Hovering a marker,
or pinning it with the star, draws its clues: a yellow line for a
bearing-only clue, a white circle (radius = distance) for a distance-only
clue, a yellow arrow when a single clue has both. Ambiguous entities draw
both `potential_coords` as hollow dashed markers. Targets also have an
alive/destroyed toggle (checkmark icon) — dead ones show struck-through in
their list and dimmed on the map.
## OCR
Two sub-pipelines, planned: **text** (typewriter orders, implemented) and
**image** (map icons/markers, not started). Text OCR: screenshot →
grayscale → divide by a heavily-blurred copy of itself to flatten the
game's light-falloff vignette → threshold → `tesseract --psm 6` → parsing.
See `src/ironnest_assist/ocr.py`.
Two text formats parsed, both fuzzy/typo-tolerant (`difflib` keyword
matching, digit/letter OCR-mixup normalization `O`/`0` `I`/`l`/`1` `S`/`5`
`B`/`8` etc., and every separator — `#`, `:`, block-terminating `.`
treated as just as corruptible as any other character, never matched
literally):
1. **Absolute grid refs**`IRON NEST - C4 3:3`, `Spotter#1 - F7 7:1`
`nest_coord`, `spotters`.
2. **Field-intelligence blocks** — named entity header (`Target#5`,
`AmmoCache#1:`, `Reference Point Alpha:`) followed by one or more
`Bearing`/`Distance`/combined clue lines, terminated by a blank line or
lone `.``reference_points`, `targets`, each as (raw text, parsed
clues). The same clue grammar (`ocr.parse_clues_from_text`) also backs
the manual "Description" tab in the coord-entry dialog.
- Header clipboard button (or `Ctrl+P`): universal fetch — merges
everything recognized into the board.
- A category's "from screenshot" actions extract only that category;
toasts an error if the requested item isn't found in the screenshot.
- "Load all from screenshot" / a list item's screenshot action both use
the same merge: same name/id → update in place, new → add.
Known limitation: OCR on a *heavily skewed/rotated* screenshot degrades
badly — psm 6 assumes a roughly-upright text block, and deskewing isn't
implemented. A front-on-ish shot works well. Considered switching OCR
engines (vision-LLM structured extraction, PaddleOCR/EasyOCR) instead of
continuing to special-case Tesseract's misreads one at a time — staying
on Tesseract per your call for now.
## Ammunition (`shells.py`)
`Shell` enum from High Command's field reference — description, blast
radius in km (`None` where not yet measured), and whether it's a
"standard" (unlocked-by-default) type. Feeds the firing-solution
calculator later (e.g. AP is required for underground supply caches per
the typewriter note).
## Firing commands panel
Header button (top-right, `sidebar-show-right-symbolic`) slides in a
right-hand sidebar via `Adw.OverlaySplitView` — it shares the window's
width with the map (narrows it), rather than overlaying on top. One
placeholder card per board target (`firing_panel.py`): shell + quantity,
elevation/azimuth readout, confirm/cancel — all dummy values for now,
since the actual aiming math isn't known yet (see open questions).
## Status
Board data model, map view + overlays, exact/description coordinate
input, save/load to JSON, text OCR for both known typewriter formats, the
bearing/distance solver, and the firing-commands panel shell are all
working. Still open: image OCR sub-pipeline, deskewing, circle/circle
ambiguous case, and the actual firing-solution calculator (turret aiming
math still unknown — the panel currently just shows placeholder cards).
## Open questions
- What inputs the firing-solution math actually needs, beyond target
position and shell choice

9
requirements.txt Normal file
View File

@ -0,0 +1,9 @@
# PyGObject (GTK4 + libadwaita bindings) is installed as a system package
# on this machine, not via pip — on Fedora: `sudo dnf install python3-gobject
# gtk4 libadwaita`. Listed here for reference, not installed by pip.
#
# pygobject
Pillow
numpy
pytesseract

5
run.sh Executable file
View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Launch IronNest Assist.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
exec env PYTHONPATH=src python3 -m ironnest_assist.app

View File

@ -0,0 +1 @@
"""ironnest-assist: screen-reading helper for IRON NEST: Heavy Turret Simulator."""

881
src/ironnest_assist/app.py Normal file
View File

@ -0,0 +1,881 @@
"""IronNest Assist — GTK4/libadwaita app entry point.
Layout: a row of category dropdowns on top (+ a universal clipboard-fetch
button), the map/grid filling the center. Coordinates can be set by exact
numeric input, by a free-text relative description ("Bearing 293 from
Alpha"), or extracted from a clipboard screenshot via the text OCR
pipeline. A solver resolves relative descriptions into absolute
coordinates whenever their dependencies become known (see solver.py);
every mutation runs through _refresh(), which is the single choke point
for that.
"""
from __future__ import annotations
import io
import json
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
gi.require_version("Gdk", "4.0")
from gi.repository import Adw, Gdk, Gio, GLib, Gtk # noqa: E402
from PIL import Image # noqa: E402
from . import ballistics, ocr, solver # noqa: E402
from .coord_dialog import CoordDialog # noqa: E402
from .firing_panel import FiringPanel # noqa: E402
from .grid_widget import GridCanvas # noqa: E402
from .models import Board, Location, Target, TargetType # noqa: E402
from .shells import Shell # noqa: E402
APP_ID = "eu.dominik-roth.IronNestAssist"
def _apply_location(obj, location: Location) -> None:
"""Apply a Location patch from CoordDialog: a resolved coord and a
description are independent, so only touch whichever half is set,
leaving the other (already on `obj`) alone."""
if location.coord is not None:
obj.coord = location.coord # preserves existing desc_raw/clues, clears potential_coords
if location.desc_raw is not None:
obj.location.desc_raw = location.desc_raw
obj.location.clues = location.clues
def _row(
name: str,
*,
on_input,
on_screenshot,
on_remove=None,
hidden=False,
on_toggle_hidden=None,
show_geo=False,
on_toggle_show_geo=None,
alive=None,
on_toggle_alive=None,
) -> Gtk.Widget:
"""One list entry: a label plus input/screenshot/geo/hide[/alive][/remove]
action buttons."""
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
box.set_margin_top(4)
box.set_margin_bottom(4)
box.set_margin_start(8)
box.set_margin_end(8)
label = Gtk.Label(xalign=0, hexpand=True)
if alive is False:
label.set_markup(f"<s>{GLib.markup_escape_text(name)}</s>")
else:
label.set_label(name)
if hidden:
label.add_css_class("dim-label")
box.append(label)
input_btn = Gtk.Button(icon_name="document-edit-symbolic", tooltip_text="Set coords from input")
input_btn.connect("clicked", lambda _b: on_input())
box.append(input_btn)
shot_btn = Gtk.Button(icon_name="insert-image-symbolic", tooltip_text="Set coords from screenshot")
shot_btn.connect("clicked", lambda _b: on_screenshot())
box.append(shot_btn)
if on_toggle_show_geo is not None:
geo_btn = Gtk.Button(
icon_name="starred-symbolic" if show_geo else "non-starred-symbolic",
tooltip_text="Always show bearing/distance overlay" if not show_geo
else "Only show overlay on hover",
)
geo_btn.add_css_class("flat")
geo_btn.connect("clicked", lambda _b: on_toggle_show_geo())
box.append(geo_btn)
if on_toggle_alive is not None:
alive_btn = Gtk.Button(
icon_name="object-select-symbolic" if alive else "action-unavailable-symbolic",
tooltip_text="Mark destroyed" if alive else "Mark alive",
)
alive_btn.add_css_class("flat")
alive_btn.connect("clicked", lambda _b: on_toggle_alive())
box.append(alive_btn)
if on_toggle_hidden is not None:
hide_btn = Gtk.Button(
icon_name="view-reveal-symbolic" if hidden else "view-conceal-symbolic",
tooltip_text="Show on map" if hidden else "Hide from map",
)
hide_btn.add_css_class("flat")
hide_btn.connect("clicked", lambda _b: on_toggle_hidden())
box.append(hide_btn)
if on_remove is not None:
rm_btn = Gtk.Button(icon_name="user-trash-symbolic", tooltip_text="Remove")
rm_btn.add_css_class("flat")
rm_btn.connect("clicked", lambda _b: on_remove())
box.append(rm_btn)
return box
def _location_status(obj) -> str:
if obj.coord is not None:
return obj.coord.label()
if obj.location.potential_coords:
return f"ambiguous ({len(obj.location.potential_coords)} candidates)"
if obj.location.desc_raw:
return "from description"
return "not set"
_FIRING_CARD_CSS = """
.firing-card { transition: background-color 150ms ease, border-color 150ms ease; }
.firing-card-hovered { background-color: alpha(@accent_color, 0.12); }
.firing-card-selected { border: 2px solid @accent_color; }
"""
def _install_css() -> None:
provider = Gtk.CssProvider()
provider.load_from_string(_FIRING_CARD_CSS)
Gtk.StyleContext.add_provider_for_display(
Gdk.Display.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
)
class MainWindow(Adw.ApplicationWindow):
def __init__(self, app: Adw.Application) -> None:
super().__init__(application=app, title="IronNest Assist")
self.set_default_size(1100, 750)
_install_css()
self.board = Board()
self.toast_overlay = Adw.ToastOverlay()
self.set_content(self.toast_overlay)
toolbar_view = Adw.ToolbarView()
self.toast_overlay.set_child(toolbar_view)
header = Adw.HeaderBar()
header.set_title_widget(Gtk.Box()) # drop the window-title label, header's crowded
toolbar_view.add_top_bar(header)
save_btn = Gtk.Button(icon_name="document-save-symbolic")
save_btn.set_tooltip_text("Save board to file")
save_btn.connect("clicked", lambda _b: self._save_to_file())
header.pack_start(save_btn)
load_btn = Gtk.Button(icon_name="document-open-symbolic")
load_btn.set_tooltip_text("Load board from file")
load_btn.connect("clicked", lambda _b: self._load_from_file())
header.pack_start(load_btn)
clip_btn = Gtk.Button(icon_name="edit-paste-symbolic")
clip_btn.set_tooltip_text("Fetch screenshot from clipboard (Ctrl+P)")
clip_btn.connect("clicked", lambda _b: self._fetch_clipboard())
header.pack_start(clip_btn)
header.pack_start(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL))
for label, popover_builder in (
("Nest", self._build_nest_popover),
("Spotters", self._build_spotters_popover),
("Reference Points", self._build_rp_popover),
("Targets", self._build_targets_popover),
):
header.pack_start(self._make_menu_button(label, popover_builder))
strike_btn = Gtk.Button(icon_name="find-location-symbolic")
strike_btn.set_tooltip_text("Add strike")
strike_btn.connect("clicked", lambda _b: self._add_strike())
header.pack_start(strike_btn)
firing_btn = Gtk.Button(icon_name="sidebar-show-right-symbolic")
firing_btn.set_tooltip_text("Firing commands")
firing_btn.connect("clicked", lambda _b: self._toggle_firing_panel())
header.pack_end(firing_btn)
self.cursor_label = Gtk.Label(xalign=1)
self.cursor_label.add_css_class("dim-label")
self.cursor_label.add_css_class("numeric")
header.pack_end(self.cursor_label) # packed after firing_btn -> sits to its left
self.canvas = GridCanvas(self.board)
self.firing_panel = FiringPanel(
self.board,
on_change=self._refresh,
on_select=self._set_selection,
on_edit_position=self._edit_target_position,
on_set_position=self._start_target_placement,
on_remove=self._remove_target_via_panel,
on_toggle_hide_dead_map=self._on_toggle_hide_dead_map,
)
self.canvas.on_select = self._set_selection
self.canvas.on_hover_change = self._on_map_hover_change
self.canvas.on_cursor_move = self._on_cursor_move
self.canvas.on_right_click = self._on_map_right_click
self.split_view = Adw.OverlaySplitView()
self.split_view.set_content(self.canvas)
self.split_view.set_sidebar(self.firing_panel)
self.split_view.set_sidebar_position(Gtk.PackType.END)
self.split_view.set_min_sidebar_width(280)
self.split_view.set_max_sidebar_width(360)
self.split_view.set_show_sidebar(False)
toolbar_view.set_content(self.split_view)
controller = Gtk.ShortcutController()
controller.add_shortcut(
Gtk.Shortcut.new(
Gtk.ShortcutTrigger.parse_string("<Control>p"),
Gtk.CallbackAction.new(lambda *_a: self._fetch_clipboard() or True),
)
)
self.add_controller(controller)
# -- generic helpers -----------------------------------------------------
def _make_menu_button(self, label: str, build_popover) -> Gtk.MenuButton:
"""build_popover(rebuild) returns the popover's content widget;
rebuild() lets a row's own callback refresh the popover in place
(e.g. after toggling hidden, or removing an item) instead of only
refreshing next time it's reopened."""
button = Gtk.MenuButton(label=label)
popover = Gtk.Popover()
popover.set_size_request(280, -1)
button.set_popover(popover)
def rebuild():
popover.set_child(build_popover(rebuild))
popover.connect("show", lambda _p: rebuild())
return button
def toast(self, message: str) -> None:
self.toast_overlay.add_toast(Adw.Toast(title=message, timeout=3))
# -- save / load -----------------------------------------------------------
def _save_to_file(self) -> None:
dialog = Gtk.FileDialog(initial_name="board.json")
dialog.save(self, None, self._on_save_finish)
def _on_save_finish(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> None:
try:
gfile = dialog.save_finish(result)
except GLib.Error as exc:
if exc.matches(Gtk.dialog_error_quark(), Gtk.DialogError.DISMISSED):
return
self.toast(f"Save failed: {exc.message}")
return
data = json.dumps(self.board.to_dict(), indent=2)
try:
gfile.replace_contents(
data.encode("utf-8"), None, False, Gio.FileCreateFlags.NONE, None
)
except GLib.Error as exc:
self.toast(f"Save failed: {exc.message}")
return
self.toast(f"Saved to {gfile.get_path()}")
def _load_from_file(self) -> None:
dialog = Gtk.FileDialog()
dialog.open(self, None, self._on_load_finish)
def _on_load_finish(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> None:
try:
gfile = dialog.open_finish(result)
except GLib.Error as exc:
if exc.matches(Gtk.dialog_error_quark(), Gtk.DialogError.DISMISSED):
return
self.toast(f"Load failed: {exc.message}")
return
try:
ok, contents, _etag = gfile.load_contents(None)
data = json.loads(contents.decode("utf-8"))
self.board.load_from_dict(data)
except (GLib.Error, json.JSONDecodeError, KeyError, ValueError) as exc:
self.toast(f"Load failed: {exc}")
return
self._refresh()
self.toast(f"Loaded {gfile.get_path()}")
# -- OCR plumbing -----------------------------------------------------------
def _fetch_clipboard(self) -> None:
"""Header button / Ctrl+P: universal fetch — run OCR and merge everything found."""
self._run_ocr_from_clipboard(self._merge_all)
def _run_ocr_from_clipboard(self, on_parsed) -> None:
"""Read the clipboard image, OCR it, and call on_parsed(ParsedInfo)."""
clipboard = Gdk.Display.get_default().get_clipboard()
clipboard.read_texture_async(None, lambda cb, res: self._on_ocr_texture_ready(res, on_parsed))
def _on_ocr_texture_ready(self, result: Gio.AsyncResult, on_parsed) -> None:
clipboard = Gdk.Display.get_default().get_clipboard()
try:
texture = clipboard.read_texture_finish(result)
except GLib.Error as exc:
self.toast(f"No image on clipboard ({exc.message}).")
return
if texture is None:
self.toast("Clipboard has no image. Copy a screenshot first.")
return
try:
pil_image = Image.open(io.BytesIO(texture.save_to_png_bytes().get_data()))
info = ocr.run(pil_image)
except Exception as exc: # OCR/parsing hiccups shouldn't crash the app
self.toast(f"OCR failed: {exc}")
return
on_parsed(info)
def _merge_all(self, info: "ocr.ParsedInfo") -> None:
changed = []
if info.nest_coord is not None:
self.board.nest.coord = info.nest_coord
changed.append("Nest")
changed.extend(self._merge_spotters(info, toast=False))
changed.extend(self._merge_reference_points(info, toast=False))
changed.extend(self._merge_targets(info, toast=False))
if not changed:
self.toast("No relevant info found in screenshot.")
else:
self.toast("Merged from screenshot: " + ", ".join(changed))
self._refresh()
def _merge_spotters(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]:
changed = []
for spotter_id, coord in info.spotters.items():
existing = next((s for s in self.board.spotters if s.id == spotter_id), None)
if existing is not None:
existing.coord = coord
else:
existing = self.board.add_spotter(coord, spotter_id)
changed.append(existing.name)
if toast:
self.toast("No spotters found in screenshot." if not changed
else "Loaded from screenshot: " + ", ".join(changed))
self._refresh()
return changed
def _merge_parsed_location(self, existing, raw: str, clues, coord) -> None:
"""Refresh desc/clues from a fresh OCR read, but never clobber a
coord the entity already has from a prior resolve, an earlier
screenshot's Grid ref, or a manual Edit Pos override. Re-reading
the same intel later shouldn't undo that; only apply the new coord
if there wasn't one already."""
existing.location.desc_raw = raw
existing.location.clues = clues
if existing.coord is None:
existing.coord = coord
def _merge_reference_points(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]:
changed = []
for name, (raw, clues, coord) in info.reference_points.items():
existing = next((rp for rp in self.board.reference_points if rp.rp_name == name), None)
if existing is not None:
self._merge_parsed_location(existing, raw, clues, coord)
else:
existing = self.board.add_reference_point(
Location(coord=coord, desc_raw=raw, clues=clues), name=name
)
changed.append(existing.name)
if toast:
self.toast("No reference points found in screenshot." if not changed
else "Loaded from screenshot: " + ", ".join(changed))
self._refresh()
return changed
def _merge_targets(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]:
changed = []
for (target_type, target_id), (raw, clues, coord) in info.targets.items():
existing = next(
(t for t in self.board.targets if t.type == target_type and t.id == target_id), None
)
if existing is not None:
self._merge_parsed_location(existing, raw, clues, coord)
else:
existing = self.board.add_target(
target_type, Location(coord=coord, desc_raw=raw, clues=clues), id_=target_id
)
changed.append(existing.name)
# "SupplyCache#1 Destroyed." etc. Mark it dead if we already know
# it; if this destruction report is the *first* we've heard of it
# (never separately spotted with a position), still record it as a
# dead, position-unknown target rather than losing the report —
# better a target with no coord than no record it existed at all.
for target_type, target_id in info.destroyed:
existing = next(
(t for t in self.board.targets if t.type == target_type and t.id == target_id), None
)
is_new = existing is None
if existing is None:
existing = self.board.add_target(target_type, id_=target_id)
if existing.alive:
existing.alive = False
suffix = " (destroyed, position unknown)" if is_new else " (destroyed)"
changed.append(f"{existing.name}{suffix}")
if toast:
self.toast("No targets found in screenshot." if not changed
else "Loaded from screenshot: " + ", ".join(changed))
self._refresh()
return changed
def _set_nest_from_screenshot_info(self, info: "ocr.ParsedInfo") -> None:
if info.nest_coord is None:
self.toast("Nest position not found in screenshot.")
return
self.board.nest.coord = info.nest_coord
self._refresh()
self.toast(f"Nest set to {info.nest_coord.label()} from screenshot.")
def _set_spotter_from_screenshot_info(self, info: "ocr.ParsedInfo", spotter) -> None:
coord = info.spotters.get(spotter.id)
if coord is None:
self.toast(f"{spotter.name} not found in screenshot.")
return
spotter.coord = coord
self._refresh()
self.toast(f"{spotter.name} set to {coord.label()} from screenshot.")
def _set_rp_from_screenshot_info(self, info: "ocr.ParsedInfo", rp) -> None:
result = info.reference_points.get(rp.rp_name)
if result is None:
self.toast(f"{rp.name} not found in screenshot.")
return
raw, clues, coord = result
self._merge_parsed_location(rp, raw, clues, coord)
self._refresh()
self.toast(f"{rp.name} set from screenshot.")
def _set_target_from_screenshot_info(self, info: "ocr.ParsedInfo", target) -> None:
result = info.targets.get((target.type, target.id))
if result is None:
self.toast(f"{target.name} not found in screenshot.")
return
raw, clues, coord = result
self._merge_parsed_location(target, raw, clues, coord)
self._refresh()
self.toast(f"{target.name} set from screenshot.")
def _open_coord_dialog(
self, *, title, on_submit, show_id=False, show_type=False, id_placeholder=None,
initial_location=None, initial_id=None, initial_type=None,
):
dialog = CoordDialog(
title=title,
on_submit=on_submit,
show_id=show_id,
show_type=show_type,
id_placeholder=id_placeholder,
initial_location=initial_location,
initial_id=initial_id,
initial_type=initial_type,
)
dialog.present(self)
def _refresh(self) -> None:
"""Single choke point: re-run the solver, then redraw. Every
mutation goes through here so newly-unblocked descriptions get a
chance to resolve immediately."""
solver.resolve_board(self.board)
removed = solver.dedupe_generic_targets(self.board)
if removed and isinstance(self.canvas.selected, Target) and self.canvas.selected not in self.board.targets:
self._set_selection(None) # the selected generic target just got merged away
self.canvas.refresh()
self.firing_panel.refresh()
def _on_toggle_hide_dead_map(self, value: bool) -> None:
self.canvas.hide_dead_from_map = value
self._refresh()
def _toggle_firing_panel(self) -> None:
self.split_view.set_show_sidebar(not self.split_view.get_show_sidebar())
def _set_selection(self, obj, point=None) -> None:
"""Shared by both the map (click a marker) and the firing panel
(click a card) so clicking either keeps the other in sync. `point`
disambiguates which candidate of an ambiguous target was clicked."""
self.canvas.set_selected(obj, point)
self.firing_panel.set_selected(obj if isinstance(obj, Target) else None, point)
if isinstance(obj, Target) and not self.split_view.get_show_sidebar():
self.split_view.set_show_sidebar(True)
def _on_map_hover_change(self, obj, point=None) -> None:
self.firing_panel.set_hovered(obj if isinstance(obj, Target) else None, point)
def _on_cursor_move(self, point_km) -> None:
if point_km is None:
self.cursor_label.set_label("")
return
coord = solver.point_to_coord(point_km)
coord_text = coord.label() if coord is not None else "off map"
nest = self.board.nest
if nest.coord is not None:
az = ballistics.bearing_deg_point(nest.coord.as_fraction(), point_km)
dist = ballistics.distance_km_point(nest.coord.as_fraction(), point_km)
self.cursor_label.set_label(f"AZ {az:05.1f}° {dist:5.2f}km {coord_text}")
else:
self.cursor_label.set_label(coord_text)
# -- Nest -----------------------------------------------------------------
def _build_nest_popover(self, rebuild) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
box.set_margin_top(6)
box.set_margin_bottom(6)
nest = self.board.nest
box.append(_row(
f"Nest — {_location_status(nest)}",
on_input=lambda: self._open_coord_dialog(
title="Set Nest coordinates",
on_submit=lambda loc, _id, _t: self._apply_and_refresh(nest, loc),
initial_location=nest.location,
),
on_screenshot=lambda: self._run_ocr_from_clipboard(self._set_nest_from_screenshot_info),
hidden=nest.hidden,
on_toggle_hidden=lambda: self._toggle_hidden(nest, rebuild),
show_geo=nest.show_geo_desc,
on_toggle_show_geo=lambda: self._toggle_show_geo(nest, rebuild),
))
return box
# -- Spotters --------------------------------------------------------------
def _build_spotters_popover(self, rebuild) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
box.set_margin_top(6)
box.set_margin_bottom(6)
load_btn = Gtk.Button(label="Load all from screenshot")
load_btn.set_margin_start(8)
load_btn.set_margin_end(8)
load_btn.set_margin_bottom(4)
load_btn.connect("clicked", lambda _b: self._run_ocr_from_clipboard(self._merge_spotters))
box.append(load_btn)
box.append(Gtk.Separator())
for sp in list(self.board.spotters):
box.append(_row(
f"{sp.name}{_location_status(sp)}",
on_input=lambda sp=sp: self._open_coord_dialog(
title=f"Set {sp.name} coordinates",
on_submit=lambda loc, _id, _t, sp=sp: self._apply_and_refresh(sp, loc),
initial_location=sp.location,
),
on_screenshot=lambda sp=sp: self._run_ocr_from_clipboard(
lambda info, sp=sp: self._set_spotter_from_screenshot_info(info, sp)
),
on_remove=lambda sp=sp: self._remove_spotter(sp, rebuild),
hidden=sp.hidden,
on_toggle_hidden=lambda sp=sp: self._toggle_hidden(sp, rebuild),
show_geo=sp.show_geo_desc,
on_toggle_show_geo=lambda sp=sp: self._toggle_show_geo(sp, rebuild),
))
box.append(Gtk.Separator())
box.append(_row(
"Add spotter",
on_input=lambda: self._open_coord_dialog(
title="Add spotter",
on_submit=lambda loc, id_, _t: self._add_spotter(loc, id_),
show_id=True,
id_placeholder=f"ID (blank = auto, next: {self.board.next_spotter_id()})",
),
on_screenshot=lambda: self._run_ocr_from_clipboard(self._merge_spotters),
))
return box
def _add_spotter(self, location: Location, id_text: str | None) -> None:
parsed_id = None
if id_text:
try:
parsed_id = int(id_text)
except ValueError:
self.toast(f"Spotter ID must be a whole number, got {id_text!r}.")
return
self.board.add_spotter(location, parsed_id)
self._refresh()
def _remove_spotter(self, sp, rebuild) -> None:
self.board.remove_spotter(sp)
self._refresh()
rebuild()
# -- Reference Points --------------------------------------------------------
def _build_rp_popover(self, rebuild) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
box.set_margin_top(6)
box.set_margin_bottom(6)
load_btn = Gtk.Button(label="Load all from screenshot")
load_btn.set_margin_start(8)
load_btn.set_margin_end(8)
load_btn.set_margin_bottom(4)
load_btn.connect("clicked", lambda _b: self._run_ocr_from_clipboard(self._merge_reference_points))
box.append(load_btn)
box.append(Gtk.Separator())
for rp in list(self.board.reference_points):
box.append(_row(
f"{rp.name}{_location_status(rp)}",
on_input=lambda rp=rp: self._open_coord_dialog(
title=f"Set {rp.name} coordinates",
on_submit=lambda loc, _id, _t, rp=rp: self._apply_and_refresh(rp, loc),
initial_location=rp.location,
),
on_screenshot=lambda rp=rp: self._run_ocr_from_clipboard(
lambda info, rp=rp: self._set_rp_from_screenshot_info(info, rp)
),
on_remove=lambda rp=rp: self._remove_rp(rp, rebuild),
hidden=rp.hidden,
on_toggle_hidden=lambda rp=rp: self._toggle_hidden(rp, rebuild),
show_geo=rp.show_geo_desc,
on_toggle_show_geo=lambda rp=rp: self._toggle_show_geo(rp, rebuild),
))
box.append(Gtk.Separator())
box.append(_row(
"Add RP",
on_input=lambda: self._open_coord_dialog(
title="Add reference point",
on_submit=lambda loc, _id, _t: self._add_rp(loc),
),
on_screenshot=lambda: self._run_ocr_from_clipboard(self._merge_reference_points),
))
return box
def _add_rp(self, location: Location) -> None:
self.board.add_reference_point(location)
self._refresh()
def _remove_rp(self, rp, rebuild) -> None:
self.board.remove_reference_point(rp)
self._refresh()
rebuild()
# -- Targets -----------------------------------------------------------------
def _build_targets_popover(self, rebuild) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
box.set_margin_top(6)
box.set_margin_bottom(6)
load_btn = Gtk.Button(label="Load all from screenshot")
load_btn.set_margin_start(8)
load_btn.set_margin_end(8)
load_btn.set_margin_bottom(4)
load_btn.connect("clicked", lambda _b: self._run_ocr_from_clipboard(self._merge_targets))
box.append(load_btn)
box.append(Gtk.Separator())
for t in list(self.board.targets):
box.append(_row(
f"{t.name}{_location_status(t)}",
on_input=lambda t=t: self._open_coord_dialog(
title=f"Set {t.name} coordinates",
on_submit=lambda loc, _id, _t2, t=t: self._apply_and_refresh(t, loc),
initial_location=t.location,
),
on_screenshot=lambda t=t: self._run_ocr_from_clipboard(
lambda info, t=t: self._set_target_from_screenshot_info(info, t)
),
on_remove=lambda t=t: self._remove_target(t, rebuild),
hidden=t.hidden,
on_toggle_hidden=lambda t=t: self._toggle_hidden(t, rebuild),
show_geo=t.show_geo_desc,
on_toggle_show_geo=lambda t=t: self._toggle_show_geo(t, rebuild),
alive=t.alive,
on_toggle_alive=lambda t=t: self._toggle_alive(t, rebuild),
))
box.append(Gtk.Separator())
box.append(_row(
"Add target",
on_input=lambda: self._open_coord_dialog(
title="Add target",
on_submit=lambda loc, id_, type_: self._add_target(loc, id_, type_),
show_id=True,
show_type=True,
),
on_screenshot=lambda: self._run_ocr_from_clipboard(self._merge_targets),
))
return box
def _add_target(self, location: Location, id_, type_) -> None:
self.board.add_target(type_ or TargetType.UNKNOWN, location, id_)
self._refresh()
def _add_strike(self) -> None:
"""Two-step add: pick a shell (for blast radius) first, then click
the map to place it a Strike is just a Target with
TargetType.STRIKE and an explicit shell, and its position is set
by clicking, not typing in coordinates."""
dialog = Adw.Dialog(title="Add Strike", content_width=340, content_height=200)
toolbar_view = Adw.ToolbarView()
dialog.set_child(toolbar_view)
toolbar_view.add_top_bar(Adw.HeaderBar())
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16,
margin_top=16, margin_bottom=16, margin_start=16, margin_end=16)
group = Adw.PreferencesGroup(title="Shell (blast radius)")
shells = list(Shell)
shell_row = Adw.ComboRow(title="Shell", model=Gtk.StringList.new([s.name for s in shells]))
shell_row.set_selected(shells.index(Shell.HCHE))
group.add(shell_row)
outer.append(group)
next_btn = Gtk.Button(label="Next: click the map to place it")
next_btn.add_css_class("suggested-action")
next_btn.add_css_class("pill")
next_btn.set_halign(Gtk.Align.CENTER)
def on_next(_b):
chosen_shell = shells[shell_row.get_selected()]
dialog.close()
self.canvas.start_placement(
lambda coord: self._add_strike_at(coord, chosen_shell),
preview_radius_km=chosen_shell.blast_radius_km,
)
self.toast(f"Click the map to place the strike ({chosen_shell.name}) — Esc to cancel.")
next_btn.connect("clicked", on_next)
outer.append(next_btn)
toolbar_view.set_content(outer)
dialog.present(self)
def _add_strike_at(self, coord, shell: Shell) -> None:
target = self.board.add_target(TargetType.STRIKE, coord)
target.shell = shell
self.board.reorder_target(target, 0) # new strikes go to the front of the list
self._refresh()
def _start_target_placement(self, target) -> None:
"""Firing card's "set position on map" button: click-to-place/
reposition, previewing the target's blast radius if its shell has
a known one (mirrors the Add Strike flow, generalized to any
target)."""
self.canvas.start_placement(
lambda coord: self._apply_and_refresh(target, Location.from_coord(coord)),
preview_radius_km=target.effective_shell.blast_radius_km,
)
self.toast(f"Click the map to place {target.name} — Esc to cancel.")
def _on_map_right_click(self, coord, x: float, y: float) -> None:
"""Right-click anywhere on the map: quick-add a Target or Strike
right there, no dialog for when you already know exactly where
you're pointing and don't need to type coordinates."""
popover = Gtk.Popover()
popover.set_parent(self.canvas)
popover.set_pointing_to(Gdk.Rectangle(x=int(x), y=int(y), width=1, height=1))
popover.connect("closed", lambda _p: popover.unparent())
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
margin_top=6, margin_bottom=6, margin_start=6, margin_end=6)
def add_target(_b):
self.board.add_target(TargetType.UNKNOWN, coord)
self._refresh()
popover.popdown()
def add_strike(_b):
target = self.board.add_target(TargetType.STRIKE, coord)
self.board.reorder_target(target, 0) # new strikes go to the front of the list
self._refresh()
popover.popdown()
target_btn = Gtk.Button(label=f"Add target at {coord.label()}")
target_btn.add_css_class("flat")
target_btn.connect("clicked", add_target)
box.append(target_btn)
strike_btn = Gtk.Button(label=f"Add strike at {coord.label()}")
strike_btn.add_css_class("flat")
strike_btn.connect("clicked", add_strike)
box.append(strike_btn)
popover.set_child(box)
popover.popup()
def _remove_target(self, target, rebuild) -> None:
self.board.remove_target(target)
self._refresh()
rebuild()
def _remove_target_via_panel(self, target) -> None:
"""Same as _remove_target, but for the firing panel's own delete
button (Strikes) no popover `rebuild` callback to call there."""
self.board.remove_target(target)
self._refresh()
def _toggle_alive(self, target, rebuild) -> None:
target.alive = not target.alive
self._refresh()
rebuild()
# -- shared -------------------------------------------------------------
def _apply_and_refresh(self, obj, location: Location) -> None:
_apply_location(obj, location)
self._refresh()
def _edit_target_position(self, target) -> None:
"""Firing card's "Edit pos" button: manual coord overrides whatever
the solver had (definitive or ambiguous) once set it's sticky,
_merge_parsed_location won't touch it on a later re-screenshot.
Also lets you change id/type here, prefilled with the current
values blank id means "leave it as-is", not "auto-assign new"."""
self._open_coord_dialog(
title=f"Edit {target.name}",
on_submit=lambda loc, id_, type_: self._apply_target_edit(target, loc, id_, type_),
initial_location=target.location,
show_id=True,
show_type=True,
id_placeholder=f"ID (current: {target.id})",
initial_id=target.id,
initial_type=target.type,
)
def _apply_target_edit(self, target, location: Location, id_, type_) -> None:
if id_:
target.id = id_
if type_ is not None:
target.type = type_
self._apply_and_refresh(target, location)
def _toggle_hidden(self, obj, rebuild) -> None:
obj.hidden = not obj.hidden
self._refresh()
rebuild()
def _toggle_show_geo(self, obj, rebuild) -> None:
obj.show_geo_desc = not obj.show_geo_desc
self._refresh()
rebuild()
class IronNestApp(Adw.Application):
def __init__(self) -> None:
super().__init__(application_id=APP_ID)
def do_activate(self) -> None:
win = self.props.active_window
if win is None:
win = MainWindow(self)
win.present()
def main() -> int:
app = IronNestApp()
return app.run(None)
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,46 @@
"""Firing-solution math, from High Command's gunnery tables.
Distances/bearings reuse the same board-units-are-km convention as
solver.py. Powder charge controls elevation: more charge, lower arc for
the same distance, up to MAX_POWDER_CHARGE; min_powder_charge() is the
least charge that can still reach a given distance at all.
"""
from __future__ import annotations
import math
from .models import Coord
MAX_POWDER_CHARGE = 6
def distance_km_point(a: tuple[float, float], b: tuple[float, float]) -> float:
return math.hypot(b[0] - a[0], b[1] - a[1])
def bearing_deg_point(a: tuple[float, float], b: tuple[float, float]) -> float:
"""Compass bearing from a to b: 0 = north (+row), 90 = east (+col),
matching solver.py's convention — the inverse of
solver.point_from_bearing_distance()."""
return math.degrees(math.atan2(b[0] - a[0], b[1] - a[1])) % 360
def distance_km(a: Coord, b: Coord) -> float:
return distance_km_point(a.as_fraction(), b.as_fraction())
def bearing_deg(a: Coord, b: Coord) -> float:
return bearing_deg_point(a.as_fraction(), b.as_fraction())
def min_powder_charge(dist_km: float, eps: float = 1e-9) -> int:
n = dist_km / 5
rounded = round(n)
if abs(n - rounded) < eps:
return max(1, rounded)
return math.ceil(n)
def elevation_deg(dist_km: float, charges: int) -> float:
return 12 * dist_km / charges

View File

@ -0,0 +1,279 @@
"""Modal dialog for entering a Coord, or a free-text relative description.
Two ways to specify a location, matching the two tabs:
- "Exact" X/Y/x/y fields (+ id/type when adding a target).
- "Description" free-form text box, parsed with the same
Bearing/Distance clue grammar the OCR pipeline uses
(ocr.parse_clues_from_text). Prefilled with whatever
description is already stored, if any.
Either tab calls on_submit with a Location from_coord() for Exact,
from_desc() for Description so the caller applies just that half
without clobbering the other (a coord and a description can coexist).
"""
from __future__ import annotations
from typing import Callable
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
gi.require_version("Gdk", "4.0")
from gi.repository import Adw, Gdk, Gtk # noqa: E402
from . import ocr
from .models import LARGE_X, Coord, Location, TargetType
class CoordDialog(Adw.Dialog):
"""Emits a Location (and, if enabled, id/type) via on_submit."""
def __init__(
self,
*,
title: str,
on_submit: Callable[[Location, str | None, TargetType | None], None],
show_id: bool = False,
show_type: bool = False,
id_placeholder: str | None = None,
initial_location: Location | None = None,
initial_id: str | None = None,
initial_type: TargetType | None = None,
) -> None:
super().__init__(title=title, content_width=420, content_height=580)
self._on_submit = on_submit
self._show_id = show_id
self._show_type = show_type
self._id_placeholder = id_placeholder or "ID (blank = auto)"
self._initial_location = initial_location or Location()
self._initial_id = initial_id
self._initial_type = initial_type
toolbar_view = Adw.ToolbarView()
self.set_child(toolbar_view)
toolbar_view.add_top_bar(Adw.HeaderBar())
stack = Adw.ViewStack()
switcher = Adw.ViewSwitcherBar(stack=stack, reveal=True)
page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
page.append(stack)
page.append(switcher)
toolbar_view.set_content(page)
stack.add_titled_with_icon(
self._build_exact_tab(), "exact", "Exact", "input-keyboard-symbolic"
)
stack.add_titled_with_icon(
self._build_desc_tab(), "desc", "Description", "text-x-generic-symbolic"
)
# Quick keyboard entry: type e.g. "C433" (X digit×3) to fill and
# submit in one go. Y takes '0' to mean 10. Any letter A-T restarts
# the sequence, so a typo just means retyping the letter.
self._kb_stage = 0
key_controller = Gtk.EventControllerKey()
key_controller.connect("key-pressed", self._on_key_pressed)
self.add_controller(key_controller)
# Grab keyboard focus onto the dialog itself as soon as it's shown —
# otherwise no descendant is focused (we made the picker buttons
# non-focusable) so key events never reach our controller at all.
self.set_focusable(True)
self.connect("map", lambda *_a: self.grab_focus())
def _make_picker(self, labels, initial_index: int, on_select) -> tuple[Gtk.FlowBox, list]:
"""A wrapping row of toggle buttons acting as a radio group."""
flow = Gtk.FlowBox()
flow.set_selection_mode(Gtk.SelectionMode.NONE)
flow.set_homogeneous(True)
flow.set_row_spacing(4)
flow.set_column_spacing(4)
flow.set_max_children_per_line(10)
flow.set_min_children_per_line(5)
buttons = []
group_leader = None
for i, lbl in enumerate(labels):
btn = Gtk.ToggleButton(label=str(lbl))
btn.set_size_request(34, 34)
btn.set_focusable(False) # keep keyboard focus on the dialog, not the grid
if group_leader is None:
group_leader = btn
else:
btn.set_group(group_leader)
if i == initial_index:
btn.set_active(True)
def _on_toggled(b, i=i):
if b.get_active():
on_select(i)
btn.connect("toggled", _on_toggled)
flow.append(btn)
buttons.append(btn)
return flow, buttons
def _picker_group(self, title: str, picker: Gtk.FlowBox) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
heading = Gtk.Label(label=title, xalign=0)
heading.add_css_class("heading")
box.append(heading)
box.append(picker)
return box
def _build_exact_tab(self) -> Gtk.Widget:
outer = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
spacing=16,
margin_top=16,
margin_bottom=16,
margin_start=16,
margin_end=16,
)
initial = self._initial_location.coord
init_X_idx = LARGE_X.index(initial.X) if initial else 0
init_Y_idx = (initial.Y - 1) if initial else 0
init_x = initial.x if initial else 0
init_y = initial.y if initial else 0
self._X_idx = init_X_idx
self._Y_val = init_Y_idx + 1
self._x_val = init_x
self._y_val = init_y
X_flow, self._X_buttons = self._make_picker(
list(LARGE_X), init_X_idx, lambda i: setattr(self, "_X_idx", i)
)
Y_flow, self._Y_buttons = self._make_picker(
range(1, 11), init_Y_idx, lambda i: setattr(self, "_Y_val", i + 1)
)
x_flow, self._x_buttons = self._make_picker(
range(0, 10), init_x, lambda i: setattr(self, "_x_val", i)
)
y_flow, self._y_buttons = self._make_picker(
range(0, 10), init_y, lambda i: setattr(self, "_y_val", i)
)
outer.append(self._picker_group("X (AT)", X_flow))
outer.append(self._picker_group("Y (110)", Y_flow))
outer.append(self._picker_group("x (09)", x_flow))
outer.append(self._picker_group("y (09)", 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

View File

@ -0,0 +1,435 @@
"""Firing-commands sidebar: one card per target (or one per candidate
position, for an ambiguous target), shown alongside the map in an
Adw.OverlaySplitView (opening it narrows the map, doesn't overlay it).
Opened/closed from the main header's toggle button — no close control of
its own, so no header bar here, just the sort/filter toolbar.
Card layout:
Target#5 [edit] [L/R/—] [alive]
ELEV AZ
48.42° 31.2°
12.10km
AP [charge segments 1-6] 3
Elevation/azimuth are real (ballistics.py), computed from the Nest to
whichever coord the card represents. Shell defaults per target type
(Target.effective_shell AP for FDC/AmmoCache, HE otherwise) but is
editable per-target via the shell button, which also drives the map's
blast-radius overlay when this target is selected (grid_widget.py).
Cards are drag-reorderable `self.board.targets`' own list order is the
persisted order and doubles as the sort's tie-break (see refresh()).
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
from gi.repository import Gdk, GObject, Gtk # noqa: E402
from . import ballistics
from .models import Board, Target, TargetType
from .shells import Shell
_SELECTED_CSS = "firing-card-selected"
_HOVERED_CSS = "firing-card-hovered"
_SHOW_DEAD_STATES = ["hide", "show", "sort_later"]
_SHOW_DEAD_ICONS = {
"hide": "view-conceal-symbolic",
"show": "view-reveal-symbolic",
"sort_later": "view-list-symbolic",
}
_SHOW_DEAD_LABELS = {
"hide": "Dead targets: hidden",
"show": "Dead targets: shown",
"sort_later": "Dead targets: sorted last",
}
_ASSIGNMENT_STATES = ["unassigned", "left", "right"]
_ASSIGNMENT_LABELS = {"unassigned": "", "left": "L", "right": "R"}
_ASSIGNMENT_TOOLTIPS = {
"unassigned": "Unassigned (click to assign left gun)",
"left": "Assigned: left gun (click to assign right gun)",
"right": "Assigned: right gun (click to unassign)",
}
class FiringPanel(Gtk.Box):
"""Right-hand sidebar content: sort/filter toolbar + scrollable cards."""
def __init__(
self, board: Board, *, on_change, on_select, on_edit_position, on_set_position, on_remove,
on_toggle_hide_dead_map,
) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.board = board
self.on_change = on_change
self.on_select = on_select
self.on_edit_position = on_edit_position
self.on_set_position = on_set_position
self.on_remove = on_remove
self.on_toggle_hide_dead_map = on_toggle_hide_dead_map
self.selected: Target | None = None
self.selected_point = None
self.hovered: Target | None = None
self.hovered_point = None
self.show_dead = "sort_later" # "hide" | "show" | "sort_later"
self.hide_dead_from_map = False # off by default — the map's own filter, separate from sort/hide-in-list
# target -> [(card widget, point)] (usually 1; several for an ambiguous target)
self._cards_by_target: dict[Target, list[tuple[Gtk.Widget, object]]] = {}
self.append(self._build_toolbar())
self._list_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
self._list_box.set_margin_top(4)
self._list_box.set_margin_bottom(10)
self._list_box.set_margin_start(10)
self._list_box.set_margin_end(10)
scroller = Gtk.ScrolledWindow(child=self._list_box, vexpand=True)
self.append(scroller)
self.refresh()
# -- sort/filter toolbar -----------------------------------------------------
def _build_toolbar(self) -> Gtk.Widget:
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
row.set_margin_top(10)
row.set_margin_bottom(6)
row.set_margin_start(10)
row.set_margin_end(10)
self._show_dead_btn = Gtk.Button()
self._show_dead_btn.connect("clicked", self._on_show_dead_clicked)
row.append(self._show_dead_btn)
self._update_show_dead_button()
self._hide_dead_map_btn = Gtk.ToggleButton()
self._hide_dead_map_btn.connect("toggled", self._on_hide_dead_map_toggled)
row.append(self._hide_dead_map_btn)
self._update_hide_dead_map_button()
return row
def _update_show_dead_button(self) -> None:
self._show_dead_btn.set_icon_name(_SHOW_DEAD_ICONS[self.show_dead])
self._show_dead_btn.set_tooltip_text(f"{_SHOW_DEAD_LABELS[self.show_dead]} (click to cycle)")
def _on_show_dead_clicked(self, _btn) -> None:
idx = _SHOW_DEAD_STATES.index(self.show_dead)
self.show_dead = _SHOW_DEAD_STATES[(idx + 1) % len(_SHOW_DEAD_STATES)]
self._update_show_dead_button()
self.refresh()
def _update_hide_dead_map_button(self) -> None:
self._hide_dead_map_btn.set_icon_name(
"view-conceal-symbolic" if self.hide_dead_from_map else "view-reveal-symbolic"
)
label = "Dead targets hidden from map" if self.hide_dead_from_map else "Dead targets shown on map"
self._hide_dead_map_btn.set_tooltip_text(f"{label} (click to toggle; selecting one still shows it)")
def _on_hide_dead_map_toggled(self, btn) -> None:
self.hide_dead_from_map = btn.get_active()
self._update_hide_dead_map_button()
self.on_toggle_hide_dead_map(self.hide_dead_from_map)
# -- selection / hover highlight (lightweight — no rebuild) -------------------
def set_selected(self, target, point=None) -> None:
if target is self.selected and point == self.selected_point:
return
self._restyle(self.selected, self.selected_point, _SELECTED_CSS, False)
self.selected, self.selected_point = target, point
self._restyle(self.selected, self.selected_point, _SELECTED_CSS, True)
def set_hovered(self, target, point=None) -> None:
if target is self.hovered and point == self.hovered_point:
return
self._restyle(self.hovered, self.hovered_point, _HOVERED_CSS, False)
self.hovered, self.hovered_point = target, point
self._restyle(self.hovered, self.hovered_point, _HOVERED_CSS, True)
def _restyle(self, target, point, css_class: str, add: bool) -> None:
"""point=None means "the whole target" (every one of its cards);
otherwise only the card for that specific ambiguous candidate
without this, both candidate cards light up identically and you
can't tell which one was actually picked."""
for card, card_point in self._cards_by_target.get(target, []):
if point is None or card_point == point:
(card.add_css_class if add else card.remove_css_class)(css_class)
# -- rebuild --------------------------------------------------------------------
def refresh(self) -> None:
while (child := self._list_box.get_first_child()) is not None:
self._list_box.remove(child)
self._cards_by_target = {}
targets = list(self.board.targets)
if self.show_dead == "hide":
targets = [t for t in targets if t.alive]
def sort_key(t: Target):
dead_last = 1 if (self.show_dead == "sort_later" and not t.alive) else 0
strike_first = 0 if t.type is TargetType.STRIKE else 1
return (dead_last, strike_first)
# Stable sort: ties keep board order, which is exactly what drag
# reordering (Board.reorder_target) manipulates.
targets.sort(key=sort_key)
if not targets:
placeholder = Gtk.Label(label="No targets yet.", wrap=True)
placeholder.add_css_class("dim-label")
placeholder.set_margin_top(24)
self._list_box.append(placeholder)
return
prev_was_dead_group = False
for target in targets:
in_dead_group = self.show_dead == "sort_later" and not target.alive
if in_dead_group and not prev_was_dead_group:
self._list_box.append(Gtk.Separator(margin_top=4, margin_bottom=4))
prev_was_dead_group = in_dead_group
cards = self._build_cards(target) # list of (card, point)
self._cards_by_target[target] = cards
for card, point in cards:
if target is self.selected and (self.selected_point is None or point == self.selected_point):
card.add_css_class(_SELECTED_CSS)
if target is self.hovered and (self.hovered_point is None or point == self.hovered_point):
card.add_css_class(_HOVERED_CSS)
self._list_box.append(card)
def _build_cards(self, target: Target) -> list[tuple[Gtk.Widget, object]]:
if target.coord is not None:
return [(self._build_card(target, target.coord), target.coord)]
if target.location.potential_coords:
return [
(self._build_card(target, coord, ambiguous_index=i + 1), coord)
for i, coord in enumerate(target.location.potential_coords)
]
return [(self._build_unresolved_card(target), None)]
def _build_card_shell(self, target: Target, point=None) -> tuple[Gtk.Box, Gtk.Box]:
"""Card frame + top row (name, edit, assignment, alive) common to
every card kind. `point` is the specific coord this card represents
(None for the "position unknown" card) passed back on select so
the map can highlight/arrow exactly this candidate, not every one
of them."""
card = Gtk.Box()
card.add_css_class("card")
card.add_css_class("firing-card")
click = Gtk.GestureClick()
click.connect("released", lambda *_a: self.on_select(target, point))
card.add_controller(click)
self._add_drag_reorder(card, target)
inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
inner.set_margin_top(10)
inner.set_margin_bottom(10)
inner.set_margin_start(12)
inner.set_margin_end(12)
card.append(inner)
top_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
name_label = Gtk.Label(label=target.name, xalign=0, hexpand=True)
name_label.add_css_class("heading")
if not target.alive:
name_label.add_css_class("dim-label")
top_row.append(name_label)
if target.type is TargetType.STRIKE:
# A strike is placed by clicking the map (set_pos_btn below);
# typing in coordinates to "edit" one doesn't fit that — delete
# and re-place instead.
delete_btn = Gtk.Button(icon_name="user-trash-symbolic", tooltip_text="Delete strike")
delete_btn.add_css_class("flat")
delete_btn.connect("clicked", lambda _b: self.on_remove(target))
top_row.append(delete_btn)
else:
edit_btn = Gtk.Button(icon_name="document-edit-symbolic", tooltip_text="Edit position (type in coords)")
edit_btn.add_css_class("flat")
edit_btn.connect("clicked", lambda _b: self.on_edit_position(target))
top_row.append(edit_btn)
set_pos_btn = Gtk.Button(icon_name="find-location-symbolic", tooltip_text="Set position on map")
set_pos_btn.add_css_class("flat")
set_pos_btn.connect("clicked", lambda _b: self.on_set_position(target))
top_row.append(set_pos_btn)
assign_btn = Gtk.Button(label=_ASSIGNMENT_LABELS[target.assignment])
assign_btn.add_css_class("flat")
assign_btn.set_tooltip_text(_ASSIGNMENT_TOOLTIPS[target.assignment])
assign_btn.connect("clicked", lambda _b: self._cycle_assignment(target))
top_row.append(assign_btn)
alive_btn = Gtk.Button(
icon_name="object-select-symbolic" if target.alive else "action-unavailable-symbolic",
tooltip_text="Mark destroyed" if target.alive else "Mark alive",
)
alive_btn.add_css_class("flat")
alive_btn.connect("clicked", lambda _b: self._toggle_alive(target))
top_row.append(alive_btn)
inner.append(top_row)
if not target.alive:
card.set_opacity(0.55)
return card, inner
def _add_drag_reorder(self, card: Gtk.Widget, target: Target) -> None:
drag_source = Gtk.DragSource()
drag_source.set_actions(Gdk.DragAction.MOVE)
def on_prepare(_src, _x, _y, target=target):
return Gdk.ContentProvider.new_for_value(GObject.Value(GObject.TYPE_PYOBJECT, target))
drag_source.connect("prepare", on_prepare)
drag_source.connect("drag-begin", lambda *_a: card.add_css_class("firing-card-dragging"))
drag_source.connect("drag-end", lambda *_a: card.remove_css_class("firing-card-dragging"))
card.add_controller(drag_source)
drop_target = Gtk.DropTarget.new(GObject.TYPE_PYOBJECT, Gdk.DragAction.MOVE)
def on_drop(_dt, dragged, _x, _y, drop_onto=target):
if dragged is drop_onto:
return False
self._reorder(dragged, drop_onto)
return True
drop_target.connect("drop", on_drop)
card.add_controller(drop_target)
def _reorder(self, dragged: Target, drop_onto: Target) -> None:
targets = self.board.targets
if dragged not in targets or drop_onto not in targets:
return
self.board.reorder_target(dragged, targets.index(drop_onto))
self.on_change()
def _build_unresolved_card(self, target: Target) -> Gtk.Widget:
card, inner = self._build_card_shell(target)
note = Gtk.Label(label="Position unknown", xalign=0, wrap=True)
note.add_css_class("dim-label")
inner.append(note)
return card
def _build_card(self, target: Target, coord, ambiguous_index: int | None = None) -> Gtk.Widget:
card, inner = self._build_card_shell(target, coord)
if ambiguous_index is not None:
tag = Gtk.Label(label=f"AMBIGUOUS — candidate {ambiguous_index}", xalign=0)
tag.add_css_class("caption")
tag.add_css_class("warning")
inner.append(tag)
nest = self.board.nest
if nest.coord is None:
note = Gtk.Label(label="Nest position unknown", xalign=0, wrap=True)
note.add_css_class("dim-label")
inner.append(note)
return card
dist = ballistics.distance_km(nest.coord, coord)
az = ballistics.bearing_deg(nest.coord, coord)
min_charge = ballistics.min_powder_charge(dist)
charges = target.powder_charges or min_charge
charges = max(min_charge, min(charges, ballistics.MAX_POWDER_CHARGE))
readouts = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=28)
elev_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
elev_caption = Gtk.Label(label="ELEV", xalign=0)
elev_caption.add_css_class("caption")
elev_caption.add_css_class("dim-label")
elev_value = Gtk.Label(label=f"{ballistics.elevation_deg(dist, charges):.2f}°", xalign=0)
elev_value.add_css_class("title-3")
dist_label = Gtk.Label(label=f"{dist:.2f}km", xalign=0)
dist_label.add_css_class("caption")
dist_label.add_css_class("dim-label")
elev_box.append(elev_caption)
elev_box.append(elev_value)
elev_box.append(dist_label)
readouts.append(elev_box)
az_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
az_caption = Gtk.Label(label="AZ", xalign=0)
az_caption.add_css_class("caption")
az_caption.add_css_class("dim-label")
az_value = Gtk.Label(label=f"{az:.1f}°", xalign=0)
az_value.add_css_class("title-3")
az_box.append(az_caption)
az_box.append(az_value)
readouts.append(az_box)
inner.append(readouts)
inner.append(self._build_charge_row(target, dist, min_charge, charges, elev_value))
return card
def _build_charge_row(self, target: Target, dist_km: float, min_charge: int,
charges: int, elev_value_label: Gtk.Label) -> Gtk.Widget:
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
shell_btn = Gtk.MenuButton(label=target.effective_shell.name)
shell_btn.add_css_class("flat")
shell_btn.set_tooltip_text("Change shell (blast radius)")
popover = Gtk.Popover()
shell_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
margin_top=6, margin_bottom=6, margin_start=6, margin_end=6)
for s in Shell:
radius = f"{s.blast_radius_km}km" if s.blast_radius_km is not None else "unknown radius"
btn = Gtk.Button(label=f"{s.name}{s.description} ({radius})")
btn.add_css_class("flat")
btn.get_child().set_xalign(0)
btn.connect("clicked", lambda _b, s=s: self._pick_shell(target, s, popover))
shell_list.append(btn)
popover.set_child(shell_list)
shell_btn.set_popover(popover)
row.append(shell_btn)
segments: list[Gtk.Button] = []
count_label = Gtk.Label(label=str(charges))
count_label.set_margin_start(4)
def apply_fill(value: int) -> None:
for i, seg in enumerate(segments, start=1):
seg.remove_css_class("suggested-action")
seg.remove_css_class("flat")
seg.add_css_class("suggested-action" if i <= value else "flat")
count_label.set_label(str(value))
def on_pick(n: int) -> None:
target.powder_charges = n
apply_fill(n)
elev_value_label.set_label(f"{ballistics.elevation_deg(dist_km, n):.2f}°")
for n in range(1, ballistics.MAX_POWDER_CHARGE + 1):
seg = Gtk.Button(label=" ")
seg.set_size_request(14, 14)
seg.add_css_class("circular")
seg.set_sensitive(n >= min_charge)
seg.set_tooltip_text(f"{n} charge{'s' if n != 1 else ''}")
seg.connect("clicked", lambda _b, n=n: on_pick(n))
segments.append(seg)
row.append(seg)
apply_fill(charges)
row.append(count_label)
return row
def _cycle_assignment(self, target: Target) -> None:
idx = _ASSIGNMENT_STATES.index(target.assignment)
target.assignment = _ASSIGNMENT_STATES[(idx + 1) % len(_ASSIGNMENT_STATES)]
self.on_change()
def _toggle_alive(self, target: Target) -> None:
target.alive = not target.alive
self.on_change()
def _pick_shell(self, target: Target, shell: Shell, popover: Gtk.Popover) -> None:
target.shell = shell
popover.popdown()
self.on_change()

View File

@ -0,0 +1,533 @@
"""GridCanvas: draws the 20x10 map, every placed entity, ambiguous
solver candidates, and geo-description overlays (bearing/distance clues)
for whatever's hovered or pinned via show_geo_desc. Also handles
click-to-select and hover notification so the firing-commands panel can
stay in sync with the map (see app.py)."""
from __future__ import annotations
import math
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
from gi.repository import Gdk, Gtk # noqa: E402
from . import solver
from .models import LARGE_X, Board, Target
COLS, ROWS = 20, 10
MARGIN_LEFT = 34
MARGIN_TOP = 30
MARGIN_RIGHT = 50
MARGIN_BOTTOM = 30
LABEL_PAD = 8 # gap between a marker and its name label
HOVER_RADIUS_PX = 12
OVERLAY_RAY_LENGTH_KM = 30.0 # long enough to cross the 20x10 map from any origin
CATEGORY_COLOR = {
"nest": (0.35, 0.60, 0.95),
"spotter": (0.35, 0.78, 0.40),
"rp": (0.95, 0.78, 0.20),
"target": (0.92, 0.30, 0.28),
}
BG = (0.13, 0.12, 0.10)
GRID_LINE = (1.0, 1.0, 1.0, 0.20)
LABEL = (0.88, 0.86, 0.80)
COORD_LABEL = (0.60, 0.58, 0.54)
YELLOW = (0.95, 0.85, 0.20)
WHITE = (1.0, 1.0, 1.0)
FIRING_ARROW = (0.95, 0.15, 0.15)
SELECTION_RING = (1.0, 1.0, 1.0)
BLAST_RADIUS = (0.95, 0.40, 0.10)
PLACEMENT_PREVIEW = (0.95, 0.85, 0.20)
class GridCanvas(Gtk.DrawingArea):
def __init__(self, board: Board) -> None:
super().__init__()
self.board = board
self.hovered = None
self.hovered_point = None # which candidate, when obj has more than one point
self.selected = None
self.selected_point = None # which candidate, when obj has more than one point
self.on_select = None # callback(obj | None, point | None), fired on click
self.on_hover_change = None # callback(obj | None, point | None), fired on hover change
self.on_cursor_move = None # callback((col, row) km | None), fired on every motion/leave
self.on_right_click = None # callback(Coord, x, y), fired on right-click (unless placing)
self.hide_dead_from_map = False # off by default; toggled from the firing panel toolbar
# Placement mode: while armed, the next left-click calls
# placement_callback(Coord) instead of doing the normal
# select/hit-test, and (if placement_preview_radius_km is set) a
# circle of that radius follows the cursor as a preview.
self.placement_callback = None
self.placement_preview_radius_km = None
self._placement_cursor_km = None
self.set_hexpand(True)
self.set_vexpand(True)
self.set_draw_func(self._draw)
motion = Gtk.EventControllerMotion()
motion.connect("motion", self._on_motion)
motion.connect("leave", self._on_leave)
self.add_controller(motion)
click = Gtk.GestureClick()
click.connect("released", self._on_click)
self.add_controller(click)
right_click = Gtk.GestureClick()
right_click.set_button(Gdk.BUTTON_SECONDARY)
right_click.connect("released", self._on_right_click)
self.add_controller(right_click)
keys = Gtk.EventControllerKey()
keys.connect("key-pressed", self._on_key_pressed)
self.set_focusable(True)
self.add_controller(keys)
def refresh(self) -> None:
self.queue_draw()
# -- placement mode -----------------------------------------------------------
def start_placement(self, callback, preview_radius_km=None) -> None:
self.placement_callback = callback
self.placement_preview_radius_km = preview_radius_km
self.set_cursor_from_name("crosshair")
self.queue_draw()
def cancel_placement(self) -> None:
if self.placement_callback is None:
return
self.placement_callback = None
self.placement_preview_radius_km = None
self.set_cursor_from_name(None)
self.queue_draw()
def _on_key_pressed(self, _controller, keyval, _keycode, _state) -> bool:
if keyval == Gdk.KEY_Escape and self.placement_callback is not None:
self.cancel_placement()
return True
return False
def set_selected(self, obj, point=None) -> None:
if obj is not self.selected or point != self.selected_point:
self.selected = obj
self.selected_point = point
self.queue_draw()
# -- geometry -------------------------------------------------------------
def _cell_size(self, width: int, height: int) -> tuple[float, float]:
grid_w = max(width - MARGIN_LEFT - MARGIN_RIGHT, 1)
grid_h = max(height - MARGIN_TOP - MARGIN_BOTTOM, 1)
return grid_w / COLS, grid_h / ROWS
def _km_to_px(self, point_km, cell_w, cell_h, grid_h) -> tuple[float, float]:
col, row = point_km
return MARGIN_LEFT + col * cell_w, MARGIN_TOP + grid_h - row * cell_h
def _px_to_km(self, x, y, cell_w, cell_h, grid_h) -> tuple[float, float]:
return (x - MARGIN_LEFT) / cell_w, (grid_h - (y - MARGIN_TOP)) / cell_h
def _excluded_from_map(self, obj) -> bool:
"""True if `obj` should be dropped from the map view entirely —
it's hidden, or it's a dead target with the map's dead-hiding
toggle on unless it's the current selection, in which case it's
still drawn (darkened) so it stays reachable/un-hideable."""
if obj is self.selected:
return False
if obj.hidden:
return True
if self.hide_dead_from_map and isinstance(obj, Target) and not obj.alive:
return True
return False
def _all_positions(self):
"""Yield (obj, coord) for every point drawn on the map, including
each ambiguous candidate separately (hover/click targets each of
them individually, but they all resolve to the same obj). Hidden
entities, and (if toggled) dead targets, are excluded from the map
entirely unless they're the current selection (so they can still
be un-hidden/interacted with once selected some other way, e.g.
from the firing panel)."""
for _category, obj in self.board.placed_entities_all():
if self._excluded_from_map(obj):
continue
yield obj, obj.coord
for _category, obj in self.board.ambiguous_entities_all():
if self._excluded_from_map(obj):
continue
for candidate in obj.location.potential_coords:
yield obj, candidate
def _hit_test(self, x: float, y: float):
"""Returns (obj, coord) of the nearest marker within range, or
(None, None) coord disambiguates which candidate of an
ambiguous obj was actually hit, since it can have several points."""
cell_w, cell_h = self._cell_size(self.get_width(), self.get_height())
grid_h = cell_h * ROWS
best_obj, best_coord, best_dist = None, None, HOVER_RADIUS_PX
for obj, coord in self._all_positions():
px, py = self._km_to_px(coord.as_fraction(), cell_w, cell_h, grid_h)
dist = math.hypot(px - x, py - y)
if dist < best_dist:
best_dist, best_obj, best_coord = dist, obj, coord
return best_obj, best_coord
# -- hover / click ------------------------------------------------------------
def _on_motion(self, _controller, x: float, y: float) -> None:
cell_w, cell_h = self._cell_size(self.get_width(), self.get_height())
grid_h = cell_h * ROWS
cursor_km = self._px_to_km(x, y, cell_w, cell_h, grid_h)
if self.on_cursor_move is not None:
self.on_cursor_move(cursor_km)
if self.placement_callback is not None:
self._placement_cursor_km = cursor_km
self.queue_draw()
return # no hover/select while placing — the map's just a target picker right now
hit, coord = self._hit_test(x, y)
if hit is not self.hovered or coord != self.hovered_point:
self.hovered = hit
self.hovered_point = coord
self.queue_draw()
if self.on_hover_change is not None:
self.on_hover_change(hit, coord)
def _on_leave(self, _controller) -> None:
if self.on_cursor_move is not None:
self.on_cursor_move(None)
if self.placement_callback is not None:
self._placement_cursor_km = None
self.queue_draw()
if self.hovered is not None:
self.hovered = None
self.hovered_point = None
self.queue_draw()
if self.on_hover_change is not None:
self.on_hover_change(None, None)
def _on_click(self, _gesture, _n_press, x: float, y: float) -> None:
if self.placement_callback is not None:
cell_w, cell_h = self._cell_size(self.get_width(), self.get_height())
grid_h = cell_h * ROWS
coord = solver.point_to_coord(self._px_to_km(x, y, cell_w, cell_h, grid_h))
callback = self.placement_callback
self.cancel_placement()
if coord is not None:
callback(coord)
return
hit, coord = self._hit_test(x, y)
self.set_selected(hit, coord)
if self.on_select is not None:
self.on_select(hit, coord)
def _on_right_click(self, _gesture, _n_press, x: float, y: float) -> None:
if self.placement_callback is not None:
self.cancel_placement()
return
if self.on_right_click is None:
return
cell_w, cell_h = self._cell_size(self.get_width(), self.get_height())
grid_h = cell_h * ROWS
coord = solver.point_to_coord(self._px_to_km(x, y, cell_w, cell_h, grid_h))
if coord is not None:
self.on_right_click(coord, x, y)
# -- drawing ----------------------------------------------------------------
def _draw(self, _area, cr, width, height) -> None:
cr.set_source_rgb(*BG)
cr.paint()
cell_w, cell_h = self._cell_size(width, height)
grid_w, grid_h = cell_w * COLS, cell_h * ROWS
cr.set_source_rgba(*GRID_LINE)
cr.set_line_width(1)
for c in range(COLS + 1):
x = MARGIN_LEFT + c * cell_w
cr.move_to(x, MARGIN_TOP)
cr.line_to(x, MARGIN_TOP + grid_h)
for r in range(ROWS + 1):
y = MARGIN_TOP + grid_h - r * cell_h
cr.move_to(MARGIN_LEFT, y)
cr.line_to(MARGIN_LEFT + grid_w, y)
cr.stroke()
cr.set_source_rgb(*LABEL)
cr.set_font_size(11)
for i, letter in enumerate(LARGE_X):
x = MARGIN_LEFT + i * cell_w + cell_w / 2 - 4
cr.move_to(x, MARGIN_TOP - 10)
cr.show_text(letter)
for r in range(ROWS):
y = MARGIN_TOP + grid_h - r * cell_h - cell_h / 2 + 4
cr.move_to(4, y)
cr.show_text(str(r + 1))
self._draw_geo_overlays(cr, cell_w, cell_h, grid_h)
self._draw_firing_arrows(cr, cell_w, cell_h, grid_h)
self._draw_blast_radius(cr, cell_w, cell_h, grid_h)
self._draw_placement_preview(cr, cell_w, cell_h, grid_h)
for category, obj in self.board.placed_entities_all():
if self._excluded_from_map(obj):
continue # hidden/dead-and-toggled-off entities are removed, not just darkened
self._draw_marker(cr, obj.coord.as_fraction(), CATEGORY_COLOR[category],
obj.name, cell_w, cell_h, grid_h, width, height,
dim=(category == "target" and not obj.alive) or obj.hidden,
selected=(obj is self.selected), coord=obj.coord)
for category, obj in self.board.ambiguous_entities_all():
if self._excluded_from_map(obj):
continue
color = CATEGORY_COLOR[category]
for i, candidate in enumerate(obj.location.potential_coords):
is_selected = obj is self.selected and candidate == self.selected_point
self._draw_marker(cr, candidate.as_fraction(), color,
f"{obj.name}? ({i + 1})", cell_w, cell_h, grid_h, width, height,
hollow=True, dim=obj.hidden or (category == "target" and not obj.alive),
selected=is_selected, coord=candidate)
def _draw_marker(self, cr, point_km, color, label, cell_w, cell_h, grid_h,
canvas_width, canvas_height, *, hollow=False, dim=False,
selected=False, coord=None) -> None:
x, y = self._km_to_px(point_km, cell_w, cell_h, grid_h)
r, g, b = color
alpha = 0.45 if dim else 1.0
if selected:
cr.new_path()
cr.set_source_rgba(*SELECTION_RING, 0.9)
cr.set_line_width(2)
cr.arc(x, y, 9, 0, 2 * math.pi)
cr.stroke()
if hollow:
cr.new_path() # cairo's arc() draws a line from any stale current
cr.set_source_rgba(r, g, b, alpha) # point (e.g. the last label's
cr.set_line_width(1.5) # show_text position) to the arc's start —
cr.set_dash([3, 2]) # this is what stops that connector line.
cr.arc(x, y, 5.5, 0, 2 * math.pi)
cr.stroke()
cr.set_dash([])
else:
cr.new_path()
cr.set_source_rgba(r, g, b, alpha)
cr.arc(x, y, 5.5, 0, 2 * math.pi)
cr.fill()
cr.new_path()
cr.set_source_rgba(0, 0, 0, 0.6 * alpha)
cr.arc(x, y, 5.5, 0, 2 * math.pi)
cr.set_line_width(1)
cr.stroke()
cr.set_font_size(11)
text_width = cr.text_extents(label).width
coord_text = coord.label() if coord is not None else None
coord_width = cr.text_extents(coord_text).width if coord_text else 0
# Flip to the left of the marker if the label would run past the
# right edge; clamp vertically so it doesn't clip top/bottom either.
label_x = x + LABEL_PAD
if label_x + max(text_width, coord_width) > canvas_width - 4:
label_x = x - LABEL_PAD - max(text_width, coord_width)
label_y = max(10, min(y - 7, canvas_height - 20))
cr.set_source_rgba(*LABEL, alpha)
cr.move_to(label_x, label_y)
cr.show_text(label)
if coord_text:
cr.set_font_size(9)
cr.set_source_rgba(*COORD_LABEL, alpha)
cr.move_to(label_x, label_y + 12)
cr.show_text(coord_text)
cr.set_font_size(11)
def _draw_firing_arrows(self, cr, cell_w, cell_h, grid_h) -> None:
"""Red arrow(s) Nest -> Target, for whatever's hovered or selected.
Points at exactly the hovered/selected candidate when one is known
(mouse over/click on a specific ambiguous marker) rather than every
candidate of that target same reasoning as the selection ring:
drawing to all of them makes it impossible to tell which is which."""
nest = self.board.nest
if nest.coord is None:
return
nest_km = nest.coord.as_fraction()
points: list = []
for target, point in ((self.hovered, self.hovered_point), (self.selected, self.selected_point)):
if not isinstance(target, Target):
continue
if target.coord is not None:
points.append(target.coord)
elif point is not None:
points.append(point)
else:
points.extend(target.location.potential_coords)
seen = set()
for candidate in points:
if candidate in seen:
continue
seen.add(candidate)
tx, ty = self._km_to_px(candidate.as_fraction(), cell_w, cell_h, grid_h)
nx, ny = self._km_to_px(nest_km, cell_w, cell_h, grid_h)
cr.set_source_rgb(*FIRING_ARROW)
cr.set_line_width(2)
self._draw_arrow(cr, nx, ny, tx, ty)
def _draw_blast_radius(self, cr, cell_w, cell_h, grid_h) -> None:
"""When a Target is selected, its effective shell's blast radius —
selection only, not hover (unlike the geo overlays/firing arrow),
per spec. Uses the specific selected candidate point if the target
is ambiguous; skipped entirely if there's no known point yet, or
the shell's blast radius isn't known."""
if not isinstance(self.selected, Target):
return
target = self.selected
point = target.coord if target.coord is not None else self.selected_point
if point is None:
return
radius_km = target.effective_shell.blast_radius_km
if radius_km is None:
return
x, y = self._km_to_px(point.as_fraction(), cell_w, cell_h, grid_h)
rx, ry = cell_w * radius_km, cell_h * radius_km
self._draw_ellipse(cr, x, y, rx, ry)
cr.set_source_rgba(*BLAST_RADIUS, 0.18)
cr.fill_preserve()
cr.set_source_rgba(*BLAST_RADIUS, 0.85)
cr.set_line_width(2)
cr.stroke()
def _draw_placement_preview(self, cr, cell_w, cell_h, grid_h) -> None:
"""While armed to place/reposition something, a small crosshair dot
follows the cursor, plus a blast-radius preview circle if one was
given (e.g. placing a Strike see its shell before you commit)."""
if self.placement_callback is None or self._placement_cursor_km is None:
return
x, y = self._km_to_px(self._placement_cursor_km, cell_w, cell_h, grid_h)
if self.placement_preview_radius_km is not None:
rx, ry = cell_w * self.placement_preview_radius_km, cell_h * self.placement_preview_radius_km
self._draw_ellipse(cr, x, y, rx, ry)
cr.set_source_rgba(*PLACEMENT_PREVIEW, 0.15)
cr.fill_preserve()
cr.set_source_rgba(*PLACEMENT_PREVIEW, 0.8)
cr.set_line_width(1.5)
cr.set_dash([3, 2])
cr.stroke()
cr.set_dash([])
cr.new_path()
cr.set_source_rgba(*PLACEMENT_PREVIEW, 0.9)
cr.set_line_width(1.5)
cr.move_to(x - 7, y)
cr.line_to(x + 7, y)
cr.move_to(x, y - 7)
cr.line_to(x, y + 7)
cr.stroke()
def _draw_geo_overlays(self, cr, cell_w, cell_h, grid_h) -> None:
to_show = []
for category, obj in self.board.placed_entities():
if obj is self.hovered or obj.show_geo_desc:
to_show.append(obj)
for category, obj in self.board.ambiguous_entities():
if obj is self.hovered or obj.show_geo_desc:
to_show.append(obj)
for obj in to_show:
for clue in obj.location.clues:
ref = self.board.find_by_name(clue.reference)
if ref is None or ref.coord is None:
continue
ref_km = ref.coord.as_fraction()
rx, ry = self._km_to_px(ref_km, cell_w, cell_h, grid_h)
if clue.bearing_deg is not None and clue.distance_km is not None:
target_km = solver.point_from_bearing_distance(ref_km, clue.bearing_deg, clue.distance_km)
tx, ty = self._km_to_px(target_km, cell_w, cell_h, grid_h)
cr.set_source_rgb(*YELLOW)
cr.set_line_width(2)
self._draw_arrow(cr, rx, ry, tx, ty)
elif clue.bearing_deg is not None:
far_km = solver.point_from_bearing_distance(ref_km, clue.bearing_deg, OVERLAY_RAY_LENGTH_KM)
fx, fy = self._km_to_px(far_km, cell_w, cell_h, grid_h)
cr.set_source_rgb(*YELLOW)
cr.set_line_width(1.5)
cr.move_to(rx, ry)
cr.line_to(fx, fy)
cr.stroke()
elif clue.distance_km is not None:
radius_x, radius_y = cell_w * clue.distance_km, cell_h * clue.distance_km
self._draw_ellipse(cr, rx, ry, radius_x, radius_y)
cr.set_source_rgba(*WHITE, 0.85)
cr.set_line_width(1.5)
cr.stroke()
# Radius indicator: a line from center to an actual point
# on the circle — the intersection with another of this
# entity's clues if one pairs with it (same geometry the
# solver would use; picks the nearer of two candidates),
# else straight up as a last-resort fallback with nothing
# to intersect against yet.
radius_target_km = None
for other in obj.location.clues:
if other is clue or other.bearing_deg is None or other.distance_km is not None:
continue
other_ref = self.board.find_by_name(other.reference)
if other_ref is None or other_ref.coord is None:
continue
points = solver.ray_circle_intersections(
other_ref.coord.as_fraction(), other.bearing_deg, ref_km, clue.distance_km
)
if points:
radius_target_km = points[0]
break
if radius_target_km is None:
radius_target_km = (ref_km[0], ref_km[1] + clue.distance_km)
tx, ty = self._km_to_px(radius_target_km, cell_w, cell_h, grid_h)
cr.new_path()
cr.set_source_rgba(*WHITE, 0.85)
cr.set_line_width(1)
cr.set_dash([1.5, 2.5])
cr.move_to(rx, ry)
cr.line_to(tx, ty)
cr.stroke()
cr.set_dash([])
def _draw_arrow(self, cr, x0, y0, x1, y1, head_size=8) -> None:
cr.new_path()
cr.move_to(x0, y0)
cr.line_to(x1, y1)
cr.stroke()
angle = math.atan2(y1 - y0, x1 - x0)
cr.new_path()
for delta in (math.pi * 5 / 6, -math.pi * 5 / 6):
cr.move_to(x1, y1)
cr.line_to(x1 + head_size * math.cos(angle + delta), y1 + head_size * math.sin(angle + delta))
cr.stroke()
def _draw_ellipse(self, cr, cx, cy, rx, ry, steps=72) -> None:
# Points computed explicitly (not via cr.scale) so the stroke width
# stays uniform regardless of rx/ry — a scaled CTM would stretch it.
cr.new_path()
cr.move_to(cx + rx, cy)
for i in range(1, steps + 1):
theta = 2 * math.pi * i / steps
cr.line_to(cx + rx * math.cos(theta), cy + ry * math.sin(theta))

View File

@ -0,0 +1,526 @@
"""Board data model: coordinates and the entities placed on the map.
Coordinate system (matches the in-game map):
Large grid: X in A..T (20 cols), Y in 1..10 (10 rows), row 1 at the bottom.
Sub-grid within a cell: x, y in 0..9.
Not everything is known as an absolute grid coordinate. The typewriter
also hands out *relative* fixes "Bearing 293 from Alpha", "Distance
13.59km from Spotter#1" — that only resolve to a Coord once whatever
they're relative to is itself known, and that can chain (AmmoCache#3 is
relative to AmmoCache#2, which is itself relative to Alpha and Spotter#1).
A Location captures both cases: either a resolved Coord, or a raw
description plus the Clues parsed out of it each Clue naming another
entity, so the Clues across the board form a dependency graph a future
solver walks (topologically, anchored at entities with a resolved Coord)
to work out everything else. No solver yet this just defines the shape.
"""
from __future__ import annotations
import string
from dataclasses import dataclass, field
from enum import Enum
from .shells import Shell
LARGE_X = string.ascii_uppercase[:20] # A..T
LARGE_Y = range(1, 11) # 1..10
NATO_ALPHABET = [
"Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot", "Golf", "Hotel",
"India", "Juliett", "Kilo", "Lima", "Mike", "November", "Oscar", "Papa",
"Quebec", "Romeo", "Sierra", "Tango", "Uniform", "Victor", "Whiskey",
"X-ray", "Yankee", "Zulu",
]
class TargetType(Enum):
SUPPLY_CACHE = "Supply Cache"
UNKNOWN = "Target" # generic contact, spotted but not yet identified
FDC = "FDC" # Fire Direction Center — coordinates enemy counter-battery fire
INFANTRY = "Infantry" # hostile ground troops
MECHANIZED = "Mechanized" # hostile armored/vehicle unit
HOSTILE_ARTILLERY = "Hostile Artillery"
HOSTILE_TANK = "Hostile Tank"
STRIKE = "Strike" # a planned impact point, not an enemy contact
@property
def short(self) -> str:
"""Compact form used in item names, e.g. 'SupplyCache' / 'FDC'."""
return self.value.replace(" ", "")
# Renamed/removed enum members, for loading save files written before the
# rename — AMMO_CACHE turned out to be a misreading of the game's actual
# "SupplyCache" name and was dropped in favor of it.
_TARGET_TYPE_MIGRATIONS = {"AMMO_CACHE": "SUPPLY_CACHE"}
def _migrate_target_type(name: str) -> TargetType:
return TargetType[_TARGET_TYPE_MIGRATIONS.get(name, name)]
@dataclass(frozen=True)
class Coord:
X: str # 'A'..'T'
Y: int # 1..10
x: int # 0..9
y: int # 0..9
def __post_init__(self) -> None:
if self.X not in LARGE_X:
raise ValueError(f"X must be one of A..T, got {self.X!r}")
if self.Y not in LARGE_Y:
raise ValueError(f"Y must be 1..10, got {self.Y!r}")
if not (0 <= self.x <= 9):
raise ValueError(f"x must be 0..9, got {self.x!r}")
if not (0 <= self.y <= 9):
raise ValueError(f"y must be 0..9, got {self.y!r}")
def as_fraction(self) -> tuple[float, float]:
"""Position in board units: col in [0,20], row in [0,10], sub-cell centered."""
col = LARGE_X.index(self.X) + (self.x + 0.5) / 10
row = (self.Y - 1) + (self.y + 0.5) / 10
return col, row
def label(self) -> str:
return f"{self.X}{self.Y} {self.x}:{self.y}"
def to_dict(self) -> dict:
return {"X": self.X, "Y": self.Y, "x": self.x, "y": self.y}
@classmethod
def from_dict(cls, d: dict) -> "Coord":
return cls(X=d["X"], Y=d["Y"], x=d["x"], y=d["y"])
def _coord_to_dict(coord: Coord | None) -> dict | None:
return coord.to_dict() if coord is not None else None
def _coord_from_dict(d: dict | None) -> Coord | None:
return Coord.from_dict(d) if d is not None else None
@dataclass(frozen=True)
class Clue:
"""One relative-position reading: bearing and/or distance from another
named entity (e.g. 'Spotter#1', 'Alpha', 'AmmoCache#2'). At least one
of bearing/distance is set; a single clue with both fully determines a
position given the reference, two clues (from different references)
need triangulating."""
reference: str
bearing_deg: float | None = None
distance_km: float | None = None
def to_dict(self) -> dict:
return {
"reference": self.reference,
"bearing_deg": self.bearing_deg,
"distance_km": self.distance_km,
}
@classmethod
def from_dict(cls, d: dict) -> "Clue":
return cls(
reference=d["reference"],
bearing_deg=d.get("bearing_deg"),
distance_km=d.get("distance_km"),
)
@dataclass
class Location:
"""Where something is. `coord` is the resolved absolute position —
known directly for entities given in grid form (Nest, Spotters), or
filled in later by a solver once every clue's reference is resolved.
`desc_raw` + `clues` hold a relative description before/instead of
that resolution. `potential_coords` holds a solver result that was
genuinely ambiguous (e.g. a bearing ray crossing a distance circle
twice) shown on the map as candidates, never treated as resolved
and never used to resolve anything else."""
coord: Coord | None = None
desc_raw: str | None = None
clues: list[Clue] = field(default_factory=list)
potential_coords: list[Coord] = field(default_factory=list)
@property
def is_resolved(self) -> bool:
return self.coord is not None
@property
def depends_on(self) -> list[str]:
"""Names of other entities this location's clues are relative to."""
return [c.reference for c in self.clues]
@classmethod
def from_coord(cls, coord: Coord | None) -> "Location":
return cls(coord=coord)
@classmethod
def from_desc(cls, raw: str, clues: list[Clue] | None = None) -> "Location":
return cls(desc_raw=raw, clues=list(clues) if clues else [])
def to_dict(self) -> dict:
return {
"coord": _coord_to_dict(self.coord),
"desc_raw": self.desc_raw,
"clues": [c.to_dict() for c in self.clues],
"potential_coords": [c.to_dict() for c in self.potential_coords],
}
@classmethod
def from_dict(cls, d: dict | None) -> "Location":
if not d:
return cls()
return cls(
coord=_coord_from_dict(d.get("coord")),
desc_raw=d.get("desc_raw"),
clues=[Clue.from_dict(c) for c in d.get("clues", [])],
potential_coords=[Coord.from_dict(c) for c in d.get("potential_coords", [])],
)
def _as_location(value: "Location | Coord | None") -> Location:
return value if isinstance(value, Location) else Location.from_coord(value)
@dataclass(eq=False) # identity equality/hash — these are mutable, used as dict keys/set members
class Nest:
location: Location = field(default_factory=Location)
name: str = "Nest"
hidden: bool = False
show_geo_desc: bool = False
@property
def coord(self) -> Coord | None:
return self.location.coord
@coord.setter
def coord(self, value: Coord | None) -> None:
# Set the resolved position without clobbering any desc_raw/clues
# already stored on this Location (provenance: how it got there).
# potential_coords is left alone too, not cleared — a coord takes
# priority over it everywhere it matters (placed_entities() /
# ambiguous_entities() / firing panel cards all check coord first),
# so it just goes inert rather than being deleted.
self.location.coord = value
@dataclass(eq=False) # identity equality/hash — these are mutable, used as dict keys/set members
class Spotter:
id: int
location: Location = field(default_factory=Location)
hidden: bool = False
show_geo_desc: bool = False
@property
def name(self) -> str:
return f"Spotter#{self.id}"
@property
def coord(self) -> Coord | None:
return self.location.coord
@coord.setter
def coord(self, value: Coord | None) -> None:
# Set the resolved position without clobbering any desc_raw/clues
# already stored on this Location (provenance: how it got there).
# potential_coords is left alone too, not cleared — a coord takes
# priority over it everywhere it matters (placed_entities() /
# ambiguous_entities() / firing panel cards all check coord first),
# so it just goes inert rather than being deleted.
self.location.coord = value
@dataclass(eq=False) # identity equality/hash — these are mutable, used as dict keys/set members
class ReferencePoint:
rp_name: str
location: Location = field(default_factory=Location)
hidden: bool = False
show_geo_desc: bool = False
@property
def name(self) -> str:
return self.rp_name
@property
def coord(self) -> Coord | None:
return self.location.coord
@coord.setter
def coord(self, value: Coord | None) -> None:
# Set the resolved position without clobbering any desc_raw/clues
# already stored on this Location (provenance: how it got there).
# potential_coords is left alone too, not cleared — a coord takes
# priority over it everywhere it matters (placed_entities() /
# ambiguous_entities() / firing panel cards all check coord first),
# so it just goes inert rather than being deleted.
self.location.coord = value
@dataclass(eq=False) # identity equality/hash — these are mutable, used as dict keys/set members
class Target:
type: TargetType
id: str
location: Location = field(default_factory=Location)
hidden: bool = False
show_geo_desc: bool = False
alive: bool = True
# None = use the computed minimum for the current distance; only set
# once the user picks a value explicitly (see firing_panel.py).
powder_charges: int | None = None
# None = use effective_shell's type-based default; only set once the
# user picks one explicitly.
shell: Shell | None = None
# Which gun this target is assigned to, if any — "unassigned" | "left" | "right".
assignment: str = "unassigned"
@property
def name(self) -> str:
return f"{self.type.short}#{self.id}"
_AP_DEFAULT_TYPES = (TargetType.FDC, TargetType.SUPPLY_CACHE)
@property
def effective_shell(self) -> Shell:
if self.shell is not None:
return self.shell
if self.type in self._AP_DEFAULT_TYPES:
return Shell.AP
if self.type is TargetType.STRIKE:
return Shell.HCHE
return Shell.HE
@property
def coord(self) -> Coord | None:
return self.location.coord
@coord.setter
def coord(self, value: Coord | None) -> None:
# Set the resolved position without clobbering any desc_raw/clues
# already stored on this Location (provenance: how it got there).
# potential_coords is left alone too, not cleared — a coord takes
# priority over it everywhere it matters (placed_entities() /
# ambiguous_entities() / firing panel cards all check coord first),
# so it just goes inert rather than being deleted.
self.location.coord = value
SAVE_FORMAT_VERSION = 2
class Board:
"""Holds everything placed on the map and the naming rules for new items."""
def __init__(self) -> None:
self.nest = Nest()
self.spotters: list[Spotter] = []
self.reference_points: list[ReferencePoint] = []
self.targets: list[Target] = []
self._spotter_seq = 0
# -- lookup by name, for resolving clue references ----------------------
def find_by_name(self, name: str):
if self.nest.name == name:
return self.nest
for sp in self.spotters:
if sp.name == name:
return sp
for rp in self.reference_points:
if rp.name == name:
return rp
for t in self.targets:
if t.name == name:
return t
return None
# -- spotters --------------------------------------------------------
def next_spotter_id(self) -> int:
return self._spotter_seq + 1
def add_spotter(self, location: Location | Coord | None = None, id_: int | None = None) -> Spotter:
if id_ is None:
id_ = self.next_spotter_id()
self._spotter_seq = max(self._spotter_seq, id_)
sp = Spotter(id=id_, location=_as_location(location))
self.spotters.append(sp)
return sp
def remove_spotter(self, spotter: Spotter) -> None:
self.spotters.remove(spotter)
# -- reference points -------------------------------------------------
def add_reference_point(
self, location: Location | Coord | None = None, name: str | None = None
) -> ReferencePoint:
if name is None:
used = {rp.rp_name for rp in self.reference_points}
name = next(n for n in NATO_ALPHABET if n not in used)
rp = ReferencePoint(rp_name=name, location=_as_location(location))
self.reference_points.append(rp)
return rp
def remove_reference_point(self, rp: ReferencePoint) -> None:
self.reference_points.remove(rp)
# -- targets ------------------------------------------------------------
def add_target(
self,
type_: TargetType,
location: Location | Coord | None = None,
id_: str | None = None,
) -> Target:
if not id_:
used = {t.id for t in self.targets if t.type == type_}
id_ = next(c for c in string.ascii_uppercase if c not in used)
t = Target(type=type_, id=id_, location=_as_location(location))
self.targets.append(t)
return t
def remove_target(self, target: Target) -> None:
self.targets.remove(target)
def reorder_target(self, target: Target, new_index: int) -> None:
"""Manual drag-order: `self.targets`' list order is itself the
persisted order (saved/loaded as a plain JSON array), and is what
the firing panel's sort falls back to as a tie-break once its
other sort options are applied."""
self.targets.remove(target)
new_index = max(0, min(new_index, len(self.targets)))
self.targets.insert(new_index, target)
# -- drawing helper ----------------------------------------------------
def placed_entities(self):
"""Yield (category, obj) for everything with a *resolved* coord that
isn't hidden. Entities that only have a relative description (or
only ambiguous potential_coords) aren't drawable this way — see
ambiguous_entities(). See placed_entities_all()/ambiguous_entities_all()
for the unfiltered versions (needed so the map can still show a
hidden entity, darkened, while it's selected)."""
for category, obj in self.placed_entities_all():
if not obj.hidden:
yield category, obj
def placed_entities_all(self):
"""Like placed_entities(), but includes hidden entities too."""
if self.nest.coord is not None:
yield "nest", self.nest
for sp in self.spotters:
if sp.coord is not None:
yield "spotter", sp
for rp in self.reference_points:
if rp.coord is not None:
yield "rp", rp
for t in self.targets:
if t.coord is not None:
yield "target", t
def ambiguous_entities(self):
"""Yield (category, obj) for RPs/Targets that resolved to two or
more equally-valid potential_coords instead of one definitive
coord."""
for category, obj in self.ambiguous_entities_all():
if not obj.hidden:
yield category, obj
def ambiguous_entities_all(self):
"""Like ambiguous_entities(), but includes hidden entities too."""
for rp in self.reference_points:
if rp.coord is None and rp.location.potential_coords:
yield "rp", rp
for t in self.targets:
if t.coord is None and t.location.potential_coords:
yield "target", t
# -- save / load ---------------------------------------------------------
def to_dict(self) -> dict:
return {
"version": SAVE_FORMAT_VERSION,
"nest": {
"location": self.nest.location.to_dict(),
"hidden": self.nest.hidden,
"show_geo_desc": self.nest.show_geo_desc,
},
"spotters": [
{
"id": sp.id,
"location": sp.location.to_dict(),
"hidden": sp.hidden,
"show_geo_desc": sp.show_geo_desc,
}
for sp in self.spotters
],
"reference_points": [
{
"name": rp.rp_name,
"location": rp.location.to_dict(),
"hidden": rp.hidden,
"show_geo_desc": rp.show_geo_desc,
}
for rp in self.reference_points
],
"targets": [
{
"type": t.type.name,
"id": t.id,
"location": t.location.to_dict(),
"hidden": t.hidden,
"show_geo_desc": t.show_geo_desc,
"alive": t.alive,
"powder_charges": t.powder_charges,
"shell": t.shell.name if t.shell is not None else None,
"assignment": t.assignment,
}
for t in self.targets
],
}
def load_from_dict(self, data: dict) -> None:
"""Replace all current state with what's in `data` (in place, so
anything holding a reference to this Board keeps working)."""
nest_data = data.get("nest", {})
self.nest = Nest(
location=Location.from_dict(nest_data.get("location")),
hidden=nest_data.get("hidden", False),
show_geo_desc=nest_data.get("show_geo_desc", False),
)
self.spotters = [
Spotter(
id=sp["id"],
location=Location.from_dict(sp.get("location")),
hidden=sp.get("hidden", False),
show_geo_desc=sp.get("show_geo_desc", False),
)
for sp in data.get("spotters", [])
]
self._spotter_seq = max((sp.id for sp in self.spotters), default=0)
self.reference_points = [
ReferencePoint(
rp_name=rp["name"],
location=Location.from_dict(rp.get("location")),
hidden=rp.get("hidden", False),
show_geo_desc=rp.get("show_geo_desc", False),
)
for rp in data.get("reference_points", [])
]
self.targets = [
Target(
type=_migrate_target_type(t["type"]),
id=t["id"],
location=Location.from_dict(t.get("location")),
hidden=t.get("hidden", False),
show_geo_desc=t.get("show_geo_desc", False),
alive=t.get("alive", True),
powder_charges=t.get("powder_charges"),
shell=Shell[t["shell"]] if t.get("shell") else None,
assignment=t.get("assignment", "unassigned"),
)
for t in data.get("targets", [])
]

463
src/ironnest_assist/ocr.py Normal file
View File

@ -0,0 +1,463 @@
"""Text OCR pipeline: screenshot -> cleaned-up text -> parsed board info.
Only the text sub-pipeline is implemented. There's no image/icon
recognition sub-pipeline yet (spotting markers, ship icons, etc.) that's
a separate future pipeline, out of scope here.
Preprocessing matters more than the regexes: the typewriter photo has an
uneven vignette (in-game light falloff) that a single global threshold
can't handle — it either loses faint corners or blobs-out dark ones. We
flatten that by dividing by a heavily blurred copy of itself (crude local
background normalization) before thresholding, which recovers text in the
darkened areas reliably.
Parsing is deliberately fuzzy: OCR on an in-game screenshot will misread
the odd character (keywords slightly garbled, '0'/'O', '1'/'I'/'l',
'5'/'S', '8'/'B' confused, stray specks turning ':' into ';' or '.'). We
match keywords by similarity rather than exact spelling, and normalize
digit-shaped letters before parsing numbers, so a couple of misrecognized
characters don't drop an otherwise-good line.
"""
from __future__ import annotations
import difflib
import re
from dataclasses import dataclass, field
import numpy as np
import pytesseract
from PIL import Image, ImageFilter
from .models import Clue, Coord, TargetType
# --- preprocessing -----------------------------------------------------------
_BLUR_RADIUS = 35
_THRESHOLD = 200
def preprocess(image: Image.Image) -> Image.Image:
gray = image.convert("L")
arr = np.asarray(gray, dtype=np.float32)
background = np.asarray(gray.filter(ImageFilter.GaussianBlur(_BLUR_RADIUS)), dtype=np.float32)
normalized = np.clip(arr / (background + 1e-3) * 255.0, 0, 255).astype(np.uint8)
normalized_img = Image.fromarray(normalized)
return normalized_img.point(lambda p: 255 if p > _THRESHOLD else 0)
def ocr_text(image: Image.Image) -> str:
return pytesseract.image_to_string(preprocess(image), config="--psm 6")
# --- fuzzy keyword matching ---------------------------------------------------
NEST_KEYWORD = "IRON NEST"
SPOTTER_KEYWORD = "SPOTTER"
_FUZZY_THRESHOLD = 0.65
def _fuzzy_locate(line: str, keyword: str, threshold: float = _FUZZY_THRESHOLD) -> tuple[int, int] | None:
"""Span of the best approximate match of `keyword` in `line`, or None."""
line_u = line.upper()
kw = keyword.upper()
n = len(kw)
best_ratio = 0.0
best_span = None
for size in range(max(n - 2, 1), n + 3):
for start in range(0, max(len(line_u) - size, 0) + 1):
end = start + size
ratio = difflib.SequenceMatcher(None, line_u[start:end], kw).ratio()
if ratio > best_ratio:
best_ratio = ratio
best_span = (start, end)
return best_span if best_ratio >= threshold else None
def _fuzzy_contains(line: str, keyword: str, threshold: float = _FUZZY_THRESHOLD) -> bool:
"""True if some run of characters in `line` approximately matches `keyword`."""
return _fuzzy_locate(line, keyword, threshold) is not None
# --- coordinate / id extraction, tolerant of digit<->letter OCR mixups --------
_DIGIT_CLASS = r"[0-9OoIiLlSsBbZzGg]"
_DIGIT_FIX = str.maketrans({
"O": "0", "o": "0",
"I": "1", "i": "1", "L": "1", "l": "1",
"S": "5", "s": "5",
"B": "8", "b": "8",
"Z": "2", "z": "2",
"G": "6", "g": "6",
})
_SEP = r"\s*[-–—]\s*"
_COORD_RE = re.compile(
rf"([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})"
)
# No literal '#' required — it's just as OCR-corruptible as anything else
# (missing entirely, or misread as e.g. 'H'). We instead anchor to *where*
# the fuzzy keyword match ended and take the first run of digit-shaped
# characters after that, skipping over whatever separator survived.
_ID_RE = re.compile(rf"({_DIGIT_CLASS}+)")
# Not seen in a real screenshot yet, so no extraction for these — add a
# keyword + extractor here (plus a field below and a case in parse_text)
# once we know the format:
# - reference points
# - targets
def _fix_digits(s: str) -> str:
return s.translate(_DIGIT_FIX)
def _fix_id_digits(raw: str) -> str:
"""Like _fix_digits, but for id-length runs specifically: also
collapses a 2-character run where one char is a genuine digit and the
other a look-alike letter that maps to the *same* digit e.g. '1l' or
'S5' both fix to '11'/'55', but a real 2-digit id wouldn't plausibly
render as one numeral plus one letter of the identical value; that
shape is the signature of OCR ghosting a single thin glyph twice
(seen repeatedly: 'AmmoCache#l1' -> #1, 'HostileTank#S5' -> #5).
A genuine two-digit id (both chars already real digits, e.g. '11')
is left alone."""
fixed = _fix_digits(raw)
if len(raw) == 2 and len(set(fixed)) == 1:
has_digit = any(c.isdigit() for c in raw)
has_letter = any(c.isalpha() for c in raw)
if has_digit and has_letter:
return fixed[0]
return fixed
# A target can also be spotted with an absolute grid ref directly
# ("Target#10 Spotted. Grid Q3 9:0") instead of/alongside bearing/distance
# clues — same coordinate shape as _COORD_RE, just anchored after "Grid".
_GRID_COORD_RE = re.compile(
rf"Grid\s+([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})",
re.IGNORECASE,
)
def _extract_grid_coord(text: str) -> Coord | None:
m = _GRID_COORD_RE.search(text)
if not m:
return None
letter, y, x, yy = m.groups()
try:
return Coord(X=letter.upper(), Y=int(_fix_digits(y)), x=int(_fix_digits(x)), y=int(_fix_digits(yy)))
except ValueError:
return None
def _extract_coord(line: str) -> Coord | None:
m = _COORD_RE.search(line)
if not m:
return None
letter, y, x, yy = m.groups()
try:
return Coord(
X=letter.upper(),
Y=int(_fix_digits(y)),
x=int(_fix_digits(x)),
y=int(_fix_digits(yy)),
)
except ValueError:
return None # out-of-range numbers -> not actually a coordinate
def _extract_leading_id(remainder: str) -> int | None:
"""First digit-shaped run in `remainder` (text after the keyword match)."""
m = _ID_RE.search(remainder)
if not m:
return None
try:
return int(_fix_id_digits(m.group(1)))
except ValueError:
return None
# --- "field intelligence" blocks: relative Bearing/Distance descriptions ------
#
# Target#5 Spotted. 088, 12.10km from Spotter#1
# Reference Point Alpha:
# Bearing 094 from Spotter#1
# Distance 13.26km from Spotter#2
# .
# AmmoCache#3:
# Bearing 217 & Distance 10.48km from AmmoCache#2
#
# Each named entity is a block of one or more clue lines, terminated by a
# blank line or a lone '.'. Degree signs, colons, and the 'km' unit are all
# treated as optional/lossy — OCR drops them unpredictably.
_RP_HEADER_RE = re.compile(r"Reference\s+Point\s+([A-Za-z][\w-]*)\s*:?", re.IGNORECASE)
# '#' isn't required literally — same reasoning as the spotter-id fix: OCR
# drops it or renders it as noise (seen: '€'). Up to 2 junk characters
# between the type word and its digits is enough slack without risking a
# false match elsewhere.
# ^ the junk-class run is REQUIRED (1-2 chars, not 0-2): the digit class
# deliberately overlaps the alphabet (g/s/i/l/o/... look like digits), so
# with a 0-width separator allowed, "Bearing" backtracks into itself —
# word="Bearin", "digit"=its own trailing 'g' — and falsely matches as a
# header. Requiring real punctuation between word and digits (true of
# every observed header: '#', a misread substitute, ...) rules that out,
# and also stops a bare "Word 094" clue line (space only) from matching.
_NAMED_HEADER_RE = re.compile(rf"^([A-Za-z]+)[^A-Za-z0-9\s]{{1,2}}({_DIGIT_CLASS}+)\s*:?\s*(.*)$")
_BLOCK_END_RE = re.compile(r"^[.\s]{1,6}$")
_LEADING_NOISE_RE = re.compile(r"^[^A-Za-z]{1,3}(?=[A-Za-z])")
# Reference capture is (\S+), not (.+): references are always a single
# token with no spaces, and being non-greedy this way is what lets
# finditer() find more than one clue per line/block — "Bearing 118 from
# Spotter#2 & Bearing 125 from Spotter#1" needs two separate matches, and
# a greedy (.+) would let the first one swallow the rest of the string.
#
# _GAP sits right before "from": plain whitespace normally, but an OCR
# line-wrap can drop a stray junk token right at the break ("Bearing 125°"
# / "P; from Spotter#1") — and that junk can itself contain a letter (the
# 'P' above), so this isn't just non-alnum noise like the header-bullet
# case; tolerate any single short token, not just punctuation.
_GAP = r"[\s]*(?:\S{1,3}\s*)?"
_CLUE_COMBINED_RE = re.compile(
rf"Bearing\s*(\d{{1,3}})\s*°?\s*&\s*Distance\s*([\d.]+)\s*k?m?{_GAP}from\s+(\S+)", re.IGNORECASE
)
_CLUE_INLINE_RE = re.compile(
rf"(\d{{1,3}})\s*°?\s*,\s*([\d.]+)\s*k?m{_GAP}from\s+(\S+)", re.IGNORECASE
)
_CLUE_BEARING_RE = re.compile(rf"Bearing\s*(\d{{1,3}})\s*°?{_GAP}from\s+(\S+)", re.IGNORECASE)
_CLUE_DISTANCE_RE = re.compile(rf"Distance\s*([\d.]+)\s*k?m?{_GAP}from\s+(\S+)", re.IGNORECASE)
_TYPE_BY_SHORT = {t.short: t for t in TargetType}
# The game's typewriter has used "AmmoCache" for what's now modeled as
# SupplyCache — treat it as the same type rather than dropping the target.
_TYPE_WORD_ALIASES = {"AmmoCache": "SupplyCache"}
_REF_NAMED_RE = re.compile(rf"^([A-Za-z]+)[^A-Za-z0-9\s]{{1,2}}({_DIGIT_CLASS}+)")
def _clean_reference(raw: str) -> str:
"""Leading name-shaped token, dropping trailing OCR noise (stray dots,
double spaces, etc.) reference names never contain spaces. Named
references ('Spotter#1', 'AmmoCache#2') get their digit part fixed up
and their separator normalized to '#'; plain word references ('Alpha')
are left untouched don't run digit-fixing over them or real letters
like the 'l' in 'Alpha' get corrupted into '1'."""
token = re.match(r"\S+", raw.strip())
token = token.group(0) if token else raw.strip()
named = _REF_NAMED_RE.match(token)
if named:
word, num = named.groups()
word = _TYPE_WORD_ALIASES.get(word, word)
return f"{word}#{_fix_id_digits(num)}"
return re.sub(r"[^\w-]+$", "", token) # trim trailing punctuation off a plain name
# Priority order matters: try the "both bearing and distance" shape before
# the single-value shapes, so e.g. "Bearing 217 & Distance 10.48km from X"
# becomes one clue, not a spurious extra bearing-only one from the same
# text. `\s` already matches a literal newline, so this bridges an OCR
# line-wrap ("...Bearing 125°" / "from Spotter#1" split across two lines)
# without needing the caller to rejoin anything.
_CLUE_PATTERNS = (
(_CLUE_COMBINED_RE, lambda m: (float(m.group(1)), float(m.group(2)), m.group(3))),
(_CLUE_INLINE_RE, lambda m: (float(m.group(1)), float(m.group(2)), m.group(3))),
(_CLUE_BEARING_RE, lambda m: (float(m.group(1)), None, m.group(2))),
(_CLUE_DISTANCE_RE, lambda m: (None, float(m.group(1)), m.group(2))),
)
def _parse_all_clues(text: str) -> list[Clue]:
"""Every Bearing/Distance clue found anywhere in `text` — a block can
have several (one per clue line, or more than one on a single line
joined with '&')."""
clues: list[Clue] = []
consumed: list[tuple[int, int]] = []
def overlaps(span: tuple[int, int]) -> bool:
return any(span[0] < e and span[1] > s for s, e in consumed)
for pattern, extract in _CLUE_PATTERNS:
for m in pattern.finditer(text):
span = m.span()
if overlaps(span):
continue
consumed.append(span)
bearing, distance, ref = extract(m)
clues.append(Clue(reference=_clean_reference(ref), bearing_deg=bearing, distance_km=distance))
return clues
def parse_clues_from_text(text: str) -> list[Clue]:
"""Parse every Bearing/Distance clue found in free-form text — used
for manually-typed descriptions in the coord dialog, sharing the
exact same clue grammar as the OCR'd intel blocks."""
return _parse_all_clues(text)
def _resolve_target_type(type_word: str) -> TargetType | None:
"""Exact match on the type word (after aliasing), falling back to
fuzzy (OCR can garble the type word itself, e.g. 'AmmoCoche')."""
type_word = _TYPE_WORD_ALIASES.get(type_word, type_word)
if type_word in _TYPE_BY_SHORT:
return _TYPE_BY_SHORT[type_word]
best, best_ratio = None, 0.0
for short, target_type in _TYPE_BY_SHORT.items():
ratio = difflib.SequenceMatcher(None, type_word.upper(), short.upper()).ratio()
if ratio > best_ratio:
best, best_ratio = target_type, ratio
return best if best_ratio >= _FUZZY_THRESHOLD else None
def parse_intel_blocks(text: str) -> list[dict]:
"""Parse 'field intelligence' blocks into a list of dicts with keys
kind ('rp' | 'named'), name, type_word, id, raw, clues, coord. Entries
with neither a clue nor a grid coord are dropped (nothing to store).
Clues are extracted once per block, from the whole joined block text,
at flush time not accumulated line-by-line while scanning. That's
what lets a clue split across an OCR line-wrap ("...Bearing 125°" /
"from Spotter#1" on separate lines) or two clues on one line
("Bearing X from A & Bearing Y from B") both resolve correctly. A
target can also carry an absolute grid ref directly ("Grid Q3 9:0")
instead of/alongside clues."""
entries: list[dict] = []
current: dict | None = None
def flush():
nonlocal current
if current is not None:
joined = "\n".join(current["raw"])
current["clues"] = _parse_all_clues(joined)
current["coord"] = _extract_grid_coord(joined)
if current["clues"] or current["coord"] is not None:
current["raw"] = joined
entries.append(current)
current = None
for raw_line in text.splitlines():
line = raw_line.strip()
if not line:
continue # blank lines are just visual spacing here, not a section boundary
if _BLOCK_END_RE.match(line):
flush()
continue
# The '.' block-separator bullet often survives OCR as 1-3 stray
# junk characters glued onto the *next* line ('i AmmoCache#1:',
# '* AmmoCache#2:') instead of its own line, which would otherwise
# defeat the column-0-anchored header regexes below. Try the line
# as-is first, and only if that fails, retry with its first
# whitespace-delimited token stripped (covers symbol junk *and*
# a misread bullet that happened to OCR as a stray letter) — but
# only when that leading token is bullet-length (<=3 chars), else
# a genuine wrapped clue continuation like "from Spotter#2" gets
# its "from" stripped and "Spotter#2" misread as a new header.
first_token_m = re.match(r"\S+", line)
strip_first_token = (
re.sub(r"^\S+\s+", "", line, count=1)
if first_token_m and len(first_token_m.group(0)) <= 3
else line
)
candidates = (line, _LEADING_NOISE_RE.sub("", line), strip_first_token)
rp_m = next((m for c in candidates if (m := _RP_HEADER_RE.search(c))), None)
if rp_m:
flush()
current = {"kind": "rp", "name": rp_m.group(1), "type_word": None, "id": None,
"raw": [line], "clues": []}
continue
named_m = next((m for c in candidates if (m := _NAMED_HEADER_RE.match(c))), None)
if named_m:
flush()
type_word, num, inline = named_m.groups()
num = _fix_id_digits(num)
current = {"kind": "named", "name": f"{type_word}#{num}", "type_word": type_word,
"id": num, "raw": [line], "clues": []}
continue
if current is not None:
current["raw"].append(line)
flush()
return entries
# Destruction reports are standalone one-liners, not tied to a block:
# "SupplyCache#2 Destroyed. Additional Requisition Granted."
# "Direct Hit! HostileTank#3 Destroyed."
# Just need "<Type>#<id>" immediately followed by "Destroyed" — the
# "Direct Hit!" prefix (or its absence) doesn't matter, search() finds
# the name+Destroyed pair anywhere in the line either way.
_DESTROYED_RE = re.compile(
rf"([A-Za-z]+)[^A-Za-z0-9\s]{{1,2}}({_DIGIT_CLASS}+)\s*Destroyed", re.IGNORECASE
)
def parse_destroyed(text: str) -> set[tuple[TargetType, str]]:
destroyed = set()
for m in _DESTROYED_RE.finditer(text):
type_word, num = m.groups()
target_type = _resolve_target_type(type_word)
if target_type is None:
continue
destroyed.add((target_type, _fix_id_digits(num)))
return destroyed
@dataclass
class ParsedInfo:
nest_coord: Coord | None = None
spotters: dict[int, Coord] = field(default_factory=dict)
# name -> (raw description, clues, absolute coord if given directly)
reference_points: dict[str, tuple[str, list[Clue], Coord | None]] = field(default_factory=dict)
# (type, id) -> (raw description, clues, absolute coord if given directly)
targets: dict[tuple[TargetType, str], tuple[str, list[Clue], Coord | None]] = field(default_factory=dict)
# (type, id) of targets reported destroyed
destroyed: set[tuple[TargetType, str]] = field(default_factory=set)
def is_empty(self) -> bool:
return not (self.nest_coord or self.spotters or self.reference_points
or self.targets or self.destroyed)
def parse_text(text: str) -> ParsedInfo:
info = ParsedInfo()
for raw_line in text.splitlines():
line = raw_line.strip()
if not line:
continue
if info.nest_coord is None and _fuzzy_contains(line, NEST_KEYWORD):
coord = _extract_coord(line)
if coord is not None:
info.nest_coord = coord
continue
spotter_span = _fuzzy_locate(line, SPOTTER_KEYWORD)
if spotter_span is not None:
spotter_id = _extract_leading_id(line[spotter_span[1]:])
coord = _extract_coord(line)
if spotter_id is not None and coord is not None:
info.spotters[spotter_id] = coord
for entry in parse_intel_blocks(text):
if entry["kind"] == "rp":
info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"])
continue
target_type = _resolve_target_type(entry["type_word"])
if target_type is None:
continue
info.targets[(target_type, entry["id"])] = (entry["raw"], entry["clues"], entry["coord"])
info.destroyed = parse_destroyed(text)
return info
def run(image: Image.Image) -> ParsedInfo:
return parse_text(ocr_text(image))

View File

@ -0,0 +1,34 @@
"""Shell (ammunition) types and their known properties.
From High Command's field reference. Blast radius is None where unknown —
5 of 12 shells (42%) are "standard" (available without special unlock/
research), and all three shells with a known blast radius are standard
ones; the rest are still unmeasured. Feeds into the firing-solution
calculator later (ordnance choice affects what's needed to make a shot
count, e.g. AP required for underground supply caches per the typewriter).
"""
from __future__ import annotations
from enum import Enum
class Shell(Enum):
# description blast_radius_km standard
AP = ("Armor-piercing", 0.05, True)
HE = ("High-explosive fragmentation", 0.25, True)
HCHE = ("High-capacity bursting charge", 0.50, True)
STAR = ("Illumination star", None, True)
SMK = ("White phosphorus smoke", None, True)
MSTD = ("Mustard (YX) blister agent", None, False)
TEAR = ("Lachrymatory (CN) irritant", None, False)
PRPG = ("Agitation leaflet propaganda", None, False)
PHGN = ("Phosgene (CG) choking gas", None, False)
INCN = ("Thermite incendiary", None, False)
ATMC = ("Experimental uranium nucleus-splitting annihilation", None, False)
CLMN = ("Cluster mine dispersal", None, False)
def __init__(self, description: str, blast_radius_km: float | None, standard: bool) -> None:
self.description = description
self.blast_radius_km = blast_radius_km # None = not yet measured
self.standard = standard # available without a special unlock/research

View File

@ -0,0 +1,244 @@
"""Resolve relative Clues (bearing/distance from another entity) into
absolute Coords.
Geometry lives in "board units" = km: one large grid cell is 1km x 1km, and
Coord.as_fraction() already returns (col, row) in exactly those units, so
no extra scale factor is needed. Bearing is compass-style: 0 = north
(+row, since row increases upward on the map same as in-game), 90 = east
(+col), clockwise.
Solvable shapes, in priority order (matches everything seen in real
typewriter data so far):
1. One clue with both bearing and distance from a resolved reference
-> direct polar projection. Always unique.
2. Two bearing-only clues from different resolved references
-> ray/ray intersection. Always unique (unless parallel).
3. A bearing-only clue + a distance-only clue (different references)
-> ray/circle intersection. A ray can cross a circle at 0, 1, or 2
points.
4. Two distance-only clues from different references
-> circle/circle intersection. Two circles can cross at 0, 1, or 2
points.
Cases 3 and 4 surface a 2-point result as `potential` rather than a
resolved `coord` nothing here picks a "more likely" one of the two, so
nothing downstream is allowed to depend on it either.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from .models import LARGE_X, Board, Coord, Location, TargetType
Point = tuple[float, float] # (col, row) in km
@dataclass
class SolveResult:
coord: Coord | None = None
potential: list[Coord] = field(default_factory=list)
def bearing_distance_to_delta(bearing_deg: float, distance_km: float) -> Point:
rad = math.radians(bearing_deg)
return distance_km * math.sin(rad), distance_km * math.cos(rad)
def point_from_bearing_distance(origin: Point, bearing_deg: float, distance_km: float) -> Point:
dcol, drow = bearing_distance_to_delta(bearing_deg, distance_km)
return origin[0] + dcol, origin[1] + drow
def ray_circle_intersections(
origin: Point, bearing_deg: float, center: Point, radius_km: float
) -> list[Point]:
"""All points at distance >= 0 along the bearing ray from `origin` that
lie on the circle of `radius_km` around `center`, nearest first. 0, 1,
or 2 points."""
rad = math.radians(bearing_deg)
dx, dy = math.sin(rad), math.cos(rad)
ox, oy = origin[0] - center[0], origin[1] - center[1]
b = 2 * (dx * ox + dy * oy)
c = ox * ox + oy * oy - radius_km * radius_km
disc = b * b - 4 * c
if disc < 0:
return []
sqrt_disc = math.sqrt(disc)
ts = sorted(t for t in ((-b - sqrt_disc) / 2, (-b + sqrt_disc) / 2) if t >= 1e-9)
if len(ts) == 2 and abs(ts[0] - ts[1]) < 1e-6:
ts = ts[:1] # tangent: two roots collapse to one point
return [(origin[0] + dx * t, origin[1] + dy * t) for t in ts]
def circle_circle_intersections(
center_a: Point, radius_a: float, center_b: Point, radius_b: float
) -> list[Point]:
"""Points where two circles cross, arbitrary order. 0, 1, or 2 points;
also 0 for coincident circles (infinitely many "intersections"
nothing useful to return)."""
ax, ay = center_a
bx, by = center_b
dx, dy = bx - ax, by - ay
d = math.hypot(dx, dy)
if d < 1e-9:
return [] # same center — either no solution (r differs) or infinite (r same); neither is useful
if d > radius_a + radius_b + 1e-9 or d < abs(radius_a - radius_b) - 1e-9:
return [] # too far apart, or one circle nested inside the other with no crossing
a = (radius_a**2 - radius_b**2 + d**2) / (2 * d)
h = math.sqrt(max(radius_a**2 - a**2, 0.0))
px, py = ax + a * dx / d, ay + a * dy / d
if h < 1e-9:
return [(px, py)] # tangent circles: one touching point
perp_x, perp_y = -dy / d, dx / d
return [(px + h * perp_x, py + h * perp_y), (px - h * perp_x, py - h * perp_y)]
def ray_ray_intersection(
origin_a: Point, bearing_a: float, origin_b: Point, bearing_b: float
) -> Point | None:
rad_a, rad_b = math.radians(bearing_a), math.radians(bearing_b)
dax, day = math.sin(rad_a), math.cos(rad_a)
dbx, dby = math.sin(rad_b), math.cos(rad_b)
denom = dax * dby - day * dbx
if abs(denom) < 1e-9:
return None # parallel bearings, no unique intersection
ex, ey = origin_b[0] - origin_a[0], origin_b[1] - origin_a[1]
t = (ex * dby - ey * dbx) / denom
return origin_a[0] + dax * t, origin_a[1] + day * t
def point_to_coord(point: Point) -> Coord | None:
"""(col, row) km -> Coord, or None if it's meaningfully off the 20x10
map (rather than just a hair over from rounding)."""
col, row = point
if not (-0.5 <= col <= 20.5 and -0.5 <= row <= 10.5):
return None
col = min(max(col, 0.0), 19.999)
row = min(max(row, 0.0), 9.999)
x_idx = int(col)
x = round((col - x_idx) * 10)
if x > 9:
x, x_idx = 0, min(x_idx + 1, 19)
Y = int(row) + 1
y = round((row - (Y - 1)) * 10)
if y > 9:
y, Y = 0, min(Y + 1, 10)
return Coord(X=LARGE_X[x_idx], Y=Y, x=x, y=y)
def _entity_point(board: Board, name: str) -> Point | None:
obj = board.find_by_name(name)
if obj is None or obj.coord is None:
return None
return obj.coord.as_fraction()
def solve_location(location: Location, board: Board) -> SolveResult:
"""Try to resolve `location` from its clues, given everything currently
resolved on `board`. Returns a definitive `coord`, or `potential`
candidates when the geometry is genuinely ambiguous, or neither if
there's not enough resolved info yet."""
if location.coord is not None:
return SolveResult(coord=location.coord)
resolved = [(clue, pt) for clue in location.clues if (pt := _entity_point(board, clue.reference)) is not None]
if not resolved:
return SolveResult()
for clue, pt in resolved:
if clue.bearing_deg is not None and clue.distance_km is not None:
coord = point_to_coord(point_from_bearing_distance(pt, clue.bearing_deg, clue.distance_km))
if coord is not None:
return SolveResult(coord=coord)
bearings = [(c, p) for c, p in resolved if c.bearing_deg is not None and c.distance_km is None]
distances = [(c, p) for c, p in resolved if c.distance_km is not None and c.bearing_deg is None]
if len(bearings) >= 2:
(c1, p1), (c2, p2) = bearings[0], bearings[1]
point = ray_ray_intersection(p1, c1.bearing_deg, p2, c2.bearing_deg)
if point is not None:
coord = point_to_coord(point)
if coord is not None:
return SolveResult(coord=coord)
if bearings and distances:
(cb, pb), (cd, pd) = bearings[0], distances[0]
points = ray_circle_intersections(pb, cb.bearing_deg, pd, cd.distance_km)
coords = [c for p in points if (c := point_to_coord(p)) is not None]
if len(coords) == 1:
return SolveResult(coord=coords[0])
if len(coords) >= 2:
return SolveResult(potential=coords) # genuinely ambiguous
if len(distances) >= 2:
(c1, p1), (c2, p2) = distances[0], distances[1]
points = circle_circle_intersections(p1, c1.distance_km, p2, c2.distance_km)
coords = [c for p in points if (c := point_to_coord(p)) is not None]
if len(coords) == 1:
return SolveResult(coord=coords[0])
if len(coords) >= 2:
return SolveResult(potential=coords) # genuinely ambiguous
return SolveResult()
def resolve_board(board: Board) -> list[str]:
"""Resolve every not-yet-resolved RP/Target whose clues can currently
be satisfied, repeating until a fixed point (handles dependency
chains like AmmoCache#3 -> AmmoCache#2 -> Alpha/Spotters). Ambiguous
results are recorded as `potential_coords` and never feed further
resolution. Returns the names of everything newly *resolved* (not
counting ones that only became ambiguous) this call."""
newly_resolved: list[str] = []
changed = True
while changed:
changed = False
for entities in (board.reference_points, board.targets):
for obj in entities:
if obj.coord is not None or obj.location.potential_coords:
continue # already resolved, or stuck ambiguous — don't reprocess
result = solve_location(obj.location, board)
if result.coord is not None:
obj.coord = result.coord
newly_resolved.append(obj.name)
changed = True
elif result.potential:
obj.location.potential_coords = result.potential
return newly_resolved
def dedupe_generic_targets(board: Board) -> list[str]:
"""A target is often first spotted before it's identified, coming in
as the generic TargetType.UNKNOWN ("Target#N"). If a later report
identifies it with a specific type and it resolves to the *exact
same* position as an already-known specific target, it's not a new
contact it's the same one being spotted, just described more
precisely. Drop the redundant generic entry, keep the specific one.
Strikes are our own planned impacts, not enemy contacts, and never
participate. Run this after resolve_board(), since positions may
only become comparable once resolved. Returns the names removed."""
removed: list[str] = []
unknowns = [t for t in board.targets if t.type is TargetType.UNKNOWN and t.coord is not None]
specifics = [
t for t in board.targets
if t.type not in (TargetType.UNKNOWN, TargetType.STRIKE) and t.coord is not None
]
for generic in unknowns:
if any(generic.coord == specific.coord for specific in specifics):
board.remove_target(generic)
removed.append(generic.name)
return removed