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:
2026-08-11 20:48:57 +02:00
co-authored by Claude Sonnet 5
parent 49863b6045
commit 23615a8c92
20 changed files with 962 additions and 69 deletions
+59
View File
@@ -0,0 +1,59 @@
"""warp_to_map's img_scale param: a caller can hand it a differently-sized
image than the one `sol` was actually solved against (see
map_vision.load_full_res / ScreenshotImport.full_image), scaled to
compensate. This checks that compensation is correct, without needing a
real fixture screenshot or the (slow) line-detection/solve pipeline --
just a synthetic image and a stub solution with a predictable transform.
"""
import numpy as np
import pytest
from fenigma import map_vision
class _IdentitySolution:
"""H and lattice_to_grid() both identity: warp_to_map's transform then
reduces to just grid_to_map, so the output is a directly px_per_km-
scaled (and row-flipped, per warp_to_map's own comment) copy of
whatever region of the input `warp_to_map` reads as "grid space"."""
H = np.eye(3)
def lattice_to_grid(self):
return np.eye(3)
def test_img_scale_compensates_for_a_bigger_source_image():
# A small solid-color source, plus a 2x upscaled copy of it -- same
# content, different pixel dimensions.
small = np.zeros((20, 20, 3), dtype=np.uint8)
small[:, :] = (10, 20, 30) # BGR
big = np.zeros((40, 40, 3), dtype=np.uint8)
big[:, :] = (10, 20, 30)
sol = _IdentitySolution()
out_small, ppk_small = map_vision.warp_to_map(small, sol, px_per_km=1)
out_big, ppk_big = map_vision.warp_to_map(big, sol, px_per_km=1, img_scale=2.0)
assert ppk_small == ppk_big == 1
assert out_small.shape == out_big.shape # output is always MAP_KM_W/H * px_per_km, regardless of source size
# Same solid color warped in (opaque region only -- compare where both
# actually painted something, alpha channel nonzero).
painted = (out_small[:, :, 3] > 0) & (out_big[:, :, 3] > 0)
assert painted.any()
np.testing.assert_array_equal(out_small[painted][:, :3], out_big[painted][:, :3])
def test_default_img_scale_is_unchanged_behavior():
"""img_scale's default (1.0) must reproduce pre-existing behavior
exactly -- every other warp_to_map call site doesn't pass it."""
img = np.zeros((20, 20, 3), dtype=np.uint8)
img[:, :] = (1, 2, 3)
sol = _IdentitySolution()
out_default, _ = map_vision.warp_to_map(img, sol, px_per_km=1)
out_explicit, _ = map_vision.warp_to_map(img, sol, px_per_km=1, img_scale=1.0)
np.testing.assert_array_equal(out_default, out_explicit)
def test_load_full_res_raises_like_load_on_a_bad_path(tmp_path):
with pytest.raises(ValueError):
map_vision.load_full_res(tmp_path / "does-not-exist.png")
+39
View File
@@ -67,6 +67,45 @@ def test_ally_and_target_ids_are_independent_namespaces():
assert a_auto.id == "A" # first free letter among *allies* only, unaffected by the target above
def test_auto_id_is_shared_across_types_within_targets_and_within_allies():
"""The id namespace split is targets-vs-allies ONLY -- different types
within the same group (all targets, or all allies) share one A/B/C...
sequence, they do NOT each get their own independent sequence. A Tank
and an Infantry auto-assigned back to back must get 'A' and 'B', never
both 'A'."""
board = Board()
tank = board.add_target(TargetType.TANK, _coord())
infantry = board.add_target(TargetType.INFANTRY, _coord())
assert tank.id == "A"
assert infantry.id == "B" # not 'A' again just because it's a different type
ally_tank = board.add_ally(TargetType.TANK, _coord())
ally_infantry = board.add_ally(TargetType.INFANTRY, _coord())
assert ally_tank.id == "A"
assert ally_infantry.id == "B"
def test_auto_id_survives_past_26_entities_in_one_group():
"""A real crash: `next(c for c in string.ascii_uppercase if c not in
used)` raises StopIteration the instant all 26 letters are taken --
reachable after accepting 26+ map-screenshot proposals into the same
group (targets, or allies) in one session, since the fix making the
id sequence shared across types (not per-type) made 26 much easier
to hit. Must roll over to two-letter ids ('AA', 'AB', ...) instead of
raising."""
board = Board()
for _ in range(26):
board.add_target(TargetType.TANK, _coord())
twenty_seventh = board.add_target(TargetType.TANK, _coord())
assert twenty_seventh.id == "AA"
board2 = Board()
for _ in range(26):
board2.add_ally(TargetType.TANK, _coord())
twenty_seventh_ally = board2.add_ally(TargetType.TANK, _coord())
assert twenty_seventh_ally.id == "AA"
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
+39 -3
View File
@@ -272,19 +272,55 @@ def test_infantry_taking_fire_no_attacker_mention():
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."""
(inline-given) position -- two different places, so this becomes two
entries: Infantry#3 stays at its own reported position (no shell/
deadline, it's not the fire point), and a separate synthetic Strike
entry carries the shell/deadline at the computed offset coord ('our
position' isn't a named board entity to hang a Clue off of, so this
resolves straight to an absolute coord rather than via one)."""
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 coord == Coord("J", 6, 2, 5)
assert shell is None
assert requested_time is None
assert (TargetType.STRIKE_REQUEST, "Infantry3") in info.targets
raw, clues, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry3")]
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
def test_infantry_taking_fire_bearing_distance_short_range():
"""Same shape, a sub-1km offset (the earlier fixture's own distance,
10.76km, is far enough that a rounding slip in the offset math could
have gone unnoticed inside the same large cell -- this one crosses a
cell boundary, I7 0:8 -> H7 8:4, so a sign/axis error would visibly
land in the wrong cell letter entirely, not just a slightly-off
sub-position)."""
text = ("Infantry#11 taking fire!\n"
"Requesting <u><b>HE Shell</b></u> at bearing <b>210°</b>, distance "
"<b>0.43km</b> from our position, <b>I7 0:8</b>, by <u>10:17:37</u> "
"or we will be overrun!")
info = ocr.parse_text(text)
assert (TargetType.INFANTRY, "11") in info.targets
_, _, coord, shell, requested_time = info.targets[(TargetType.INFANTRY, "11")]
assert coord == Coord("I", 7, 0, 8)
assert shell is None
assert requested_time is None
assert (TargetType.STRIKE_REQUEST, "Infantry11") in info.targets
_, _, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry11")]
assert coord == Coord("H", 7, 8, 4)
assert shell is Shell.HE
assert requested_time == "10:17:37"