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:
@@ -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")
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user