Auto-ids per type not per group; show detected_id in proposal UI

Auto-assignment (Board.add_target/add_ally with no explicit id_) now
scopes its 1/2/3... sequence per TYPE within each group, not one
sequence shared across every type in the group -- Tank#1/Infantry#1
rather than Tank#A/Infantry#B, matching the game's own numbering.
Reverses the type-scoping half of an earlier fix in this file (see
TODO.md's "Allies and enemies seem to share indices" entry) per
explicit user direction; the targets-vs-allies namespace split that
fix also made is untouched, still correct. _next_free_id (letters,
rolling over to "AA" past 26) is replaced by _next_free_numeric_id --
a plain counter can't run out the way a fixed alphabet could, so
there's no equivalent rollover concern. test_models.py updated to
match (one test asserts the opposite of before, renamed accordingly).

detected_id (map_vision.read_marker_id) was being logged but never
shown anywhere a human could actually check it against the
screenshot before now: added to the proposal popover's heading and
the pending-proposal's own on-map label.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 19:28:01 +02:00
co-authored by Claude Sonnet 5
parent 8109db2f39
commit d2f70675b8
6 changed files with 111 additions and 62 deletions
+9 -1
View File
@@ -850,9 +850,17 @@ class MainWindow(Adw.ApplicationWindow):
box = page()
lbl = Gtk.Label(xalign=0, margin_start=4, margin_bottom=2)
side = "friendly" if proposal.side == "friendly" else "hostile"
# detected_id (map_vision.read_marker_id's best-effort read of
# the marker's own "#<N>" label, see its own docstring) is
# shown here so it's visible right when there's still a
# screenshot to actually check it against -- accept already
# uses it for the entity's id when present (see
# _accept_proposal), this is just making that fact visible
# before the click, not a separate signal.
id_part = f", id #{proposal.detected_id}" if proposal.detected_id else ""
lbl.set_markup(
f"<b>{GLib.markup_escape_text(proposal.coord)}</b> — {side}, "
f"{detected.value if detected else 'type unknown'}")
f"{detected.value if detected else 'type unknown'}{GLib.markup_escape_text(id_part)}")
box.append(lbl)
box.append(Gtk.Separator(margin_top=2, margin_bottom=2))
button(box, f"Accept as {detected.value if detected else TargetType.UNKNOWN.value}",
+7 -1
View File
@@ -638,8 +638,14 @@ class GridCanvas(Gtk.DrawingArea):
what a proposal is until the user accepts it."""
for p, coord in self._pending_proposals():
color = CATEGORY_COLOR["ally" if p.side == "friendly" else "target"]
# detected_id (map_vision.read_marker_id's best-effort read of
# the marker's own "#<N>" label) shown right on the map while
# the screenshot backing it is still up, so it's checkable
# against the actual pixels -- same id _accept_proposal will
# use for the entity if this gets accepted, see its own comment.
id_part = f" #{p.detected_id}" if p.detected_id else ""
self._draw_marker(cr, view, coord.as_fraction(), color,
f"? {coord.label()}", width, height,
f"?{id_part} {coord.label()}", width, height,
hollow=True, coord=coord)
def _hit_test(self, view: _View, x: float, y: float):
+34 -33
View File
@@ -18,7 +18,6 @@ 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
@@ -503,27 +502,28 @@ 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.
def _next_free_numeric_id(used: set[str]) -> str:
"""Next unused id in a plain 1, 2, 3... sequence -- matches the small
integer ids the game itself shows per unit (see
map_vision.read_marker_id and app.py's _accept_proposal, which
prefers that detected id over this auto-assignment whenever it has
one), scoped per TYPE, not per group: add_target()/add_ally() only
look at existing entities of the SAME type when building `used`, so
a Tank and an Infantry added back to back both start at '1', each
type keeping its own independent count (Infantry #1/#2/#3,
Mechanized #1/#2/#3, ..., same as the game's own numbering) rather
than sharing one sequence across every type in the group.
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
Older versions of this used a letter sequence (A, B, C..., rolling
over to AA/AB/... past 26) shared across a whole group instead of
per type -- switched away from per-type once before (see TODO.md)
because sharing made it too easy to run past 26 letters. That's not
a concern here: a plain counter never runs out, there's no fixed
alphabet to exhaust regardless of how it's scoped."""
n = 1
while str(n) in used:
n += 1
return str(n)
SAVE_FORMAT_VERSION = 3
@@ -596,14 +596,15 @@ 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.
# Own 1/2/3... sequence per TYPE, not one shared across every
# target regardless of type -- a Tank and an Infantry auto-
# assigned back to back both start at '1' (Tank#1, Infantry#1),
# matching how the game itself numbers units. targets-vs-allies
# is still its own separate id namespace (see add_ally); type
# now subdivides it further too. See _next_free_numeric_id.
if not id_:
used = {t.id for t in self.targets}
id_ = _next_free_id(used)
used = {t.id for t in self.targets if t.type == type_}
id_ = _next_free_numeric_id(used)
t = Target(type=type_, id=id_, location=_as_location(location))
self.targets.append(t)
return t
@@ -620,12 +621,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, 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.
# here only looks at other allies, never self.targets. Own
# 1/2/3... sequence per TYPE too, same as add_target -- see its
# own comment and _next_free_numeric_id.
if not id_:
used = {a.id for a in self.allies}
id_ = _next_free_id(used)
used = {a.id for a in self.allies if a.type == type_}
id_ = _next_free_numeric_id(used)
a = Ally(type=type_, id=id_, location=_as_location(location))
self.allies.append(a)
return a