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

Merged
dodox merged 1 commits from fix/bug-backlog-2026-08-11 into master 2026-08-11 17:36:21 +02:00
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
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
### 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 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 .firing_panel import FiringPanel # 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])
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:
"""map_vision reports "K8" plus sub-cell 0..9 in each axis, matching
Coord's own convention (see GridSolution.lattice_to_grid)."""
@ -282,8 +293,13 @@ class MainWindow(Adw.ApplicationWindow):
header.pack_start(load_btn)
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_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)
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
self.toast(f"OCR failed: {exc}")
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:
"""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.
if error != map_import.NOT_A_MAP:
self.toast(f"Couldn't read the grid ({error}), trying as text.")
debug_capture.save_map_read_failure(png, error)
not_a_map()
return
self._on_map_import_ready(result)
@ -594,8 +621,27 @@ class MainWindow(Adw.ApplicationWindow):
).present(self)
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
# 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
imp.build_overlay()
self.canvas.set_screenshot(imp.overlay, imp.px_per_km)
@ -649,6 +695,7 @@ class MainWindow(Adw.ApplicationWindow):
else:
self.board.add_target(type_, coord)
proposal.accepted = True
proposal.confirmed_type = type_.name
def _accept_all_proposals(self) -> None:
imp = self.screenshot_import
@ -661,6 +708,21 @@ class MainWindow(Adw.ApplicationWindow):
self._refresh_proposals()
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:
"""Dropping the screenshot also drops every proposal never accepted:
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
if imp is None:
return
self._capture_screenshot_ground_truth(imp)
dropped = len(imp.pending())
imp.drop_unaccepted()
self.screenshot_import = None
@ -764,16 +827,80 @@ class MainWindow(Adw.ApplicationWindow):
if self._clipboard_watch_handler is not None:
Gdk.Display.get_default().get_clipboard().disconnect(self._clipboard_watch_handler)
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:
board = self.board
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
dialog = Adw.AlertDialog(
heading="Clear board?",
body="Drops the Nest position and every spotter, reference point, target, and scout flight. "
"This can't be undone.",
body="Drops the Nest position and every spotter, reference point, target, ally, and scout "
"flight. This can't be undone.",
)
dialog.add_response("cancel", "Cancel")
dialog.add_response("clear", "Clear")
@ -791,7 +918,7 @@ class MainWindow(Adw.ApplicationWindow):
self._refresh()
self.toast("Board cleared.")
def _merge_all(self, info: "ocr.ParsedInfo") -> None:
def _merge_all(self, info: "ocr.ParsedInfo") -> list[str]:
changed = []
if info.nest_coord is not None:
self.board.nest.coord = info.nest_coord
@ -806,6 +933,7 @@ class MainWindow(Adw.ApplicationWindow):
else:
self.toast("Merged from screenshot: " + ", ".join(changed))
self._refresh()
return changed
def _merge_spotters(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]:
changed = []
@ -1369,10 +1497,11 @@ class MainWindow(Adw.ApplicationWindow):
def show_main():
box = page()
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))
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:
button(box, "Change ID", show_id)
button(box, "Change position (click the map)", change_position)
@ -1403,7 +1532,8 @@ class MainWindow(Adw.ApplicationWindow):
obj.type = t
self._refresh()
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():
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:
return
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:
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)
def _draw_blast_radius(self, cr, view) -> 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
"""Every Target's effective shell's blast radius, for whichever
ones are selected or pinned via the same "always show geo"
show_geo_desc toggle the bearing/distance overlay uses (not on
plain hover, unlike that overlay -- a blast radius circle
flickering in on every hover was judged too noisy, selection/
pinning is a deliberate choice). Uses the specific selected
candidate point if an ambiguous target is the selected one;
skipped per-target if there's no known point yet, or the shell's
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:
return
continue
radius_km = target.effective_shell.blast_radius_km
if radius_km is None:
return
continue
x, y = self._km_to_px(view, point.as_fraction())
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,
the only way to let the user eyeball a bad-but-close reading
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 = [
obj for obj in candidates
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)]
def _target_type_label(t: "TargetType", is_ally: bool) -> str:
"""Display text for a picker cell/tooltip. 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) --
def target_type_label(t: "TargetType", is_ally: bool) -> str:
"""Display text for a picker cell/tooltip, or any other UI spot that
would otherwise print obj.type.value directly (map popover headings,
"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,
where the very same generic/ad-hoc-named-unit case reads as 'Enemy'
is somehow a kind of Ally. Cosmetic only: the underlying TargetType
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:
return "Ally"
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:
"""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

View File

@ -41,6 +41,13 @@ class Proposal:
box: tuple
accepted: 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
def coord(self) -> str:
@ -59,6 +66,20 @@ class ScreenshotImport:
proposals: list = field(default_factory=list)
overlay: object = None # BGRA array in map space
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):
self.proposals = [

View File

@ -608,16 +608,30 @@ class Board:
# -- reset ----------------------------------------------------------
def clear(self) -> None:
"""Drop everything: Nest position, spotters, reference points,
targets, scout flights. Used by the "clear board" action for a
fresh start without restarting the app."""
targets, allies, scout flights. Used by the "clear board" action
for a fresh start without restarting the app."""
self.nest = Nest()
self.spotters.clear()
self.reference_points.clear()
self.targets.clear()
self.allies.clear()
self.scout_flights.clear()
self._spotter_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:
"""Manual drag-order: `self.targets`' list order is itself the
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
# 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
# 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
@ -659,10 +736,12 @@ def parse_intel_blocks(text: str) -> list[dict]:
current["clues"] = _parse_all_clues(joined)
current["coord"] = (
_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)
)
current["shell"] = _extract_shell_request(joined)
current["requested_time"] = _extract_requested_time(joined)
current["shell"] = _extract_shell_request(joined) or _extract_requesting_shell(joined)
current["requested_time"] = _extract_requested_time(joined) or _extract_taking_fire_time(joined)
if (current["clues"] or current["coord"] is not None
or current["shell"] is not None or current["requested_time"] is not None):
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.models import Coord, TargetType
from fenigma.shells import Shell
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, "2") 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