Revert auto-assigned ids back to letters; only detected ids are numeric

Auto-assignment (no real id known: a manual add, or an accepted
proposal with no confident marker-id read) must stay visually
distinct from a genuinely detected id, or a made-up number could
collide with or be mistaken for a real one. Reverts the previous
commit's switch to numeric auto-assignment (_next_free_numeric_id) --
that was wrong, caught by the user immediately. _next_free_id
(letters, rolling over to "AA"/"AB"/... past 26) is back as the
fallback, still scoped per type (that part of the previous change was
correct and stays). Plain numbers are reserved for an id
_accept_proposal is actually confident was read off the marker itself
(Proposal.detected_id), passed straight through and never touching
auto-assignment.

Also fixes detected_id's own collision pre-check in _accept_proposal,
which wasn't scoped per type either -- same bug as the Change ID
popover fix, just in a second place: a detected id could get
needlessly discarded because an unrelated type already used that
number, not because of a real collision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 19:49:30 +02:00
co-authored by Claude Sonnet 5
parent 6e18d60eb5
commit 5a35ea7776
5 changed files with 110 additions and 76 deletions
+2 -1
View File
@@ -760,7 +760,8 @@ class MainWindow(Adw.ApplicationWindow):
# than losing this one accept's traceability to the game's own
# number.
id_ = proposal.detected_id
existing = {a.id for a in self.board.allies} if is_ally else {t.id for t in self.board.targets}
group = self.board.allies if is_ally else self.board.targets
existing = {o.id for o in group if o.type == type_} # per-type, same as Board.add_target/add_ally
if id_ in existing:
id_ = None
if is_ally:
+45 -32
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
@@ -502,28 +503,37 @@ class ScoutFlight:
return f"ScoutFlight#{self.id}"
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.
def _next_free_id(used: set[str]) -> str:
"""Next unused id in a short, human-friendly LETTER sequence: single
uppercase letters (A..Z) first, then two-letter combinations
(AA..ZZ, spreadsheet-column style) once those run out, and so on.
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 'A' (Tank#A,
Infantry#A), each type keeping its own independent sequence.
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)
Deliberately letters, not numbers: a manually-added entity (map
right-click "Add target", or an accepted screenshot proposal with no
confident id read) has no real game id to report, so it gets an
obviously-not-a-real-id placeholder instead -- app.py's
_accept_proposal reserves plain numbers for an id it's actually
confident was read off the marker itself (map_vision.read_marker_id
via Proposal.detected_id), passed straight through as this
function's caller's `id_` and never touching this auto-assignment at
all. Letters can't collide with a real (numeric) detected id either,
on top of just reading honestly as 'made up'.
Can't run out the way `next(c for c in string.ascii_uppercase if c
not in used)` used to (a real regression, see TODO.md): rolls over to
two-letter ids ('AA', 'AB', ...) past 26 instead of raising
StopIteration."""
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
@@ -596,15 +606,18 @@ class Board:
location: Location | Coord | None = None,
id_: str | None = None,
) -> Target:
# Own 1/2/3... sequence per TYPE, not one shared across every
# Own A/B/C... 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.
# assigned back to back both start at 'A' (Tank#A, Infantry#A).
# targets-vs-allies is still its own separate id namespace (see
# add_ally); type now subdivides it further too. Letters, not
# numbers, when auto-assigning here specifically: see
# _next_free_id's own docstring for why (a real detected id, when
# there is one, is passed in as `id_` and never reaches this
# auto-assignment at all).
if not id_:
used = {t.id for t in self.targets if t.type == type_}
id_ = _next_free_numeric_id(used)
id_ = _next_free_id(used)
t = Target(type=type_, id=id_, location=_as_location(location))
self.targets.append(t)
return t
@@ -619,14 +632,14 @@ class Board:
location: Location | Coord | None = None,
id_: str | None = None,
) -> Ally:
# A separate id namespace from add_target()'s: an ally Tank#1
# and a hostile Target Tank#1 are unrelated, so auto-assignment
# A separate id namespace from add_target()'s: an ally Tank#A
# and a hostile Target Tank#A are unrelated, so auto-assignment
# 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.
# A/B/C... sequence per TYPE too, same as add_target -- see its
# own comment and _next_free_id.
if not id_:
used = {a.id for a in self.allies if a.type == type_}
id_ = _next_free_numeric_id(used)
id_ = _next_free_id(used)
a = Ally(type=type_, id=id_, location=_as_location(location))
self.allies.append(a)
return a