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
+47 -3
View File
@@ -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():