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
+45 -4
View File
@@ -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()
+98 -8
View File
@@ -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
View File
@@ -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:
+29 -4
View File
@@ -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")
+35 -1
View File
@@ -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
View File
@@ -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
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():
+6 -4
View File
@@ -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):