Fix ally/target bugs, OCR fire-support parsing, add debug capture

- Board.clear() now also drops allies; the "clear board?" guard checks
  allies too. New Board.clear_units() + Clear button right-click menu
  ("clear enemies, units & flights", keeps Nest/spotters/RPs).
- An Ally with the ad-hoc TargetType.ENEMY showed "Enemy" on the map
  popover/toast instead of "Ally" (icons.target_type_label already had
  the fix for the picker, now reused everywhere else via app.py's
  _display_name).
- Firing panel drag-reorder no longer triggers a full app refresh
  (solver + dedupe + map redraw) on every drop, just a local rebuild.
- "Always show geo" didn't draw for Allies (missing from the overlay
  candidate list); blast radius only respected selection, not the
  show_geo_desc pin.
- ocr.py: added a second fire-support-request grammar ("Infantry#N
  taking fire ... Requesting X Shell on our position at <coord> before
  <time>", plus a bearing/distance-from-position variant), distinct
  from the existing Marine Garrison one.
- New debug_capture.py: saves screenshots (+ metadata) the app handled
  badly, for later tuning of map_vision/ocr against real failures:
  map-read errors, user grid corrections (paired with the auto-detected
  grid), screenshots that read as text but may have been a map, and
  marker-detection ground truth (every proposal's accept/reject verdict
  plus units added with no matching proposal) captured whenever a
  screenshot stops being the active one.
- README: Known issues section (map screenshot reading, grid + unit
  detection, is unreliable and fails often).
- 14 new tests (tests/test_models.py, tests/test_debug_capture.py, +
  additions to tests/test_ocr.py), 38/38 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Dominik Moritz Roth 2026-08-11 17:35:37 +02:00
parent 136492b197
commit 1ddb532325
13 changed files with 823 additions and 44 deletions

View File

@ -48,6 +48,11 @@ Regression coverage for every intel-text format the OCR pipeline understands and
## Stack ## Stack
GTK4 + libadwaita (PyGObject) for the UI, Tesseract (via pytesseract) for OCR, Pillow/numpy for preprocessing, OpenCV for the map-table geometry (line detection, vanishing points, homography). Details on the coordinate system, OCR formats, solver internals, and how the map grid is recovered live in code comments (`solver.py`, `ocr.py`, `models.py`, `map_vision.py`) rather than here. GTK4 + libadwaita (PyGObject) for the UI, Tesseract (via pytesseract) for OCR, Pillow/numpy for preprocessing, OpenCV for the map-table geometry (line detection, vanishing points, homography). Details on the coordinate system, OCR formats, solver internals, and how the map grid is recovered live in code comments (`solver.py`, `ocr.py`, `models.py`, `map_vision.py`) rather than here.
## Known issues
- **Map screenshot reading is unreliable.** Grid detection and enemy/unit detection off a map screenshot both fail often: misread grids, missed or misclassified units, screenshots rejected as "not a map" when they were one. Screenshots the app gets wrong are now saved locally (see `debug_capture.py`) to develop the detection against. Still an open problem, not a quick fix.
See `TODO.md` for the fuller list, including what's already been fixed.
## FAQ ## FAQ
### Is this cheating? ### Is this cheating?

114
TODO.md Normal file
View File

@ -0,0 +1,114 @@
# Bug Backlog (from user report, 2026-08-11)
Status legend: [x] fixed+tested, [~] partially addressed, [ ] open/needs input
- [x] Allies and enemies seem to share indices.
Investigated: `Board.add_target`/`add_ally` already use fully separate
id namespaces by design (see `models.py`'s `Ally`/`Target` docstrings),
confirmed with a new regression test
(`test_ally_and_target_ids_are_independent_namespaces`). What was
probably actually seen: an ally and a hostile target of the same type
display with the *same name* ("Tank#1") on the map with no visual
"ally" cue beyond icon/side color — related to the next item, which
fixes one concrete instance of that (TargetType.ENEMY's "Enemy" label
on an Ally). If the symptom persists after that, it's a display/
legibility issue, not an id collision — happy to take a screenshot of
what's confusing.
- [x] Ally type 'ally' is called Enemy on map title.
`icons._target_type_label` (now public `icons.target_type_label`)
already special-cased this for the type picker, but the map's
right-click popover heading, "Change type (...)" button, and toast all
printed `obj.type.value` directly instead, so an Ally with the
ad-hoc TargetType.ENEMY still showed "Enemy" everywhere except the
picker itself. Fixed in `app.py` (`_display_name`, and the three
spots using it).
- [x] Reordering firing commands lags UI hard.
`FiringPanel._reorder()` was calling `self.on_change()` — app.py's
full-app refresh (re-solve every target's clue graph, dedupe, redraw
the map, THEN rebuild the panel) — on every single drag-drop, even
though reordering touches no location/clue/coord state at all. Now
calls a local `self.refresh()` instead.
- [x] "Always show geo" doesn't reliably work / blast radius should stay
shown too.
`GridCanvas._draw_geo_overlays()`'s candidate list was
`reference_points + targets` only — Allies have a `show_geo_desc` pin
in the UI and can carry OCR'd clues too, but were never drawn.
Added. `_draw_blast_radius()` only ever looked at `self.selected`,
ignoring `show_geo_desc` entirely, so pinning it and then selecting/
deselecting something else made it vanish; now iterates every
selected-or-pinned target.
- [x] Clearing the board doesn't clear allies.
`Board.clear()` cleared everything except `self.allies`. Fixed, plus
the "clear board?" confirm-dialog's early-return guard (which skipped
the whole action if only allies were on the board) now checks allies
too.
- [x] Allow right-click on Clear button: clear all enemies/units/flights,
keep spotters/RPs/nest.
New `Board.clear_units()` + a right-click popover on the header's
Clear button wired to it.
- [x] On map-reading error: save a screenshot locally to adapt the algo.
New `debug_capture.py``save_map_read_failure()` writes the PNG +
the solver's rejection reason under
`$XDG_DATA_HOME/fenigma/debug_captures/failures/`, wired into
`app.py`'s `_start_map_import`.
- [x] When the user corrects the grid, store screenshot + ground truth too.
`debug_capture.save_grid_correction()`, wired into `_accept_grid`:
fires only when the accepted `GridSolution` isn't the one auto-solve
produced (the user actually dragged a handle in GridFixDialog), saves
both solutions under `.../debug_captures/corrections/`.
- [x] Many map screenshots seem to get read as text; if nothing relevant is
found, also store the image to check whether it was actually a map.
`debug_capture.save_maybe_map()`, wired into `_ocr_png`: fires when a
screenshot (not a plain-text paste) fell through to the OCR/text path
and `_merge_all` found nothing at all. Saved under
`.../debug_captures/maybe_map/`.
- [x] Unable to parse 3 given chat messages (Infantry "taking fire" fire-
support requests).
A different grammar from the existing Marine Garrison fire-support
request: reversed shell word order ("Requesting X Shell" vs "X Shells
requested"), a bare "before/by <time>" deadline (no "Requested"/
dashes), and either a direct "on our position at <coord>" or a
bearing/distance offset from that same inline position (not a named
board entity, so resolved directly via
`solver.point_from_bearing_distance` rather than through a Clue).
New extractors in `ocr.py`, wired into `parse_intel_blocks`'s
`flush()`. 3 new regression tests, all passing (`tests/test_ocr.py`).
- [x] When the user deletes/replaces the map screenshot, capture whatever
units they confirmed as ground truth for it.
`ScreenshotImport.baseline_targets`/`baseline_allies` (a snapshot of
`board.targets`/`board.allies` taken when the grid is confirmed,
`Target`/`Ally` are identity-hashable so these are plain sets of the
live objects) let `app.py` tell "added while this screenshot was up"
apart from "was already on the board". `Proposal` also now records
`confirmed_type` (what the user actually accepted it as, which can
differ from the detector's own guess via "Accept as..."). All of it
-- every proposal's accept/reject/undecided verdict, plus every
target/ally added with no matching proposal at all (a manual add or
an OCR-text merge run alongside the screenshot) -- is saved via
`debug_capture.save_marker_ground_truth()` under
`.../debug_captures/marker_ground_truth/`. Wired into all three
places a screenshot stops being "the active one": explicit drop, a
new screenshot pasted straight over it, and window close.
## Needs more scope / your input before I keep going
- [ ] "Accept as" button on proposed targets doesn't work.
Read through the whole path (`app.py`'s `_open_proposal_menu`/
`_accept_proposal`, `map_import.py`'s `Proposal`/`ScreenshotImport`,
`grid_widget.py`'s proposal hit-testing) end to end and couldn't find
a static defect — `map_vision.GridSolution.cell_of` already clamps
sub_x/sub_y into 0..9 before a Proposal is even built, so the obvious
"coord fails to construct, accept silently no-ops" theory doesn't
hold up either. I'd need a repro (which button exactly, screenshot of
the popover, does *anything* happen — toast, marker staying put,
wrong type applied) to chase this further rather than guess.
- [ ] Enemy type detection needs to be more robust; read the entity id
label so dedup is reliable; detect death from the log.
All three are real computer-vision/OCR feature work (better marker
classification in `map_vision.py`'s `classify_marker`, a new OCR pass
reading each marker's id label off the map screenshot, and a
"<Type>#<id> Destroyed" log-scan tied into a dedup key that includes
that read id) rather than bugs with a small fix. Worth its own pass
once there's a batch of the `debug_capture` failure/maybe_map
screenshots above to develop against.

View File

@ -26,7 +26,7 @@ gi.require_version("Gdk", "4.0")
from gi.repository import Adw, Gdk, Gio, GLib, Gtk # noqa: E402 from gi.repository import Adw, Gdk, Gio, GLib, Gtk # noqa: E402
from PIL import Image # noqa: E402 from PIL import Image # noqa: E402
from . import ballistics, icons, map_import, ocr, solver # noqa: E402 from . import ballistics, debug_capture, icons, map_import, ocr, solver # noqa: E402
from .coord_dialog import CoordDialog # noqa: E402 from .coord_dialog import CoordDialog # noqa: E402
from .firing_panel import FiringPanel # noqa: E402 from .firing_panel import FiringPanel # noqa: E402
from .grid_fix_dialog import GridFixDialog # noqa: E402 from .grid_fix_dialog import GridFixDialog # noqa: E402
@ -64,6 +64,17 @@ def _idle(fn, *args):
GLib.idle_add(lambda: (fn(*args), False)[1]) GLib.idle_add(lambda: (fn(*args), False)[1])
def _display_name(obj) -> str:
"""obj.name, but with TargetType.ENEMY's raw "Enemy" value swapped for
"Ally" when obj is an Ally (see icons.target_type_label) -- the
underlying id (obj.name, used for save files and clue references)
keeps "Enemy" either way, only this display form differs."""
if not hasattr(obj, "type"):
return obj.name
label = icons.target_type_label(obj.type, isinstance(obj, Ally)).replace(" ", "")
return f"{label}#{obj.id}"
def _coord_from_proposal(p) -> Coord | None: def _coord_from_proposal(p) -> Coord | None:
"""map_vision reports "K8" plus sub-cell 0..9 in each axis, matching """map_vision reports "K8" plus sub-cell 0..9 in each axis, matching
Coord's own convention (see GridSolution.lattice_to_grid).""" Coord's own convention (see GridSolution.lattice_to_grid)."""
@ -282,8 +293,13 @@ class MainWindow(Adw.ApplicationWindow):
header.pack_start(load_btn) header.pack_start(load_btn)
clear_btn = Gtk.Button(icon_name="edit-clear-all-symbolic") clear_btn = Gtk.Button(icon_name="edit-clear-all-symbolic")
clear_btn.set_tooltip_text("Clear board (drop everything)") clear_btn.set_tooltip_text(
"Clear board (drop everything). Right-click for a lighter option.")
clear_btn.connect("clicked", lambda _b: self._clear_board()) clear_btn.connect("clicked", lambda _b: self._clear_board())
clear_right_click = Gtk.GestureClick(button=Gdk.BUTTON_SECONDARY)
clear_right_click.connect(
"released", lambda _g, _n, x, y: self._open_clear_menu(clear_btn, x, y))
clear_btn.add_controller(clear_right_click)
header.pack_start(clear_btn) header.pack_start(clear_btn)
self._clip_btn = Gtk.Button(icon_name="edit-paste-symbolic") self._clip_btn = Gtk.Button(icon_name="edit-paste-symbolic")
@ -529,7 +545,17 @@ class MainWindow(Adw.ApplicationWindow):
except Exception as exc: # OCR/parsing hiccups shouldn't crash the app except Exception as exc: # OCR/parsing hiccups shouldn't crash the app
self.toast(f"OCR failed: {exc}") self.toast(f"OCR failed: {exc}")
return return
on_parsed(info) result = on_parsed(info)
# Empty result specifically from a screenshot IMAGE (not a plain-
# text paste) is suspicious: this screenshot fell through to the
# OCR/text path -- either the map-vision gate misrouted it, or
# map_vision itself rejected it -- and came back with nothing at
# all. It might genuinely have been a map, worth keeping to check
# against later. A plain-text paste that finds nothing is normal
# and never reaches this function at all (see
# _on_clipboard_text_ready), so no separate guard needed here.
if isinstance(result, list) and not result:
debug_capture.save_maybe_map(png)
def _start_map_import(self, png: bytes, not_a_map) -> None: def _start_map_import(self, png: bytes, not_a_map) -> None:
"""Try to read the clipboard image as a map screenshot, off-thread. """Try to read the clipboard image as a map screenshot, off-thread.
@ -561,6 +587,7 @@ class MainWindow(Adw.ApplicationWindow):
# one and the user wants to know why it didn't take. # one and the user wants to know why it didn't take.
if error != map_import.NOT_A_MAP: if error != map_import.NOT_A_MAP:
self.toast(f"Couldn't read the grid ({error}), trying as text.") self.toast(f"Couldn't read the grid ({error}), trying as text.")
debug_capture.save_map_read_failure(png, error)
not_a_map() not_a_map()
return return
self._on_map_import_ready(result) self._on_map_import_ready(result)
@ -594,8 +621,27 @@ class MainWindow(Adw.ApplicationWindow):
).present(self) ).present(self)
def _accept_grid(self, imp, solution) -> None: def _accept_grid(self, imp, solution) -> None:
"""Grid confirmed: rectify the screenshot onto the board, then detect.""" """Grid confirmed: rectify the screenshot onto the board, then detect.
If the confirmed grid isn't the one the solver proposed (the user
dragged a corner in GridFixDialog), keep both the screenshot and
both solutions as ground truth, useful later for improving the
grid solver against exactly the case it got wrong."""
if solution is not imp.solution:
debug_capture.save_grid_correction(imp.image, imp.solution, solution)
imp.solution = solution imp.solution = solution
# A screenshot already on the board (never explicitly dropped, the
# user just pasted a new one straight over it) still deserves its
# ground truth captured before it's replaced -- same as an
# explicit drop, see _capture_screenshot_ground_truth.
if self.screenshot_import is not None:
self._capture_screenshot_ground_truth(self.screenshot_import)
# Snapshot of what's on the board BEFORE this screenshot's own
# units get added, so _capture_screenshot_ground_truth can later
# tell "added because of this screenshot" apart from "was already
# there" -- see ScreenshotImport.baseline_targets/baseline_allies.
imp.baseline_targets = set(self.board.targets)
imp.baseline_allies = set(self.board.allies)
self.screenshot_import = imp self.screenshot_import = imp
imp.build_overlay() imp.build_overlay()
self.canvas.set_screenshot(imp.overlay, imp.px_per_km) self.canvas.set_screenshot(imp.overlay, imp.px_per_km)
@ -649,6 +695,7 @@ class MainWindow(Adw.ApplicationWindow):
else: else:
self.board.add_target(type_, coord) self.board.add_target(type_, coord)
proposal.accepted = True proposal.accepted = True
proposal.confirmed_type = type_.name
def _accept_all_proposals(self) -> None: def _accept_all_proposals(self) -> None:
imp = self.screenshot_import imp = self.screenshot_import
@ -661,6 +708,21 @@ class MainWindow(Adw.ApplicationWindow):
self._refresh_proposals() self._refresh_proposals()
self.toast(f"Accepted {len(pending)} unit(s).") self.toast(f"Accepted {len(pending)} unit(s).")
def _capture_screenshot_ground_truth(self, imp) -> None:
"""Whatever the user actually confirmed while `imp` was the active
screenshot -- accepted/rejected proposals, plus anything added to
the board that wasn't from a proposal at all (a manual add, or an
OCR-text merge run alongside it, see
ScreenshotImport.baseline_targets/baseline_allies) -- is exactly
the ground truth marker detection needs to improve against.
Called right before `imp` stops being the active screenshot,
whether that's an explicit drop (_remove_screenshot) or a new
screenshot pasted straight over it (_accept_grid), the last
moment it can still be tied to this specific image."""
added_targets = [t for t in self.board.targets if t not in imp.baseline_targets]
added_allies = [a for a in self.board.allies if a not in imp.baseline_allies]
debug_capture.save_marker_ground_truth(imp.image, imp.proposals, added_targets, added_allies)
def _remove_screenshot(self) -> None: def _remove_screenshot(self) -> None:
"""Dropping the screenshot also drops every proposal never accepted: """Dropping the screenshot also drops every proposal never accepted:
they were only ever readings OF that screenshot, so without it there is they were only ever readings OF that screenshot, so without it there is
@ -668,6 +730,7 @@ class MainWindow(Adw.ApplicationWindow):
imp = self.screenshot_import imp = self.screenshot_import
if imp is None: if imp is None:
return return
self._capture_screenshot_ground_truth(imp)
dropped = len(imp.pending()) dropped = len(imp.pending())
imp.drop_unaccepted() imp.drop_unaccepted()
self.screenshot_import = None self.screenshot_import = None
@ -764,16 +827,80 @@ class MainWindow(Adw.ApplicationWindow):
if self._clipboard_watch_handler is not None: if self._clipboard_watch_handler is not None:
Gdk.Display.get_default().get_clipboard().disconnect(self._clipboard_watch_handler) Gdk.Display.get_default().get_clipboard().disconnect(self._clipboard_watch_handler)
self._clipboard_watch_handler = None self._clipboard_watch_handler = None
# Closing with a screenshot still up is otherwise-silent data loss
# for debug_capture: same ground-truth capture as an explicit drop
# or pasting a new screenshot over it, see
# _capture_screenshot_ground_truth.
if self.screenshot_import is not None:
self._capture_screenshot_ground_truth(self.screenshot_import)
def _open_clear_menu(self, clear_btn: Gtk.Button, x: float, y: float) -> None:
"""Right-click on the Clear button: a lighter option than the full
Clear (left-click), for wiping the round's contacts without losing
the Nest/spotters/reference points set up for it."""
popover = Gtk.Popover()
popover.set_parent(clear_btn)
# See _popover_at's comment: Gdk.Rectangle's constructor silently
# ignores keyword args on this PyGObject version, field assignment
# after construction is the only way that actually works.
rect = Gdk.Rectangle()
rect.x, rect.y, rect.width, rect.height = int(x), int(y), 1, 1
popover.set_pointing_to(rect)
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)
btn = Gtk.Button(label="Clear enemies, units & flights", css_classes=["destructive-action"])
btn.set_tooltip_text("Drops targets, allies, and scout flights. "
"Keeps the Nest, spotters, and reference points.")
if btn.get_child() is not None:
btn.get_child().set_xalign(0.0)
def go():
popover.popdown()
self._clear_units()
btn.connect("clicked", lambda _b: go())
box.append(btn)
popover.set_child(box)
popover.popup()
def _clear_units(self) -> None:
board = self.board
if not board.targets and not board.allies and not board.scout_flights:
self.toast("Nothing to clear.")
return
dialog = Adw.AlertDialog(
heading="Clear enemies, units & flights?",
body="Drops every target, ally, and scout flight. The Nest, spotters, and reference "
"points are kept. This can't be undone.",
)
dialog.add_response("cancel", "Cancel")
dialog.add_response("clear", "Clear")
dialog.set_response_appearance("clear", Adw.ResponseAppearance.DESTRUCTIVE)
dialog.set_default_response("cancel")
dialog.set_close_response("cancel")
def on_response(_dialog, response):
if response != "clear":
return
board.clear_units()
self._set_selection(None)
self._refresh()
self.toast("Enemies, units & flights cleared.")
dialog.connect("response", on_response)
dialog.present(self)
def _clear_board(self) -> None: def _clear_board(self) -> None:
board = self.board board = self.board
if (board.nest.coord is None and not board.spotters and not board.reference_points if (board.nest.coord is None and not board.spotters and not board.reference_points
and not board.targets and not board.scout_flights): and not board.targets and not board.allies and not board.scout_flights):
return # nothing to clear return # nothing to clear
dialog = Adw.AlertDialog( dialog = Adw.AlertDialog(
heading="Clear board?", heading="Clear board?",
body="Drops the Nest position and every spotter, reference point, target, and scout flight. " body="Drops the Nest position and every spotter, reference point, target, ally, and scout "
"This can't be undone.", "flight. This can't be undone.",
) )
dialog.add_response("cancel", "Cancel") dialog.add_response("cancel", "Cancel")
dialog.add_response("clear", "Clear") dialog.add_response("clear", "Clear")
@ -791,7 +918,7 @@ class MainWindow(Adw.ApplicationWindow):
self._refresh() self._refresh()
self.toast("Board cleared.") self.toast("Board cleared.")
def _merge_all(self, info: "ocr.ParsedInfo") -> None: def _merge_all(self, info: "ocr.ParsedInfo") -> list[str]:
changed = [] changed = []
if info.nest_coord is not None: if info.nest_coord is not None:
self.board.nest.coord = info.nest_coord self.board.nest.coord = info.nest_coord
@ -806,6 +933,7 @@ class MainWindow(Adw.ApplicationWindow):
else: else:
self.toast("Merged from screenshot: " + ", ".join(changed)) self.toast("Merged from screenshot: " + ", ".join(changed))
self._refresh() self._refresh()
return changed
def _merge_spotters(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]: def _merge_spotters(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]:
changed = [] changed = []
@ -1369,10 +1497,11 @@ class MainWindow(Adw.ApplicationWindow):
def show_main(): def show_main():
box = page() box = page()
where = obj.coord.label() if getattr(obj, "coord", None) else "unplaced" where = obj.coord.label() if getattr(obj, "coord", None) else "unplaced"
heading(box, f"{obj.name}{where}") heading(box, f"{_display_name(obj)}{where}")
box.append(Gtk.Separator(margin_top=2, margin_bottom=2)) box.append(Gtk.Separator(margin_top=2, margin_bottom=2))
if hasattr(obj, "type"): if hasattr(obj, "type"):
button(box, f"Change type ({obj.type.value})", show_type) button(box, f"Change type ({icons.target_type_label(obj.type, isinstance(obj, Ally))})",
show_type)
if self._id_field_of(obj) is not None: if self._id_field_of(obj) is not None:
button(box, "Change ID", show_id) button(box, "Change ID", show_id)
button(box, "Change position (click the map)", change_position) button(box, "Change position (click the map)", change_position)
@ -1403,7 +1532,8 @@ class MainWindow(Adw.ApplicationWindow):
obj.type = t obj.type = t
self._refresh() self._refresh()
popover.popdown() popover.popdown()
self.toast(f"{obj.name} is now a {t.value}.") self.toast(f"{_display_name(obj)} is now a "
f"{icons.target_type_label(t, isinstance(obj, Ally))}.")
def show_id(): def show_id():
box = page() box = page()

View File

@ -0,0 +1,152 @@
"""Squirrel away screenshots the vision/OCR pipeline handled badly, so the
detection algorithms can later be tuned against real failures instead of
just the fixture set.
Three cases, one folder each under _debug_dir():
failures/ -- map_vision.solve_path() errored out on what the gate
thought was a map (see app.py's _start_map_import).
corrections/ -- the user dragged the grid in GridFixDialog rather than
just accepting the auto-solve, paired with both the
original and the corrected GridSolution as ground truth.
maybe_map/ -- a screenshot fell through to the OCR/text path and
came back with nothing usable at all; it might genuinely
have been a map the gate misrouted, worth a look.
Deliberately silent on its own failure (a full/read-only disk shouldn't
turn a debug aid into a crash): every function here catches broadly and
gives up quietly rather than raising into the caller's UI-thread code.
"""
from __future__ import annotations
import json
import time
from pathlib import Path
import numpy as np
from PIL import Image
def _debug_dir(sub: str) -> Path:
"""XDG data dir if set, ~/.local/share otherwise, matching where a
Linux desktop app is expected to keep its own state -- same base
other GTK/libadwaita apps on this platform use, just our own
subfolder under it."""
import os
base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
return Path(base) / "fenigma" / "debug_captures" / sub
def _save(sub: str, png: bytes, meta: dict) -> Path | None:
try:
d = _debug_dir(sub)
d.mkdir(parents=True, exist_ok=True)
stamp = f"{time.time():.6f}".replace(".", "-")
(d / f"{stamp}.png").write_bytes(png)
(d / f"{stamp}.json").write_text(json.dumps(meta, indent=2))
return d / f"{stamp}.png"
except OSError:
return None
def _to_png_bytes(image) -> bytes | None:
"""Accept raw PNG bytes, a PIL Image, or a BGR numpy array (map_vision's
own in-memory image shape, see map_vision.load), so every call site can
just hand over whatever it already has."""
if isinstance(image, (bytes, bytearray)):
return bytes(image)
if isinstance(image, np.ndarray):
image = Image.fromarray(image[:, :, ::-1]) # BGR (cv2) -> RGB (PIL)
if isinstance(image, Image.Image):
import io
buf = io.BytesIO()
image.save(buf, format="PNG")
return buf.getvalue()
return None
def save_map_read_failure(image, reason: str) -> Path | None:
"""map_vision rejected/errored on a screenshot the cheap gate thought
was a map -- the interesting case, an OCR-text screenshot false-
positiving the gate is expected background noise (see
ImportJob.looks_like_map's own docstring), but a genuine map the
solver couldn't handle is exactly what needs fixing."""
png = _to_png_bytes(image)
if png is None:
return None
return _save("failures", png, {"reason": reason})
def save_maybe_map(image) -> Path | None:
"""A screenshot that went down the OCR/text path (either the gate
routed it there, or map_vision rejected it) and came back with
nothing usable -- possibly a map screenshot misread as text rather
than genuinely empty intel."""
png = _to_png_bytes(image)
if png is None:
return None
return _save("maybe_map", png, {})
def save_marker_ground_truth(image, proposals, added_targets=(), added_allies=()) -> Path | None:
"""Ground truth for marker detection, captured when the user drops a
screenshot (app.py's _remove_screenshot): every proposal the detector
made, whether the user accepted/rejected/never decided it (and, if
accepted, what type they actually confirmed -- may differ from the
detector's own guess, see Proposal.confirmed_type), PLUS every
Target/Ally that ended up on the board while this screenshot was up
that *isn't* explained by an accepted proposal at all -- a manual
add, or one merged in from OCR text run alongside it. Both signals
matter: a rejected proposal is a false positive to fix, a manually-
added unit that had no matching proposal at all is a miss to fix.
Skipped entirely if there's nothing to say (no proposals AND no
manually-added units), a screenshot nobody ever looked at units on."""
if not proposals and not added_targets and not added_allies:
return None
png = _to_png_bytes(image)
if png is None:
return None
def verdict(p):
if p.accepted:
return "accepted"
if p.rejected:
return "rejected"
return "undecided" # dropped along with the screenshot, never actioned
return _save("marker_ground_truth", png, {
"proposals": [
{
"side": p.side, "label": p.label, "sub_x": p.sub_x, "sub_y": p.sub_y,
"detected_unit": p.unit, "verdict": verdict(p), "confirmed_type": p.confirmed_type,
}
for p in proposals
],
"added_units": [
{"kind": kind, "type": u.type.name, "id": u.id, "coord": u.coord.label() if u.coord else None}
for kind, units in (("target", added_targets), ("ally", added_allies))
for u in units
],
})
def _solution_to_dict(sol) -> dict:
return {
"H": sol.H.tolist(),
"si": sol.si, "sj": sol.sj,
"du": sol.du, "dv": sol.dv,
}
def save_grid_correction(image, original_solution, corrected_solution) -> Path | None:
"""The user dragged the grid in GridFixDialog rather than accepting
the auto-solve as-is: both solutions, saved as ground truth for
tuning the grid solver against later."""
png = _to_png_bytes(image)
if png is None:
return None
return _save("corrections", png, {
"original": _solution_to_dict(original_solution),
"corrected": _solution_to_dict(corrected_solution),
})

View File

@ -369,7 +369,16 @@ class FiringPanel(Gtk.Box):
if dragged not in targets or drop_onto not in targets: if dragged not in targets or drop_onto not in targets:
return return
self.board.reorder_target(dragged, targets.index(drop_onto)) self.board.reorder_target(dragged, targets.index(drop_onto))
self.on_change() # NOT self.on_change(): that's app.py's "single choke point" full
# refresh (re-run the solver over every target's clues, dedupe,
# redraw the map, THEN rebuild this panel), all of it wasted work
# for a pure order change -- no location/clue/coord/alive state
# moved, so nothing the solver or the map drawing cares about
# changed, only this panel's own card order did. Calling that
# full pipeline on every single drag-drop was what made
# reordering feel laggy; a local refresh() is the only rebuild a
# reorder actually needs.
self.refresh()
def _build_unresolved_card(self, target: Target) -> Gtk.Widget: def _build_unresolved_card(self, target: Target) -> Gtk.Widget:
card, inner = self._build_card_shell(target) card, inner = self._build_card_shell(target)

View File

@ -996,20 +996,28 @@ class GridCanvas(Gtk.DrawingArea):
self._draw_arrow(cr, nx, ny, tx, ty) self._draw_arrow(cr, nx, ny, tx, ty)
def _draw_blast_radius(self, cr, view) -> None: def _draw_blast_radius(self, cr, view) -> None:
"""When a Target is selected, its effective shell's blast radius, """Every Target's effective shell's blast radius, for whichever
selection only, not hover (unlike the geo overlays/firing arrow), ones are selected or pinned via the same "always show geo"
per spec. Uses the specific selected candidate point if the target show_geo_desc toggle the bearing/distance overlay uses (not on
is ambiguous; skipped entirely if there's no known point yet, or plain hover, unlike that overlay -- a blast radius circle
the shell's blast radius isn't known.""" flickering in on every hover was judged too noisy, selection/
if not isinstance(self.selected, Target): pinning is a deliberate choice). Uses the specific selected
return candidate point if an ambiguous target is the selected one;
target = self.selected skipped per-target if there's no known point yet, or the shell's
point = target.coord if target.coord is not None else self.selected_point blast radius isn't known."""
targets = [
t for t in self.board.targets
if not self._excluded_from_map(t) and (t is self.selected or t.show_geo_desc)
]
for target in targets:
point = target.coord if target.coord is not None else (
self.selected_point if target is self.selected else None
)
if point is None: if point is None:
return continue
radius_km = target.effective_shell.blast_radius_km radius_km = target.effective_shell.blast_radius_km
if radius_km is None: if radius_km is None:
return continue
x, y = self._km_to_px(view, point.as_fraction()) x, y = self._km_to_px(view, point.as_fraction())
rx, ry = view.cell_w * radius_km, view.cell_h * radius_km rx, ry = view.cell_w * radius_km, view.cell_h * radius_km
@ -1084,7 +1092,14 @@ class GridCanvas(Gtk.DrawingArea):
way, so this looks at every RP/Target directly rather than those, way, so this looks at every RP/Target directly rather than those,
the only way to let the user eyeball a bad-but-close reading the only way to let the user eyeball a bad-but-close reading
against what it should have crossed.""" against what it should have crossed."""
candidates = list(self.board.reference_points) + list(self.board.targets) # Nest/Spotter never carry clues (always given as a direct grid
# coord, no relative-bearing mechanic for them), so leaving them
# out here wouldn't visibly change anything -- but Allies DO get
# clues from OCR ("FriendlyTank#1 Spotted. 088, 12.10km from
# Spotter#1") and also have a show_geo_desc pin in the UI (see
# app.py's per-card "always show geo" toggle), so omitting them
# here meant pinning one silently did nothing.
candidates = list(self.board.reference_points) + list(self.board.targets) + list(self.board.allies)
to_show = [ to_show = [
obj for obj in candidates obj for obj in candidates
if obj.location.clues and not self._excluded_from_map(obj) if obj.location.clues and not self._excluded_from_map(obj)

View File

@ -482,20 +482,29 @@ def available_target_types(is_ally: bool = False):
return [t for t in TargetType if t is not TargetType.STRIKE and _has_own_icon(t, is_ally)] return [t for t in TargetType if t is not TargetType.STRIKE and _has_own_icon(t, is_ally)]
def _target_type_label(t: "TargetType", is_ally: bool) -> str: def target_type_label(t: "TargetType", is_ally: bool) -> str:
"""Display text for a picker cell/tooltip. TargetType.ENEMY's own """Display text for a picker cell/tooltip, or any other UI spot that
value is literally 'Enemy' (it's the word the game's OCR'd text uses would otherwise print obj.type.value directly (map popover headings,
for an ad-hoc *hostile* installation, see TargetType's own comment) -- "Change type" buttons, toasts, ...). TargetType.ENEMY's own value is
literally 'Enemy' (it's the word the game's OCR'd text uses for an
ad-hoc *hostile* installation, see TargetType's own comment) --
exactly right in the enemy picker, but confusing in the Ally one, exactly right in the enemy picker, but confusing in the Ally one,
where the very same generic/ad-hoc-named-unit case reads as 'Enemy' where the very same generic/ad-hoc-named-unit case reads as 'Enemy'
is somehow a kind of Ally. Cosmetic only: the underlying TargetType is somehow a kind of Ally. Cosmetic only: the underlying TargetType
stored on the entity is still ENEMY either way, only the label shown stored on the entity is still ENEMY either way, only the label shown
while picking it changes.""" changes -- callers that need an id-safe short form (Ally.name etc.)
keep using TargetType.short, not this."""
if is_ally and t is TargetType.ENEMY: if is_ally and t is TargetType.ENEMY:
return "Ally" return "Ally"
return t.value return t.value
# Old private name, kept as an alias: nothing outside this module should
# gain a new dependency on it, but this file's own internal callers below
# were written against it.
_target_type_label = target_type_label
def _target_type_cell(t: "TargetType", is_ally: bool) -> Gtk.Widget: def _target_type_cell(t: "TargetType", is_ally: bool) -> Gtk.Widget:
"""Icon + name, both a FIXED size regardless of how long the name is -- """Icon + name, both a FIXED size regardless of how long the name is --
a real cell size that varies with its label text (three-line names next a real cell size that varies with its label text (three-line names next

View File

@ -41,6 +41,13 @@ class Proposal:
box: tuple box: tuple
accepted: bool = False accepted: bool = False
rejected: bool = False rejected: bool = False
# The TargetType.name actually applied when accepted -- usually just
# `unit` translated through icons.target_type_from_icon, but can
# differ if the user corrected it via "Accept as...". Set by
# app.py's _accept_proposal, the only writer. Ground truth for
# debug_capture.save_marker_ground_truth: `unit` is what the
# classifier guessed, this is what the user actually confirmed.
confirmed_type: str | None = None
@property @property
def coord(self) -> str: def coord(self) -> str:
@ -59,6 +66,20 @@ class ScreenshotImport:
proposals: list = field(default_factory=list) proposals: list = field(default_factory=list)
overlay: object = None # BGRA array in map space overlay: object = None # BGRA array in map space
px_per_km: int = 0 px_per_km: int = 0
# Board.targets/Board.allies as they stood right when this screenshot's
# grid was confirmed (see app.py's _accept_grid) -- Target/Ally are
# identity-hashable (models.py's `eq=False`), so these are plain sets
# of the actual live objects, not ids/copies. Whatever's in
# board.targets/board.allies but NOT in these sets when the screenshot
# is later dropped was added while this screenshot was up, by
# whatever means (an accepted proposal, a manual add, an OCR-text
# merge run alongside it, ...) -- see app.py's _remove_screenshot,
# which treats that as this screenshot's ground truth for
# debug_capture.save_marker_ground_truth. Left for app.py to populate
# rather than done here, this module stays ignorant of the Board/
# Target/Ally types on purpose (see this file's own docstring).
baseline_targets: set = field(default_factory=set)
baseline_allies: set = field(default_factory=set)
def set_proposals(self, markers): def set_proposals(self, markers):
self.proposals = [ self.proposals = [

View File

@ -608,16 +608,30 @@ class Board:
# -- reset ---------------------------------------------------------- # -- reset ----------------------------------------------------------
def clear(self) -> None: def clear(self) -> None:
"""Drop everything: Nest position, spotters, reference points, """Drop everything: Nest position, spotters, reference points,
targets, scout flights. Used by the "clear board" action for a targets, allies, scout flights. Used by the "clear board" action
fresh start without restarting the app.""" for a fresh start without restarting the app."""
self.nest = Nest() self.nest = Nest()
self.spotters.clear() self.spotters.clear()
self.reference_points.clear() self.reference_points.clear()
self.targets.clear() self.targets.clear()
self.allies.clear()
self.scout_flights.clear() self.scout_flights.clear()
self._spotter_seq = 0 self._spotter_seq = 0
self._scout_flight_seq = 0 self._scout_flight_seq = 0
def clear_units(self) -> None:
"""Partial reset: drop targets, allies, and scout flights, but keep
the Nest, spotters, and reference points -- those are recon
infrastructure the player set up deliberately and usually wants to
keep across a round, unlike enemy/ally contacts and planned
overflights, which go stale fast. Wired to the Clear button's
right-click menu ("Clear enemies, units & flights") as a lighter
alternative to clear()."""
self.targets.clear()
self.allies.clear()
self.scout_flights.clear()
self._scout_flight_seq = 0
def reorder_target(self, target: Target, new_index: int) -> None: def reorder_target(self, target: Target, new_index: int) -> None:
"""Manual drag-order: `self.targets`' list order is itself the """Manual drag-order: `self.targets`' list order is itself the
persisted order (saved/loaded as a plain JSON array), and is what persisted order (saved/loaded as a plain JSON array), and is what

View File

@ -197,6 +197,83 @@ def _extract_requested_time(text: str) -> str | None:
return m.group(1) if m else None return m.group(1) if m else None
# A second, unrelated fire-support-request grammar, seen from Infantry
# under attack ("taking fire") rather than a pinned Marine Garrison:
# Infantry#1 taking fire from id1! Requesting SMK Shell on our
# position at J6 2:7 before 10:38:57!
# Infantry#3 taking fire! Requesting HE Shell at bearing 239°,
# distance 10.76km from our position, J6 2:5, by 10:38:18 or we
# will be overrun!
# Differs from the Marine Garrison shape in every particular: the shell
# word order is reversed ("Requesting X Shell", not "X Shells requested"),
# the deadline has no "Requested"/dashes, just a bare "before"/"by <time>",
# and the target position is either given directly ("on our position at
# <coord>") or as a bearing/distance offset from that same inline
# position (never a *named* reference -- "our position" isn't a board
# entity, so this resolves the offset directly rather than going through
# a Clue).
# \s* (not \s+) between the shell code and 'Shell(s)': a rich-text paste's
# '<b>SMK Shell</b>' span gets squashed into one no-space token 'SMKShell'
# by squash_bold_spans() before this ever runs (same as any other
# multi-word bold span, see _squash_span_content), same reasoning as
# _extract_requesting_shell()'s docstring.
_REQUESTING_SHELL_RE = re.compile(r"Requesting\s+([A-Za-z]+?)\s*Shells?\b", re.IGNORECASE)
_TAKING_FIRE_TIME_RE = re.compile(r"\b(?:before|by)\s+(T?\d{1,2}:\d{2}:\d{2})\b", re.IGNORECASE)
_ON_OUR_POSITION_COORD_RE = re.compile(
rf"on\s+our\s+position\s+at\s+{_COORD_FRAGMENT}", re.IGNORECASE
)
_BEARING_DISTANCE_FROM_POSITION_RE = re.compile(
rf"bearing\s*({_DIGIT_CLASS}{{1,3}})\s*°?\s*,?\s*distance\s*([\d.]+)\s*k?m\s+from\s+our\s+"
rf"position,?\s*{_COORD_FRAGMENT}",
re.IGNORECASE,
)
def _extract_requesting_shell(text: str) -> Shell | None:
"""'Requesting SMK Shell' -- the taking-fire grammar's shell mention,
word order reversed from _extract_shell_request()'s Marine Garrison
one ('SMK Shells requested')."""
m = _REQUESTING_SHELL_RE.search(text)
if not m:
return None
try:
return Shell[m.group(1).upper()]
except KeyError:
return None
def _extract_taking_fire_time(text: str) -> str | None:
m = _TAKING_FIRE_TIME_RE.search(text)
return m.group(1) if m else None
def _extract_on_our_position_coord(text: str) -> Coord | None:
m = _ON_OUR_POSITION_COORD_RE.search(text)
if not m:
return None
return _coord_from_groups(*m.groups())
def _extract_bearing_distance_from_position_coord(text: str) -> Coord | None:
"""The bearing/distance variant of a taking-fire request: the shell is
wanted somewhere OFF the reporting unit's own position, given as a
bearing/distance from it, with that position itself given inline
right there ('...from our position, J6 2:5, by ...'). "our position"
isn't a named board entity to hang a Clue off of, so this resolves
the offset directly via the same polar-projection math solve_location()
uses for an ordinary single bearing+distance clue."""
m = _BEARING_DISTANCE_FROM_POSITION_RE.search(text)
if not m:
return None
bearing, distance, letter, y, x, yy = m.groups()
origin = _coord_from_groups(letter, y, x, yy)
if origin is None:
return None
point = solver.point_from_bearing_distance(
origin.as_fraction(), float(_fix_digits(bearing)), float(distance))
return solver.point_to_coord(point)
# "Reported active in grid D10": only the large-grid cell, no sub-grid # "Reported active in grid D10": only the large-grid cell, no sub-grid
# x:y at all, unlike every other coord shape in this file. Tried last # x:y at all, unlike every other coord shape in this file. Tried last
# (after _extract_grid_coord, which requires the full x:y and so is # (after _extract_grid_coord, which requires the full x:y and so is
@ -659,10 +736,12 @@ def parse_intel_blocks(text: str) -> list[dict]:
current["clues"] = _parse_all_clues(joined) current["clues"] = _parse_all_clues(joined)
current["coord"] = ( current["coord"] = (
_extract_grid_coord(joined) or _extract_requested_on_coord(joined) _extract_grid_coord(joined) or _extract_requested_on_coord(joined)
or _extract_on_our_position_coord(joined)
or _extract_bearing_distance_from_position_coord(joined)
or _extract_large_grid_only_coord(joined) or _extract_large_grid_only_coord(joined)
) )
current["shell"] = _extract_shell_request(joined) current["shell"] = _extract_shell_request(joined) or _extract_requesting_shell(joined)
current["requested_time"] = _extract_requested_time(joined) current["requested_time"] = _extract_requested_time(joined) or _extract_taking_fire_time(joined)
if (current["clues"] or current["coord"] is not None if (current["clues"] or current["coord"] is not None
or current["shell"] is not None or current["requested_time"] is not None): or current["shell"] is not None or current["requested_time"] is not None):
current["raw"] = joined current["raw"] = joined

100
tests/test_debug_capture.py Normal file
View File

@ -0,0 +1,100 @@
"""debug_capture just needs to reliably write what it's given and never
raise into caller code -- these are format/plumbing checks, not vision
tests."""
import json
import numpy as np
import pytest
from PIL import Image
from fenigma import debug_capture
@pytest.fixture(autouse=True)
def _isolated_debug_dir(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
return tmp_path
def _tiny_png_bytes() -> bytes:
import io
buf = io.BytesIO()
Image.new("RGB", (4, 4), (10, 20, 30)).save(buf, format="PNG")
return buf.getvalue()
def test_save_map_read_failure_writes_png_and_reason(_isolated_debug_dir):
path = debug_capture.save_map_read_failure(_tiny_png_bytes(), "too few grid line families")
assert path is not None
assert path.exists()
meta = json.loads(path.with_suffix(".json").read_text())
assert meta["reason"] == "too few grid line families"
def test_save_maybe_map_accepts_numpy_bgr_array(_isolated_debug_dir):
bgr = np.zeros((4, 4, 3), dtype=np.uint8)
bgr[..., 0] = 200 # blue channel, would come out red if BGR/RGB got swapped
path = debug_capture.save_maybe_map(bgr)
assert path is not None
saved = Image.open(path)
assert saved.getpixel((0, 0)) == (0, 0, 200) # still blue, not swapped to red
def test_save_grid_correction_writes_both_solutions(_isolated_debug_dir):
class FakeSolution:
def __init__(self, du):
self.H = np.eye(3)
self.si, self.sj, self.du, self.dv = 1, 1, du, 0
path = debug_capture.save_grid_correction(_tiny_png_bytes(), FakeSolution(0.0), FakeSolution(0.3))
assert path is not None
meta = json.loads(path.with_suffix(".json").read_text())
assert meta["original"]["du"] == 0.0
assert meta["corrected"]["du"] == 0.3
def test_unsupported_image_type_returns_none_without_raising(_isolated_debug_dir):
assert debug_capture.save_maybe_map(object()) is None
def _proposal(**overrides):
from fenigma.map_import import Proposal
defaults = dict(side="hostile", label="K8", sub_x=3, sub_y=4, unit="Armor_Tank.png",
centre=(0, 0), box=(0, 0, 0, 0))
defaults.update(overrides)
return Proposal(**defaults)
def test_marker_ground_truth_records_verdict_per_proposal(_isolated_debug_dir):
accepted = _proposal(accepted=True, confirmed_type="TANK")
rejected = _proposal(label="K9", rejected=True)
undecided = _proposal(label="L1")
path = debug_capture.save_marker_ground_truth(_tiny_png_bytes(), [accepted, rejected, undecided])
assert path is not None
meta = json.loads(path.with_suffix(".json").read_text())
by_label = {p["label"]: p for p in meta["proposals"]}
assert by_label["K8"]["verdict"] == "accepted"
assert by_label["K8"]["confirmed_type"] == "TANK"
assert by_label["K9"]["verdict"] == "rejected"
assert by_label["L1"]["verdict"] == "undecided"
def test_marker_ground_truth_records_units_with_no_matching_proposal(_isolated_debug_dir):
from fenigma.models import Board, Coord, TargetType
board = Board()
manual_target = board.add_target(TargetType.TANK, Coord("K", 8, 3, 4))
manual_ally = board.add_ally(TargetType.INFANTRY, Coord("K", 9, 0, 0))
path = debug_capture.save_marker_ground_truth(
_tiny_png_bytes(), [], added_targets=[manual_target], added_allies=[manual_ally])
assert path is not None
meta = json.loads(path.with_suffix(".json").read_text())
kinds = {(u["kind"], u["type"], u["coord"]) for u in meta["added_units"]}
assert ("target", "TANK", "K8 3:4") in kinds
assert ("ally", "INFANTRY", "K9 0:0") in kinds
def test_marker_ground_truth_skips_when_nothing_to_say(_isolated_debug_dir):
assert debug_capture.save_marker_ground_truth(_tiny_png_bytes(), []) is None

79
tests/test_models.py Normal file
View File

@ -0,0 +1,79 @@
"""Regression coverage for Board's bulk-mutation helpers (clear/clear_units)
and the id namespaces Target/Ally are supposed to keep separate."""
from fenigma.models import Board, Coord, TargetType
def _coord(x=0, y=0):
return Coord(X="A", Y=1, x=x, y=y)
def test_clear_drops_allies_too():
"""Board.clear() used to leave self.allies untouched -- the "clear
board" action then reported success but a previously-placed ally
stayed on the map."""
board = Board()
board.nest.coord = _coord()
board.add_spotter(_coord())
board.add_reference_point(_coord())
board.add_target(TargetType.TANK, _coord())
board.add_ally(TargetType.TANK, _coord())
board.add_scout_flight((1.0, 1.0), 45.0)
board.clear()
assert board.nest.coord is None
assert board.spotters == []
assert board.reference_points == []
assert board.targets == []
assert board.allies == []
assert board.scout_flights == []
def test_clear_units_keeps_recon_infrastructure():
"""The Clear button's right-click "Clear enemies, units & flights"
option: drops targets/allies/scout flights but keeps the Nest,
spotters, and reference points."""
board = Board()
board.nest.coord = _coord()
sp = board.add_spotter(_coord())
rp = board.add_reference_point(_coord())
board.add_target(TargetType.TANK, _coord())
board.add_ally(TargetType.TANK, _coord())
board.add_scout_flight((1.0, 1.0), 45.0)
board.clear_units()
assert board.nest.coord is not None
assert board.spotters == [sp]
assert board.reference_points == [rp]
assert board.targets == []
assert board.allies == []
assert board.scout_flights == []
def test_ally_and_target_ids_are_independent_namespaces():
"""An ally Tank#1 and a hostile Target Tank#1 are unrelated -- adding
one must never be influenced by the other's ids, and auto-assignment
on each side starts from 'A' independently."""
board = Board()
t1 = board.add_target(TargetType.TANK, _coord(), id_="1")
a1 = board.add_ally(TargetType.TANK, _coord(), id_="1")
assert t1.id == a1.id == "1"
assert t1 is not a1
t_auto = board.add_target(TargetType.TANK, _coord())
a_auto = board.add_ally(TargetType.TANK, _coord())
assert t_auto.id == "A" # first free letter among *targets* only
assert a_auto.id == "A" # first free letter among *allies* only, unaffected by the target above
def test_find_by_name_prefers_target_over_same_named_ally():
"""find_by_name() (used to resolve Clue references) checks targets
before allies -- documented, deliberate priority, not a namespace
collision: an ally and a same-typed/same-id target are still two
distinct objects, this only matters when something's Clue names one
ambiguously by the shared display name."""
board = Board()
target = board.add_target(TargetType.TANK, _coord(x=1), id_="1")
board.add_ally(TargetType.TANK, _coord(x=2), id_="1")
assert board.find_by_name("Tank#1") is target

View File

@ -10,6 +10,7 @@ being noticed (or not) days later.
""" """
from fenigma import ocr from fenigma import ocr
from fenigma.models import Coord, TargetType from fenigma.models import Coord, TargetType
from fenigma.shells import Shell
def test_standard_target_and_rp_blocks(): def test_standard_target_and_rp_blocks():
@ -236,3 +237,54 @@ Tank#3 Spotted. 095, 3.00km from Spotter#1
assert (TargetType.TANK, "1") not in info.targets assert (TargetType.TANK, "1") not in info.targets
assert (TargetType.TANK, "2") in info.targets assert (TargetType.TANK, "2") in info.targets
assert (TargetType.TANK, "3") in info.targets assert (TargetType.TANK, "3") in info.targets
def test_infantry_taking_fire_direct_position_request():
"""A different fire-support-request grammar from Marine Garrison's:
shell word order reversed ('Requesting X Shell' not 'X Shells
requested'), deadline is a bare 'before <time>' with no 'Requested'/
dashes. The '<b>id1</b>' attacker mention is just prose here, not
parsed into anything -- only the request itself (shell, position,
deadline) matters."""
text = ("Infantry#1 taking fire from <b>id1</b>!\n"
"Requesting <u><b>SMK Shell</b></u> on our position at <b>J6 2:7</b> "
"before <u>10:38:57</u>!")
info = ocr.parse_text(text)
assert (TargetType.INFANTRY, "1") in info.targets
raw, clues, coord, shell, requested_time = info.targets[(TargetType.INFANTRY, "1")]
assert coord == Coord("J", 6, 2, 7)
assert shell is Shell.SMK
assert requested_time == "10:38:57"
def test_infantry_taking_fire_no_attacker_mention():
text = ("Infantry#3 taking fire!\n"
"Requesting <u><b>SMK Shell</b></u> on our position at <b>J6 2:5</b> "
"before <u>10:37:52</u>!")
info = ocr.parse_text(text)
assert (TargetType.INFANTRY, "3") in info.targets
raw, clues, coord, shell, requested_time = info.targets[(TargetType.INFANTRY, "3")]
assert coord == Coord("J", 6, 2, 5)
assert shell is Shell.SMK
assert requested_time == "10:37:52"
def test_infantry_taking_fire_bearing_distance_from_position():
"""The other request shape: the shell isn't wanted right on top of the
reporting unit, but at a bearing/distance offset from its own
(inline-given) position -- 'our position' isn't a named board entity
to hang a Clue off of, so this resolves straight to an absolute
coord."""
text = ("Infantry#3 taking fire!\n"
"Requesting <u><b>HE Shell</b></u> at bearing <b>239°</b>, distance "
"<b>10.76km</b> from our position, <b>J6 2:5</b>, by <u>10:38:18</u> "
"or we will be overrun!")
info = ocr.parse_text(text)
assert (TargetType.INFANTRY, "3") in info.targets
raw, clues, coord, shell, requested_time = info.targets[(TargetType.INFANTRY, "3")]
assert shell is Shell.HE
assert requested_time == "10:38:18"
from fenigma import solver
expected = solver.point_to_coord(
solver.point_from_bearing_distance(Coord("J", 6, 2, 5).as_fraction(), 239.0, 10.76))
assert coord == expected