Fix id-namespace regression, add StrikeRequest type, kill full-panel
rerender on assign/alive/shell, add Windows build tooling - Board.add_target/add_ally's id auto-assignment used a bare next(c for c in string.ascii_uppercase if c not in used), which raises StopIteration once 26 entities of a group exist -- a real crash confirmed via a live traceback, and a direct regression from moving that sequence from per-type to per-group. This was the actual cause of "Accept as"/"Accept all" silently doing nothing. Fixed with _next_free_id(), which rolls over to two-letter ids instead of raising. - New TargetType.STRIKE_REQUEST: the bearing/distance-offset "taking fire" fire-support request (see the earlier two-entity split) now creates this instead of reusing STRIKE, so a radioed-in request is never confused with a strike the player placed themselves. Same crosshair icon, excluded from type pickers/dedupe like STRIKE. - The "Accept as..." popover on a detected map marker now uses the same icon grid the entity-edit "Change type" popover does (was a plain unfiltered text list of every TargetType, which also wrongly offered STRIKE/STRIKE_REQUEST as pickable). - Firing panel: _cycle_assignment/_toggle_alive/_pick_shell no longer route through app.py's full solver+dedupe+canvas+panel refresh -- none of the three can affect the solver or dedupe, and none change which cards exist or their order (except _toggle_alive in hide/sort_later mode). New FiringPanel._rebuild_one() rebuilds just the one changed card; on_visual_change is a new, lighter callback (just a map redraw) for the two of these three that actually affect it. This was a real, confirmed lag source with many units on the board: every click on any of these was previously rebuilding every card of every target. - Map right-click entity menu: added "Mark destroyed"/"Mark alive", reusing the same cheap-refresh path (new FiringPanel.refresh_after_alive_change). - packaging/windows/: a from-scratch (untested against a real boot) MSYS2 + WiX .msi build pipeline for Windows, driven from Linux via dockur/windows (KVM-in-container), no Windows machine or GitHub required. See its own README for status/caveats. - New/updated tests: id-namespace sharing + the 26-entity overflow regression (tests/test_models.py), StrikeRequest split (tests/test_ocr.py), warp_to_map's img_scale param (tests/test_map_vision_warp.py). 44/44 passing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+45
-4
@@ -383,6 +383,7 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
self.firing_panel = FiringPanel(
|
||||
self.board,
|
||||
on_change=self._refresh,
|
||||
on_visual_change=self.canvas.refresh,
|
||||
on_select=self._set_selection,
|
||||
on_edit_position=self._edit_target_position,
|
||||
on_set_position=self._start_target_placement,
|
||||
@@ -785,13 +786,21 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
|
||||
def show_type():
|
||||
box = page()
|
||||
# Same icon grid the entity-edit "Change type" popover uses
|
||||
# (see _open_entity_menu's own show_type below), not a plain
|
||||
# text list -- also gets that grid's filtering for free
|
||||
# (icons.available_target_types), which a bare `for t in
|
||||
# TargetType` here didn't have: STRIKE/STRIKE_REQUEST aren't
|
||||
# real pickable unit types (see their own comments in
|
||||
# models.py) and shouldn't have been offered as "what this
|
||||
# detected marker actually is".
|
||||
scroller = Gtk.ScrolledWindow(propagate_natural_height=True,
|
||||
propagate_natural_width=True,
|
||||
max_content_height=340,
|
||||
hscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||
inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
||||
for t in TargetType:
|
||||
button(inner, t.value, lambda t=t: accept(t))
|
||||
scroller.set_child(inner)
|
||||
scroller.set_child(icons.build_target_type_grid(
|
||||
detected, lambda t: accept(t), is_ally=(proposal.side == "friendly"),
|
||||
))
|
||||
box.append(scroller)
|
||||
popover.set_child(box)
|
||||
|
||||
@@ -1419,6 +1428,7 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
def _add_strike_at(self, coord, shell: Shell) -> None:
|
||||
target = self.board.add_target(TargetType.STRIKE, coord)
|
||||
target.shell = shell
|
||||
target.show_geo_desc = True # a strike's whole point is its blast radius; show it without needing a click
|
||||
self.board.reorder_target(target, 0) # new strikes go to the front of the list
|
||||
self._refresh()
|
||||
|
||||
@@ -1505,6 +1515,12 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
if self._id_field_of(obj) is not None:
|
||||
button(box, "Change ID", show_id)
|
||||
button(box, "Change position (click the map)", change_position)
|
||||
if isinstance(obj, Target):
|
||||
# Alive/dead is Target-only (see models.py's Target.alive),
|
||||
# same "Mark destroyed"/"Mark alive" toggle the firing
|
||||
# panel's own alive button offers, just reachable from the
|
||||
# map too rather than only from the sidebar.
|
||||
button(box, "Mark destroyed" if obj.alive else "Mark alive", toggle_alive)
|
||||
if not isinstance(obj, Nest):
|
||||
button(box, "Delete", delete, css="destructive-action")
|
||||
popover.set_child(box)
|
||||
@@ -1563,6 +1579,19 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
if any(s is not obj and s.id == value for s in self.board.spotters):
|
||||
self.toast(f"Spotter#{value} already exists.")
|
||||
return
|
||||
elif field == "id" and isinstance(obj, (Target, Ally)):
|
||||
# Same invariant as Board.add_target/add_ally's auto-id
|
||||
# (see their comments): one shared id namespace per group,
|
||||
# targets vs allies, never split further by type. A manual
|
||||
# rename has to keep that too, or you get two entities
|
||||
# that both read as e.g. "...#A" with only the type prefix
|
||||
# telling them apart.
|
||||
value = text
|
||||
siblings = self.board.targets if isinstance(obj, Target) else self.board.allies
|
||||
if any(o is not obj and o.id == value for o in siblings):
|
||||
kind = "target" if isinstance(obj, Target) else "ally"
|
||||
self.toast(f"Another {kind} already has id {value!r}.")
|
||||
return
|
||||
else:
|
||||
value = text
|
||||
old = obj.name
|
||||
@@ -1580,6 +1609,17 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
lambda c: self._apply_and_refresh(obj, Location.from_coord(c)))
|
||||
self.toast(f"Click the map to place {obj.name}, Esc to cancel.")
|
||||
|
||||
def toggle_alive():
|
||||
popover.popdown()
|
||||
obj.alive = not obj.alive
|
||||
# NOT self._refresh(): same reasoning as firing_panel.py's own
|
||||
# alive toggle (see refresh_after_alive_change) -- this can
|
||||
# never affect the solver or dedupe, doesn't need that full
|
||||
# pipeline just because it's triggered from the map instead of
|
||||
# the sidebar.
|
||||
self.firing_panel.refresh_after_alive_change(obj)
|
||||
self.toast(f"{_display_name(obj)} marked {'alive' if obj.alive else 'destroyed'}.")
|
||||
|
||||
def delete():
|
||||
popover.popdown()
|
||||
name = obj.name
|
||||
@@ -1708,6 +1748,7 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
|
||||
def add_strike():
|
||||
target = self.board.add_target(TargetType.STRIKE, coord)
|
||||
target.show_geo_desc = True # a strike's whole point is its blast radius; show it without needing a click
|
||||
self.board.reorder_target(target, 0) # new strikes go to the front of the list
|
||||
self._refresh()
|
||||
popover.popdown()
|
||||
|
||||
@@ -106,12 +106,28 @@ class FiringPanel(Gtk.Box):
|
||||
"""Right-hand sidebar content: sort/filter toolbar + scrollable cards."""
|
||||
|
||||
def __init__(
|
||||
self, board: Board, *, on_change, on_select, on_edit_position, on_set_position, on_remove,
|
||||
on_toggle_hide_dead_map,
|
||||
self, board: Board, *, on_change, on_visual_change, on_select, on_edit_position, on_set_position,
|
||||
on_remove, on_toggle_hide_dead_map,
|
||||
) -> None:
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.board = board
|
||||
self.on_change = on_change
|
||||
# app.py's full pipeline (solver + dedupe + redraw + THIS panel's
|
||||
# own full rebuild) -- for mutations that actually need it (a
|
||||
# position/clue changed, a target was added/removed/reordered).
|
||||
# Assignment/alive/shell changes don't: nothing about them can
|
||||
# ever be produced by the solver or change dedupe's outcome, they
|
||||
# just need the MAP redrawn (assignment isn't drawn there at all;
|
||||
# alive dims a marker; shell can change a selected/pinned
|
||||
# target's blast-radius circle). on_visual_change is that lighter
|
||||
# path -- just a map redraw, no solver/dedupe/panel-rebuild -- see
|
||||
# _cycle_assignment/_toggle_alive/_pick_shell, which pair it with
|
||||
# _rebuild_one() for this panel's own (single-card, not
|
||||
# whole-board) update. Was a real, measured lag source: every one
|
||||
# of those three going through on_change() meant every single
|
||||
# click rebuilt every card of every target on the board, not just
|
||||
# the one that changed.
|
||||
self.on_visual_change = on_visual_change
|
||||
self.on_select = on_select
|
||||
self.on_edit_position = on_edit_position
|
||||
self.on_set_position = on_set_position
|
||||
@@ -135,14 +151,14 @@ class FiringPanel(Gtk.Box):
|
||||
self._list_box.set_margin_bottom(10)
|
||||
self._list_box.set_margin_start(10)
|
||||
self._list_box.set_margin_end(10)
|
||||
scroller = Gtk.ScrolledWindow(child=self._list_box, vexpand=True)
|
||||
self._scroller = Gtk.ScrolledWindow(child=self._list_box, vexpand=True)
|
||||
# Horizontal scrolling is never wanted here (fixed-width sidebar),
|
||||
# leaving it on AUTOMATIC (the default) lets a vertical scrollbar's
|
||||
# own width shrink the content area enough to trigger a horizontal
|
||||
# one too, which then perturbs card heights and can trip vertical
|
||||
# scrolling that wasn't actually needed. Pin it off outright.
|
||||
scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||||
self.append(scroller)
|
||||
self._scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||||
self.append(self._scroller)
|
||||
|
||||
self.refresh()
|
||||
|
||||
@@ -195,6 +211,33 @@ class FiringPanel(Gtk.Box):
|
||||
self._restyle(self.selected, self.selected_point, _SELECTED_CSS, False)
|
||||
self.selected, self.selected_point = target, point
|
||||
self._restyle(self.selected, self.selected_point, _SELECTED_CSS, True)
|
||||
if target is not None:
|
||||
self._scroll_into_view(target, point)
|
||||
|
||||
def _scroll_into_view(self, target, point) -> None:
|
||||
"""Selecting a target on the map (or cycling selection some other
|
||||
way) should bring its card on-screen if the sidebar's scrolled
|
||||
past it -- otherwise "selected" is invisible state the map alone
|
||||
shows, and the firing panel this is FOR doesn't actually show what
|
||||
got picked. A no-op if the card's already fully visible, this
|
||||
only nudges the scroll position the minimum needed, never
|
||||
recentres unnecessarily."""
|
||||
card = next(
|
||||
(c for c, p in self._cards_by_target.get(target, []) if point is None or p == point),
|
||||
None,
|
||||
)
|
||||
if card is None:
|
||||
return
|
||||
ok, bounds = card.compute_bounds(self._list_box)
|
||||
if not ok:
|
||||
return # not laid out yet (e.g. called right after a rebuild); skip rather than guess
|
||||
vadj = self._scroller.get_vadjustment()
|
||||
top, bottom = bounds.get_y(), bounds.get_y() + bounds.get_height()
|
||||
view_top, view_bottom = vadj.get_value(), vadj.get_value() + vadj.get_page_size()
|
||||
if top < view_top:
|
||||
vadj.set_value(top)
|
||||
elif bottom > view_bottom:
|
||||
vadj.set_value(bottom - vadj.get_page_size())
|
||||
|
||||
def set_hovered(self, target, point=None) -> None:
|
||||
if target is self.hovered and point == self.hovered_point:
|
||||
@@ -203,6 +246,28 @@ class FiringPanel(Gtk.Box):
|
||||
self.hovered, self.hovered_point = target, point
|
||||
self._restyle(self.hovered, self.hovered_point, _HOVERED_CSS, True)
|
||||
|
||||
def _rebuild_one(self, target: Target) -> None:
|
||||
"""Rebuild just `target`'s own card(s) in place -- O(1) in the
|
||||
number of OTHER targets on the board, unlike refresh() (which
|
||||
tears down and rebuilds every card) -- for a mutation that only
|
||||
changes this target's own display and can never add/remove a
|
||||
card or move anything in the sort order (see
|
||||
_cycle_assignment/_pick_shell; _toggle_alive uses this only when
|
||||
that's also true for it, falling back to refresh() otherwise).
|
||||
"""
|
||||
old_cards = self._cards_by_target.get(target)
|
||||
if not old_cards:
|
||||
return # not currently shown (e.g. filtered out) -- nothing to update
|
||||
new_cards = self._build_cards(target)
|
||||
for (old_widget, _old_point), (new_widget, new_point) in zip(old_cards, new_cards):
|
||||
self._list_box.insert_child_after(new_widget, old_widget)
|
||||
self._list_box.remove(old_widget)
|
||||
if target is self.selected and (self.selected_point is None or new_point == self.selected_point):
|
||||
new_widget.add_css_class(_SELECTED_CSS)
|
||||
if target is self.hovered and (self.hovered_point is None or new_point == self.hovered_point):
|
||||
new_widget.add_css_class(_HOVERED_CSS)
|
||||
self._cards_by_target[target] = new_cards
|
||||
|
||||
def _restyle(self, target, point, css_class: str, add: bool) -> None:
|
||||
"""point=None means "the whole target" (every one of its cards);
|
||||
otherwise only the card for that specific ambiguous candidate,
|
||||
@@ -505,14 +570,39 @@ class FiringPanel(Gtk.Box):
|
||||
return row
|
||||
|
||||
def _cycle_assignment(self, target: Target) -> None:
|
||||
# Assignment (L/R/unassigned) isn't drawn on the map at all, so
|
||||
# this doesn't even need on_visual_change, just the card itself.
|
||||
idx = _ASSIGNMENT_STATES.index(target.assignment)
|
||||
target.assignment = _ASSIGNMENT_STATES[(idx + 1) % len(_ASSIGNMENT_STATES)]
|
||||
self.on_change()
|
||||
self._rebuild_one(target)
|
||||
|
||||
def _toggle_alive(self, target: Target) -> None:
|
||||
target.alive = not target.alive
|
||||
self.on_change()
|
||||
self.refresh_after_alive_change(target)
|
||||
|
||||
def refresh_after_alive_change(self, target: Target) -> None:
|
||||
"""The display-only aftermath of target.alive flipping, split out
|
||||
from _toggle_alive so app.py's map-popover "Mark destroyed"/"Mark
|
||||
alive" (which flips target.alive itself, reaching this same
|
||||
target) can reuse the same cheap-when-possible logic rather than
|
||||
going through on_change()'s full solver+dedupe+canvas+panel pass
|
||||
again -- exactly the rebuild this class exists to avoid paying
|
||||
for a change that was never going to affect the solver or dedupe.
|
||||
|
||||
A card's presence/position can depend on alive (show_dead "hide"
|
||||
drops dead cards entirely, "sort_later" moves them to their own
|
||||
group at the bottom) -- only "show" guarantees this card stays
|
||||
exactly where it is, just dimmed, so only that mode gets the
|
||||
cheap single-card path; the other two need this panel's own full
|
||||
rebuild (still far cheaper than on_change()'s, since it skips
|
||||
everything but the last step)."""
|
||||
if self.show_dead == "show":
|
||||
self._rebuild_one(target)
|
||||
else:
|
||||
self.refresh()
|
||||
self.on_visual_change() # dead dimming / hide_dead_from_map affects the map too
|
||||
|
||||
def _pick_shell(self, target: Target, shell: Shell) -> None:
|
||||
target.shell = shell
|
||||
self.on_change()
|
||||
self._rebuild_one(target)
|
||||
self.on_visual_change() # a selected/pinned target's blast-radius circle depends on its shell
|
||||
|
||||
+19
-14
@@ -101,9 +101,9 @@ _TARGET_ICON = {
|
||||
TargetType.RECON: ("Recon.png", "Reconnaissance.png"), # name differs
|
||||
TargetType.RECON_LISTENING: ("Recon_Listening.png", "Recon_Listening.png"),
|
||||
}
|
||||
assert {*_TARGET_ICON} | {TargetType.STRIKE} == {*TargetType}, (
|
||||
"every TargetType needs a row in _TARGET_ICON (STRIKE is the one "
|
||||
"deliberate exception, see the comment above it)"
|
||||
assert {*_TARGET_ICON} | {TargetType.STRIKE, TargetType.STRIKE_REQUEST} == {*TargetType}, (
|
||||
"every TargetType needs a row in _TARGET_ICON (STRIKE/STRIKE_REQUEST "
|
||||
"are the deliberate exceptions, see the comment above target_icon_path)"
|
||||
)
|
||||
|
||||
|
||||
@@ -150,10 +150,11 @@ def target_icon_path(target_type: TargetType, is_ally: bool = False) -> Path | N
|
||||
good one. `is_ally` picks the friendly side of _TARGET_ICON over the
|
||||
enemy one, falling back to the enemy icon if this particular type has
|
||||
no friendly art of its own at all (the two sets aren't the same size,
|
||||
see assets/icons/README.md). STRIKE (a planned impact point, not a
|
||||
unit) gets its own crosshair rather than a unit icon, it doesn't fit
|
||||
the Enemy_/Friendly_ naming scheme at all."""
|
||||
if target_type is TargetType.STRIKE:
|
||||
see assets/icons/README.md). STRIKE/STRIKE_REQUEST (a planned impact
|
||||
point, not a unit -- player-placed vs called in by a friendly, see
|
||||
STRIKE_REQUEST's own comment) both get the same crosshair rather than
|
||||
a unit icon, neither fits the Enemy_/Friendly_ naming scheme at all."""
|
||||
if target_type in (TargetType.STRIKE, TargetType.STRIKE_REQUEST):
|
||||
return STRIKE_ICON_PATH
|
||||
own = _icon_for_side(target_type, is_ally)
|
||||
if own is not None:
|
||||
@@ -467,19 +468,23 @@ def _has_own_icon(t: "TargetType", is_ally: bool) -> bool:
|
||||
def available_target_types(is_ally: bool = False):
|
||||
"""TargetType members worth offering in a picker for this side.
|
||||
|
||||
STRIKE is never offered: it's not a unit type at all (a planned
|
||||
impact point, not a contact), it's always created through its own
|
||||
dedicated "Add strike" action (see app.py's _open_quick_add_menu),
|
||||
never by picking a type from this generic grid -- there's no such
|
||||
thing as a Strike-typed Ally either, offering it there is just
|
||||
confusing, not merely unlikely.
|
||||
STRIKE/STRIKE_REQUEST are never offered: neither is a unit type at
|
||||
all (a planned impact point, not a contact), each is always created
|
||||
through its own path instead -- STRIKE via app.py's dedicated "Add
|
||||
strike" action, STRIKE_REQUEST via ocr.py parsing a fire-support
|
||||
request -- never by picking a type from this generic grid. There's
|
||||
no such thing as a Strike-typed Ally either, offering either one
|
||||
here is just confusing, not merely unlikely.
|
||||
|
||||
Otherwise: each side only offers types it actually has its own art
|
||||
for (see _has_own_icon / _TARGET_ICON) -- some types are enemy-only
|
||||
and some are friendly-only (King, Police, a friendly hospital, ...),
|
||||
the game simply doesn't draw an installation of every kind on both
|
||||
sides."""
|
||||
return [t for t in TargetType if t is not TargetType.STRIKE and _has_own_icon(t, is_ally)]
|
||||
return [
|
||||
t for t in TargetType
|
||||
if t not in (TargetType.STRIKE, TargetType.STRIKE_REQUEST) and _has_own_icon(t, is_ally)
|
||||
]
|
||||
|
||||
|
||||
def target_type_label(t: "TargetType", is_ally: bool) -> str:
|
||||
|
||||
@@ -66,6 +66,16 @@ class ScreenshotImport:
|
||||
proposals: list = field(default_factory=list)
|
||||
overlay: object = None # BGRA array in map space
|
||||
px_per_km: int = 0
|
||||
# The same screenshot at full resolution, plus its width / `image`'s
|
||||
# width -- `image` is downscaled to WORK_W for solving/marker-detection
|
||||
# speed (see map_vision.WORK_W), which is plenty for those but throws
|
||||
# away real detail the map overlay doesn't need to give up too (a
|
||||
# screenshot can be up to 6880px wide, see map_vision.load_full_res's
|
||||
# docstring). None/1.0 (rather than always loading it) because it's
|
||||
# only needed for build_overlay(), and app.py sets it right after
|
||||
# solving, before build_overlay() is ever called.
|
||||
full_image: object = None
|
||||
full_image_scale: float = 1.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
|
||||
@@ -88,10 +98,14 @@ class ScreenshotImport:
|
||||
centre=m["centre"], box=m["box"]) for m in markers]
|
||||
return self.proposals
|
||||
|
||||
def build_overlay(self, px_per_km=100):
|
||||
"""Rectify the screenshot into map space, ready to draw under the grid."""
|
||||
def build_overlay(self, px_per_km=150):
|
||||
"""Rectify the screenshot into map space, ready to draw under the grid.
|
||||
Uses full_image (full resolution) over image (WORK_W-downscaled) when
|
||||
available, see full_image's own docstring."""
|
||||
src, scale = (self.full_image, self.full_image_scale) if self.full_image is not None \
|
||||
else (self.image, 1.0)
|
||||
self.overlay, self.px_per_km = map_vision.warp_to_map(
|
||||
self.image, self.solution, px_per_km=px_per_km)
|
||||
src, self.solution, px_per_km=px_per_km, img_scale=scale)
|
||||
return self.overlay
|
||||
|
||||
def accept_all(self):
|
||||
@@ -166,7 +180,18 @@ class ImportJob:
|
||||
sol, img, err = map_vision.solve_path(path)
|
||||
if sol is None:
|
||||
return None, err
|
||||
return ScreenshotImport(solution=sol, image=img), None
|
||||
imp = ScreenshotImport(solution=sol, image=img)
|
||||
# Best-effort: a sharper source for build_overlay() than the
|
||||
# WORK_W-downscaled `img` solving used (see full_image's own
|
||||
# docstring). Anything going wrong here just means the overlay
|
||||
# falls back to `img`, not worth failing the whole import over.
|
||||
try:
|
||||
full = map_vision.load_full_res(path)
|
||||
imp.full_image = full
|
||||
imp.full_image_scale = full.shape[1] / img.shape[1]
|
||||
except (ValueError, ZeroDivisionError, OSError):
|
||||
pass
|
||||
return imp, None
|
||||
|
||||
return self._run(work, on_done, "map-import")
|
||||
|
||||
|
||||
@@ -82,6 +82,22 @@ def load(path, work_w=None) -> np.ndarray:
|
||||
return downscale(img, work_w)
|
||||
|
||||
|
||||
def load_full_res(path) -> np.ndarray:
|
||||
"""Same read as load(), but never downscaled -- solving and marker
|
||||
detection deliberately work at WORK_W (a screenshot's real resolution
|
||||
only matters up to what a grid label needs to stay legible, see
|
||||
solve_path's own docstring), but that same downscaled image is a poor
|
||||
source for the map overlay the app draws the screenshot as: a
|
||||
screenshot wider than WORK_W (the docstring above notes these run
|
||||
700..6880px) was throwing away real detail there for no benefit. See
|
||||
warp_to_map's img_scale param, which is how a caller tells it "this
|
||||
image isn't the one `sol` was solved against, here's the size ratio"."""
|
||||
img = cv2.imread(str(path), cv2.IMREAD_COLOR)
|
||||
if img is None:
|
||||
raise ValueError(f"cannot read image: {path}")
|
||||
return img
|
||||
|
||||
|
||||
def downscale(img, work_w=None) -> np.ndarray:
|
||||
h, w = img.shape[:2]
|
||||
s = min(1.0, (work_w or WORK_W) / w)
|
||||
@@ -649,10 +665,19 @@ def centre_cell_quad(sol, shape):
|
||||
MAP_KM_W, MAP_KM_H = 20.0, 10.0
|
||||
|
||||
|
||||
def warp_to_map(img, sol, px_per_km=100):
|
||||
def warp_to_map(img, sol, px_per_km=150, img_scale=1.0):
|
||||
"""Rectify a screenshot into map space, ready to composite under the app's
|
||||
own grid.
|
||||
|
||||
`img` need not be the exact image `sol` was solved against (usually a
|
||||
WORK_W-downscaled one, see solve_path) -- pass the original full-
|
||||
resolution screenshot instead (see load_full_res) for a sharper overlay,
|
||||
with `img_scale` set to img's width / the solved image's width, so this
|
||||
can still map `sol`'s coordinates (which are in the SOLVED image's pixel
|
||||
space) onto `img`'s actual pixels. img_scale=1.0 (the default) means
|
||||
`img` IS the image `sol` was solved against, same as before this param
|
||||
existed.
|
||||
|
||||
Returns (BGRA array, px_per_km). Only the region the screenshot actually
|
||||
covers is opaque; everything else is transparent, so a partial view of the
|
||||
table does not blank out the rest of the map.
|
||||
@@ -673,6 +698,15 @@ def warp_to_map(img, sol, px_per_km=100):
|
||||
# du, dv) is what pins those to named cells, and leaving it out put the
|
||||
# screenshot in the wrong place for every automatically solved grid.
|
||||
M = grid_to_map @ sol.lattice_to_grid() @ np.linalg.inv(sol.H)
|
||||
if img_scale != 1.0:
|
||||
# img's pixels are img_scale times bigger than what M expects
|
||||
# (the solved image's pixel space) -- shrink img-space coordinates
|
||||
# down to that space first, applied first since matrices compose
|
||||
# right-to-left.
|
||||
to_solved_px = np.array([[1.0 / img_scale, 0.0, 0.0],
|
||||
[0.0, 1.0 / img_scale, 0.0],
|
||||
[0.0, 0.0, 1.0]])
|
||||
M = M @ to_solved_px
|
||||
bgra = cv2.cvtColor(img, cv2.COLOR_BGR2BGRA)
|
||||
bgra[:, :, 3] = 255
|
||||
return cv2.warpPerspective(bgra, M, (out_w, out_h), flags=cv2.INTER_LINEAR,
|
||||
|
||||
+48
-7
@@ -18,6 +18,7 @@ Coord) to work out everything else. This module just defines the shape.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import string
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
@@ -56,7 +57,18 @@ class TargetType(Enum):
|
||||
# ("Enemy Signal Station", "Enemy Field Command"), not one of the
|
||||
# game's fixed unit types, its id is the rest of that name with
|
||||
# spaces stripped, see ocr.py's squash_enemy_names()
|
||||
STRIKE = "Strike" # a planned impact point, not an enemy contact
|
||||
STRIKE = "Strike" # a planned impact point, not an enemy contact --
|
||||
# player-placed only (app.py's dedicated "Add Strike" flow / map
|
||||
# right-click), never produced by OCR.
|
||||
STRIKE_REQUEST = "Strike Request" # a planned impact point a friendly
|
||||
# unit is calling in over the radio (ocr.py's "taking fire" fire-
|
||||
# support-request grammar, when it names a bearing/distance offset
|
||||
# from the reporter rather than the reporter's own position), as
|
||||
# opposed to STRIKE, which the player places themselves. Same
|
||||
# "not an enemy contact, just an impact point" shape as STRIKE
|
||||
# (dedupe_generic_targets/icons.py both treat the two the same way),
|
||||
# kept as its own type rather than reusing STRIKE so a request that
|
||||
# came in over the radio is never confused for one the player chose.
|
||||
|
||||
# -- Ground combat units -------------------------------------------
|
||||
ANTI_AIR = "Anti-Air"
|
||||
@@ -487,6 +499,29 @@ class ScoutFlight:
|
||||
return f"ScoutFlight#{self.id}"
|
||||
|
||||
|
||||
def _next_free_id(used: set[str]) -> str:
|
||||
"""Next unused id in a short, human-friendly sequence: single
|
||||
uppercase letters (A..Z) first, then two-letter combinations
|
||||
(AA..ZZ, spreadsheet-column style) once those run out, and so on.
|
||||
|
||||
A real regression lived here: `next(c for c in string.ascii_uppercase
|
||||
if c not in used)` raises StopIteration the instant all 26 letters
|
||||
are taken, which used to need 26+ auto-added entities of one TYPE
|
||||
(rare) but, once add_target()/add_ally() moved to one shared id
|
||||
sequence per GROUP instead of per type (so a Tank and an Infantry
|
||||
added back to back get 'A'/'B', not both 'A', see their own
|
||||
comments), needs only 26 auto-added entities of ANY type in that
|
||||
group -- reachable in a single big screenshot import. This can't run
|
||||
out: it just grows the id length instead."""
|
||||
length = 1
|
||||
while True:
|
||||
for combo in itertools.product(string.ascii_uppercase, repeat=length):
|
||||
candidate = "".join(combo)
|
||||
if candidate not in used:
|
||||
return candidate
|
||||
length += 1
|
||||
|
||||
|
||||
SAVE_FORMAT_VERSION = 3
|
||||
|
||||
|
||||
@@ -557,9 +592,14 @@ class Board:
|
||||
location: Location | Coord | None = None,
|
||||
id_: str | None = None,
|
||||
) -> Target:
|
||||
# One shared A/B/C... sequence across every target regardless of
|
||||
# type, not one sequence per type -- a Tank and an Infantry auto-
|
||||
# assigned back to back get 'A' and 'B', never both 'A'. Only
|
||||
# targets-vs-allies is a separate id namespace (see add_ally),
|
||||
# type never subdivides it further.
|
||||
if not id_:
|
||||
used = {t.id for t in self.targets if t.type == type_}
|
||||
id_ = next(c for c in string.ascii_uppercase if c not in used)
|
||||
used = {t.id for t in self.targets}
|
||||
id_ = _next_free_id(used)
|
||||
t = Target(type=type_, id=id_, location=_as_location(location))
|
||||
self.targets.append(t)
|
||||
return t
|
||||
@@ -576,11 +616,12 @@ class Board:
|
||||
) -> Ally:
|
||||
# A separate id namespace from add_target()'s: an ally Tank#1
|
||||
# and a hostile Target Tank#1 are unrelated, so auto-assignment
|
||||
# here only looks at other allies of the same type, never
|
||||
# self.targets.
|
||||
# here only looks at other allies, never self.targets. Same as
|
||||
# add_target though, that's the ONLY split: one shared A/B/C...
|
||||
# sequence across every ally regardless of type, not one per type.
|
||||
if not id_:
|
||||
used = {a.id for a in self.allies if a.type == type_}
|
||||
id_ = next(c for c in string.ascii_uppercase if c not in used)
|
||||
used = {a.id for a in self.allies}
|
||||
id_ = _next_free_id(used)
|
||||
a = Ally(type=type_, id=id_, location=_as_location(location))
|
||||
self.allies.append(a)
|
||||
return a
|
||||
|
||||
+47
-3
@@ -274,6 +274,22 @@ def _extract_bearing_distance_from_position_coord(text: str) -> Coord | None:
|
||||
return solver.point_to_coord(point)
|
||||
|
||||
|
||||
def _extract_our_position_coord(text: str) -> Coord | None:
|
||||
"""The bearing/distance variant's OWN inline position ('...from our
|
||||
position, J6 2:5, by ...'), as opposed to
|
||||
_extract_bearing_distance_from_position_coord's computed offset from
|
||||
it. Reported unit and requested fire point are two different places
|
||||
for this variant (unlike the direct "on our position at <coord>" one,
|
||||
a real danger-close call), so parse_intel_blocks's flush() uses this
|
||||
for the reporting unit's own entry and the offset for a second,
|
||||
separate Strike entry -- see its comment."""
|
||||
m = _BEARING_DISTANCE_FROM_POSITION_RE.search(text)
|
||||
if not m:
|
||||
return None
|
||||
_bearing, _distance, letter, y, x, yy = m.groups()
|
||||
return _coord_from_groups(letter, y, x, yy)
|
||||
|
||||
|
||||
# "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
|
||||
@@ -734,18 +750,46 @@ def parse_intel_blocks(text: str) -> list[dict]:
|
||||
if current is not None:
|
||||
joined = "\n".join(current["raw"])
|
||||
current["clues"] = _parse_all_clues(joined)
|
||||
# The bearing/distance taking-fire variant names TWO different
|
||||
# places (see _extract_our_position_coord's docstring): the
|
||||
# reporting unit's own position, and a separate fire point
|
||||
# offset from it. Everything else in this module is "one block
|
||||
# -> one entry", so that offset gets split into a second,
|
||||
# synthetic StrikeRequest entry below rather than folded into
|
||||
# this one -- otherwise the fire point either overwrites the
|
||||
# unit's real position (wrong place) or gets silently dropped.
|
||||
offset_coord = _extract_bearing_distance_from_position_coord(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_our_position_coord(joined)
|
||||
or _extract_large_grid_only_coord(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)
|
||||
shell = _extract_shell_request(joined) or _extract_requesting_shell(joined)
|
||||
requested_time = _extract_requested_time(joined) or _extract_taking_fire_time(joined)
|
||||
# The offset variant's shell/deadline describe the FIRE POINT,
|
||||
# not the reporting unit itself -- they move to the synthetic
|
||||
# StrikeRequest entry below, not kept here too.
|
||||
current["shell"] = None if offset_coord is not None else shell
|
||||
current["requested_time"] = None if offset_coord is not None else requested_time
|
||||
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
|
||||
entries.append(current)
|
||||
if offset_coord is not None:
|
||||
# TargetType.STRIKE_REQUEST, not STRIKE: this is a
|
||||
# friendly unit calling in a strike over the radio, not
|
||||
# one the player placed themselves (see that type's own
|
||||
# comment in models.py). type_word must match its
|
||||
# TargetType.short exactly ("StrikeRequest", no space),
|
||||
# same as every other type_word this module produces.
|
||||
strike_id = f"{current['type_word']}{current['id']}"
|
||||
entries.append({
|
||||
"kind": "named", "name": f"StrikeRequest#{strike_id}",
|
||||
"type_word": "StrikeRequest",
|
||||
"id": strike_id, "raw": joined, "clues": [], "coord": offset_coord,
|
||||
"shell": shell, "requested_time": requested_time,
|
||||
})
|
||||
current = None
|
||||
|
||||
for raw_line in text.splitlines():
|
||||
|
||||
@@ -372,14 +372,16 @@ def dedupe_generic_targets(board: Board) -> list[str]:
|
||||
same* position as an already-known specific target, it's not a new
|
||||
contact, it's the same one being spotted, just described more
|
||||
precisely. Drop the redundant generic entry, keep the specific one.
|
||||
Strikes are our own planned impacts, not enemy contacts, and never
|
||||
participate. Run this after resolve_board(), since positions may
|
||||
only become comparable once resolved. Returns the names removed."""
|
||||
Strikes (player-placed or requested) are planned impacts, not enemy
|
||||
contacts, and never participate. Run this after resolve_board(),
|
||||
since positions may only become comparable once resolved. Returns
|
||||
the names removed."""
|
||||
removed: list[str] = []
|
||||
unknowns = [t for t in board.targets if t.type is TargetType.UNKNOWN and t.coord is not None]
|
||||
specifics = [
|
||||
t for t in board.targets
|
||||
if t.type not in (TargetType.UNKNOWN, TargetType.STRIKE) and t.coord is not None
|
||||
if t.type not in (TargetType.UNKNOWN, TargetType.STRIKE, TargetType.STRIKE_REQUEST)
|
||||
and t.coord is not None
|
||||
]
|
||||
for generic in unknowns:
|
||||
if any(generic.coord == specific.coord for specific in specifics):
|
||||
|
||||
Reference in New Issue
Block a user