Fix ally/target bugs, OCR fire-support parsing, add debug capture #1
@ -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
114
TODO.md
Normal 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.
|
||||
@ -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()
|
||||
|
||||
152
src/fenigma/debug_capture.py
Normal file
152
src/fenigma/debug_capture.py
Normal 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),
|
||||
})
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
100
tests/test_debug_capture.py
Normal 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
79
tests/test_models.py
Normal 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
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user