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:
parent
6e18d60eb5
commit
5a35ea7776
48
TODO.md
48
TODO.md
@ -207,23 +207,41 @@ Status legend: [x] fixed+tested, [~] partially addressed, [ ] open/needs input
|
|||||||
(`tests/test_app_accept_proposal.py`).
|
(`tests/test_app_accept_proposal.py`).
|
||||||
- Auto-assignment itself (`Board.add_target`/`add_ally` with no
|
- Auto-assignment itself (`Board.add_target`/`add_ally` with no
|
||||||
`id_`/no usable detection) changed from one shared letter
|
`id_`/no usable detection) changed from one shared letter
|
||||||
sequence per group (targets, or allies) to its own 1/2/3...
|
sequence per group (targets, or allies) to its own sequence per
|
||||||
sequence per TYPE within each group -- Tank#1/Infantry#1 rather
|
TYPE within each group -- Tank#A/Infantry#A rather than
|
||||||
than Tank#A/Infantry#B, matching the game's own numbering (and
|
Tank#A/Infantry#B. This directly reverses an earlier deliberate
|
||||||
what `detected_id` looks like when it IS read). This directly
|
fix in this same file (see the "Allies and enemies seem to share
|
||||||
reverses an earlier deliberate fix in this same file (see the
|
indices" entry above, which moved FROM per-type TO
|
||||||
"Allies and enemies seem to share indices" entry above, which
|
shared-per-group) -- that fix is still correct for what it fixed
|
||||||
moved FROM per-type TO shared-per-group) -- that fix is still
|
(targets-vs-allies must stay separate namespaces), just not for
|
||||||
correct for what it fixed (targets-vs-allies must stay separate
|
per-type-vs-shared, which the user has now clarified the other
|
||||||
namespaces), just not for per-type-vs-shared, which the user has
|
way. Tests in `test_models.py` updated to match (renamed
|
||||||
now clarified the other way. `_next_free_id` (the letter
|
|
||||||
sequence, with its own StopIteration-safe rollover to "AA" past
|
|
||||||
26) is gone, 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 to carry forward. Tests in
|
|
||||||
`test_models.py` updated to match (renamed
|
|
||||||
`test_auto_id_is_shared_across_types...` ->
|
`test_auto_id_is_shared_across_types...` ->
|
||||||
`test_auto_id_is_per_type...`, since it now asserts the opposite).
|
`test_auto_id_is_per_type...`, since it now asserts the opposite).
|
||||||
|
|
||||||
|
First pass at this ALSO switched auto-assignment from letters to
|
||||||
|
plain numbers (1/2/3...), reasoning that it should match what
|
||||||
|
`detected_id` looks like when read successfully. Wrong -- caught
|
||||||
|
by the user immediately: auto-assignment (no real id known, a
|
||||||
|
manual add or an accept with no confident read) and a genuinely
|
||||||
|
detected id need to stay visually distinct, or a made-up
|
||||||
|
auto-assigned number could collide with, or be mistaken for, a
|
||||||
|
real one. Reverted back to `_next_free_id` (letters, rolling
|
||||||
|
over to "AA"/"AB"/... past 26 rather than raising
|
||||||
|
`StopIteration`) as the auto-assignment fallback, scoped per
|
||||||
|
type same as above; plain numbers are reserved for an id
|
||||||
|
`_accept_proposal` is actually confident was read off the
|
||||||
|
marker itself (`Proposal.detected_id`), passed straight through
|
||||||
|
as `id_` and never touching auto-assignment at all.
|
||||||
|
- `_accept_proposal` now actually USES `detected_id` (it was only
|
||||||
|
being logged before, never applied) -- an accepted proposal's
|
||||||
|
entity id prefers the detected number over auto-assignment,
|
||||||
|
falling back on a collision (scoped per type, same bug fixed
|
||||||
|
in two places: this collision pre-check, and the "Change ID"
|
||||||
|
popover's own check, which still enforced the OLD shared-per-
|
||||||
|
group rule after the auto-assignment change above and rejected
|
||||||
|
valid renames across types). 4 new regression tests
|
||||||
|
(`tests/test_app_accept_proposal.py`).
|
||||||
- `detected_id` is now shown, not just logged: the proposal
|
- `detected_id` is now shown, not just logged: the proposal
|
||||||
popover's heading (", id #8") and the pending-proposal's own
|
popover's heading (", id #8") and the pending-proposal's own
|
||||||
on-map label (`? #8 G8 5:4`) both show it while there's still a
|
on-map label (`? #8 G8 5:4`) both show it while there's still a
|
||||||
|
|||||||
@ -760,7 +760,8 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
# than losing this one accept's traceability to the game's own
|
# than losing this one accept's traceability to the game's own
|
||||||
# number.
|
# number.
|
||||||
id_ = proposal.detected_id
|
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:
|
if id_ in existing:
|
||||||
id_ = None
|
id_ = None
|
||||||
if is_ally:
|
if is_ally:
|
||||||
|
|||||||
@ -18,6 +18,7 @@ Coord) to work out everything else. This module just defines the shape.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import itertools
|
||||||
import string
|
import string
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
@ -502,28 +503,37 @@ class ScoutFlight:
|
|||||||
return f"ScoutFlight#{self.id}"
|
return f"ScoutFlight#{self.id}"
|
||||||
|
|
||||||
|
|
||||||
def _next_free_numeric_id(used: set[str]) -> str:
|
def _next_free_id(used: set[str]) -> str:
|
||||||
"""Next unused id in a plain 1, 2, 3... sequence -- matches the small
|
"""Next unused id in a short, human-friendly LETTER sequence: single
|
||||||
integer ids the game itself shows per unit (see
|
uppercase letters (A..Z) first, then two-letter combinations
|
||||||
map_vision.read_marker_id and app.py's _accept_proposal, which
|
(AA..ZZ, spreadsheet-column style) once those run out, and so on.
|
||||||
prefers that detected id over this auto-assignment whenever it has
|
Scoped per TYPE, not per group: add_target()/add_ally() only look at
|
||||||
one), scoped per TYPE, not per group: add_target()/add_ally() only
|
existing entities of the SAME type when building `used`, so a Tank
|
||||||
look at existing entities of the SAME type when building `used`, so
|
and an Infantry added back to back both start at 'A' (Tank#A,
|
||||||
a Tank and an Infantry added back to back both start at '1', each
|
Infantry#A), each type keeping its own independent sequence.
|
||||||
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.
|
|
||||||
|
|
||||||
Older versions of this used a letter sequence (A, B, C..., rolling
|
Deliberately letters, not numbers: a manually-added entity (map
|
||||||
over to AA/AB/... past 26) shared across a whole group instead of
|
right-click "Add target", or an accepted screenshot proposal with no
|
||||||
per type -- switched away from per-type once before (see TODO.md)
|
confident id read) has no real game id to report, so it gets an
|
||||||
because sharing made it too easy to run past 26 letters. That's not
|
obviously-not-a-real-id placeholder instead -- app.py's
|
||||||
a concern here: a plain counter never runs out, there's no fixed
|
_accept_proposal reserves plain numbers for an id it's actually
|
||||||
alphabet to exhaust regardless of how it's scoped."""
|
confident was read off the marker itself (map_vision.read_marker_id
|
||||||
n = 1
|
via Proposal.detected_id), passed straight through as this
|
||||||
while str(n) in used:
|
function's caller's `id_` and never touching this auto-assignment at
|
||||||
n += 1
|
all. Letters can't collide with a real (numeric) detected id either,
|
||||||
return str(n)
|
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
|
SAVE_FORMAT_VERSION = 3
|
||||||
@ -596,15 +606,18 @@ class Board:
|
|||||||
location: Location | Coord | None = None,
|
location: Location | Coord | None = None,
|
||||||
id_: str | None = None,
|
id_: str | None = None,
|
||||||
) -> Target:
|
) -> 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-
|
# target regardless of type -- a Tank and an Infantry auto-
|
||||||
# assigned back to back both start at '1' (Tank#1, Infantry#1),
|
# assigned back to back both start at 'A' (Tank#A, Infantry#A).
|
||||||
# matching how the game itself numbers units. targets-vs-allies
|
# targets-vs-allies is still its own separate id namespace (see
|
||||||
# is still its own separate id namespace (see add_ally); type
|
# add_ally); type now subdivides it further too. Letters, not
|
||||||
# now subdivides it further too. See _next_free_numeric_id.
|
# 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_:
|
if not id_:
|
||||||
used = {t.id for t in self.targets if t.type == type_}
|
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))
|
t = Target(type=type_, id=id_, location=_as_location(location))
|
||||||
self.targets.append(t)
|
self.targets.append(t)
|
||||||
return t
|
return t
|
||||||
@ -619,14 +632,14 @@ class Board:
|
|||||||
location: Location | Coord | None = None,
|
location: Location | Coord | None = None,
|
||||||
id_: str | None = None,
|
id_: str | None = None,
|
||||||
) -> Ally:
|
) -> Ally:
|
||||||
# A separate id namespace from add_target()'s: an ally Tank#1
|
# A separate id namespace from add_target()'s: an ally Tank#A
|
||||||
# and a hostile Target Tank#1 are unrelated, so auto-assignment
|
# and a hostile Target Tank#A are unrelated, so auto-assignment
|
||||||
# here only looks at other allies, never self.targets. Own
|
# here only looks at other allies, never self.targets. Own
|
||||||
# 1/2/3... sequence per TYPE too, same as add_target -- see its
|
# A/B/C... sequence per TYPE too, same as add_target -- see its
|
||||||
# own comment and _next_free_numeric_id.
|
# own comment and _next_free_id.
|
||||||
if not id_:
|
if not id_:
|
||||||
used = {a.id for a in self.allies if a.type == type_}
|
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))
|
a = Ally(type=type_, id=id_, location=_as_location(location))
|
||||||
self.allies.append(a)
|
self.allies.append(a)
|
||||||
return a
|
return a
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
"""_accept_proposal: an accepted proposal's entity id should prefer the
|
"""_accept_proposal: an accepted proposal's entity id should prefer the
|
||||||
marker's own detected "#<N>" id (map_vision.read_marker_id, via
|
marker's own detected "#<N>" id (map_vision.read_marker_id, via
|
||||||
Proposal.detected_id) over an auto-assigned number, so ids on the board
|
Proposal.detected_id) over an auto-assigned letter, so ids on the board
|
||||||
match what's actually on screen -- falling back to auto-assign only when
|
match what's actually on screen -- falling back to auto-assign (a
|
||||||
there's no detection, or it collides with an id already used for that
|
letter, deliberately not a number, so it can't collide with or be
|
||||||
type in that group (see _accept_proposal's own docstring, and
|
mistaken for a real detected id -- see models.py's _next_free_id) only
|
||||||
models.py's _next_free_numeric_id for the per-type auto-assignment
|
when there's no detection, or it collides with an id already used for
|
||||||
these fall back to).
|
that type in that group (see _accept_proposal's own docstring).
|
||||||
|
|
||||||
Needs a real Adw/Gtk init (MainWindow.__new__ skips __init__, so no
|
Needs a real Adw/Gtk init (MainWindow.__new__ skips __init__, so no
|
||||||
window/widgets are actually built, but Adw.init() is still required for
|
window/widgets are actually built, but Adw.init() is still required for
|
||||||
@ -46,7 +46,7 @@ def test_accept_uses_the_detected_id_when_present():
|
|||||||
def test_accept_falls_back_to_auto_id_with_no_detection():
|
def test_accept_falls_back_to_auto_id_with_no_detection():
|
||||||
win = _window()
|
win = _window()
|
||||||
win._accept_proposal(_proposal(detected_id=None))
|
win._accept_proposal(_proposal(detected_id=None))
|
||||||
assert win.board.targets[0].id == "1"
|
assert win.board.targets[0].id == "A"
|
||||||
|
|
||||||
|
|
||||||
def test_accept_falls_back_to_auto_id_on_a_detected_id_collision():
|
def test_accept_falls_back_to_auto_id_on_a_detected_id_collision():
|
||||||
|
|||||||
@ -54,58 +54,60 @@ def test_clear_units_keeps_recon_infrastructure():
|
|||||||
def test_ally_and_target_ids_are_independent_namespaces():
|
def test_ally_and_target_ids_are_independent_namespaces():
|
||||||
"""An ally Tank#1 and a hostile Target Tank#1 are unrelated -- adding
|
"""An ally Tank#1 and a hostile Target Tank#1 are unrelated -- adding
|
||||||
one must never be influenced by the other's ids, and auto-assignment
|
one must never be influenced by the other's ids, and auto-assignment
|
||||||
on each side starts from '1' independently."""
|
on each side starts from 'A' independently. Explicit id_="1" here
|
||||||
|
(as an accepted screenshot proposal's detected_id would pass, see
|
||||||
|
app.py's _accept_proposal) to also check that auto-assignment
|
||||||
|
correctly skips a real numeric id already in use, not just other
|
||||||
|
letters."""
|
||||||
board = Board()
|
board = Board()
|
||||||
t1 = board.add_target(TargetType.TANK, _coord(), id_="1")
|
t1 = board.add_target(TargetType.TANK, _coord(), id_="1")
|
||||||
a1 = board.add_ally(TargetType.TANK, _coord(), id_="1")
|
a1 = board.add_ally(TargetType.TANK, _coord(), id_="1")
|
||||||
assert t1.id == a1.id == "1"
|
assert t1.id == a1.id == "1"
|
||||||
assert t1 is not a1
|
assert t1 is not a1
|
||||||
|
|
||||||
# '1' is already taken (explicitly) for TANK on each side, so the next
|
|
||||||
# auto-assigned Tank on each side must skip it and land on '2'.
|
|
||||||
t_auto = board.add_target(TargetType.TANK, _coord())
|
t_auto = board.add_target(TargetType.TANK, _coord())
|
||||||
a_auto = board.add_ally(TargetType.TANK, _coord())
|
a_auto = board.add_ally(TargetType.TANK, _coord())
|
||||||
assert t_auto.id == "2" # first free number among *target* Tanks only
|
assert t_auto.id == "A" # first free letter among *target* Tanks only
|
||||||
assert a_auto.id == "2" # first free number among *ally* Tanks only, unaffected by the target above
|
assert a_auto.id == "A" # first free letter among *ally* Tanks only, unaffected by the target above
|
||||||
|
|
||||||
|
|
||||||
def test_auto_id_is_per_type_within_targets_and_within_allies():
|
def test_auto_id_is_per_type_within_targets_and_within_allies():
|
||||||
"""Each TYPE gets its own independent 1/2/3... sequence within a group
|
"""Each TYPE gets its own independent A/B/C... sequence within a group
|
||||||
(all targets, or all allies) -- a Tank and an Infantry auto-assigned
|
(all targets, or all allies) -- a Tank and an Infantry auto-assigned
|
||||||
back to back both start at '1' (Tank#1, Infantry#1), matching how the
|
back to back both start at 'A' (Tank#A, Infantry#A), rather than
|
||||||
game itself numbers units, rather than sharing one sequence across
|
sharing one sequence across every type in the group."""
|
||||||
every type in the group."""
|
|
||||||
board = Board()
|
board = Board()
|
||||||
tank = board.add_target(TargetType.TANK, _coord())
|
tank = board.add_target(TargetType.TANK, _coord())
|
||||||
infantry = board.add_target(TargetType.INFANTRY, _coord())
|
infantry = board.add_target(TargetType.INFANTRY, _coord())
|
||||||
assert tank.id == "1"
|
assert tank.id == "A"
|
||||||
assert infantry.id == "1" # own sequence, not '2' just because a Tank came first
|
assert infantry.id == "A" # own sequence, not 'B' just because a Tank came first
|
||||||
|
|
||||||
second_tank = board.add_target(TargetType.TANK, _coord())
|
second_tank = board.add_target(TargetType.TANK, _coord())
|
||||||
assert second_tank.id == "2" # but a SECOND Tank does advance the Tank sequence
|
assert second_tank.id == "B" # but a SECOND Tank does advance the Tank sequence
|
||||||
|
|
||||||
ally_tank = board.add_ally(TargetType.TANK, _coord())
|
ally_tank = board.add_ally(TargetType.TANK, _coord())
|
||||||
ally_infantry = board.add_ally(TargetType.INFANTRY, _coord())
|
ally_infantry = board.add_ally(TargetType.INFANTRY, _coord())
|
||||||
assert ally_tank.id == "1"
|
assert ally_tank.id == "A"
|
||||||
assert ally_infantry.id == "1"
|
assert ally_infantry.id == "A"
|
||||||
|
|
||||||
|
|
||||||
def test_auto_id_keeps_counting_past_26_entities_of_one_type():
|
def test_auto_id_survives_past_26_entities_of_one_type():
|
||||||
"""The id sequence is a plain integer counter now (see
|
"""A real crash: `next(c for c in string.ascii_uppercase if c not in
|
||||||
_next_free_numeric_id), not the old letter sequence that could raise
|
used)` raises StopIteration the instant all 26 letters are taken --
|
||||||
StopIteration past 26 (see TODO.md/git history) -- nothing special
|
reachable after accepting/adding 26+ of the same type into one group
|
||||||
should happen crossing 26, it just keeps counting."""
|
in a single session. Must roll over to two-letter ids ('AA', 'AB',
|
||||||
|
...) instead of raising."""
|
||||||
board = Board()
|
board = Board()
|
||||||
for _ in range(26):
|
for _ in range(26):
|
||||||
board.add_target(TargetType.TANK, _coord())
|
board.add_target(TargetType.TANK, _coord())
|
||||||
twenty_seventh = board.add_target(TargetType.TANK, _coord())
|
twenty_seventh = board.add_target(TargetType.TANK, _coord())
|
||||||
assert twenty_seventh.id == "27"
|
assert twenty_seventh.id == "AA"
|
||||||
|
|
||||||
board2 = Board()
|
board2 = Board()
|
||||||
for _ in range(26):
|
for _ in range(26):
|
||||||
board2.add_ally(TargetType.TANK, _coord())
|
board2.add_ally(TargetType.TANK, _coord())
|
||||||
twenty_seventh_ally = board2.add_ally(TargetType.TANK, _coord())
|
twenty_seventh_ally = board2.add_ally(TargetType.TANK, _coord())
|
||||||
assert twenty_seventh_ally.id == "27"
|
assert twenty_seventh_ally.id == "AA"
|
||||||
|
|
||||||
|
|
||||||
def test_find_by_name_prefers_target_over_same_named_ally():
|
def test_find_by_name_prefers_target_over_same_named_ally():
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user