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:
parent
8109db2f39
commit
d2f70675b8
30
TODO.md
30
TODO.md
@ -198,3 +198,33 @@ Status legend: [x] fixed+tested, [~] partially addressed, [ ] open/needs input
|
|||||||
code exists in this repo at all yet, real scope work (find/access
|
code exists in this repo at all yet, real scope work (find/access
|
||||||
the game's log, agree a "<Type>#<id> Destroyed" grammar, wire it
|
the game's log, agree a "<Type>#<id> Destroyed" grammar, wire it
|
||||||
into a dedup key) rather than a quick pass.
|
into a dedup key) rather than a quick pass.
|
||||||
|
|
||||||
|
Follow-ups from user feedback after the above landed:
|
||||||
|
- `_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. 4 new regression tests
|
||||||
|
(`tests/test_app_accept_proposal.py`).
|
||||||
|
- Auto-assignment itself (`Board.add_target`/`add_ally` with no
|
||||||
|
`id_`/no usable detection) changed from one shared letter
|
||||||
|
sequence per group (targets, or allies) to its own 1/2/3...
|
||||||
|
sequence per TYPE within each group -- Tank#1/Infantry#1 rather
|
||||||
|
than Tank#A/Infantry#B, matching the game's own numbering (and
|
||||||
|
what `detected_id` looks like when it IS read). This directly
|
||||||
|
reverses an earlier deliberate fix in this same file (see the
|
||||||
|
"Allies and enemies seem to share indices" entry above, which
|
||||||
|
moved FROM per-type TO shared-per-group) -- that fix is still
|
||||||
|
correct for what it fixed (targets-vs-allies must stay separate
|
||||||
|
namespaces), just not for per-type-vs-shared, which the user has
|
||||||
|
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_per_type...`, since it now asserts the opposite).
|
||||||
|
- `detected_id` is now shown, not just logged: the proposal
|
||||||
|
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
|
||||||
|
screenshot up to check it against by eye.
|
||||||
|
|||||||
@ -850,9 +850,17 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
box = page()
|
box = page()
|
||||||
lbl = Gtk.Label(xalign=0, margin_start=4, margin_bottom=2)
|
lbl = Gtk.Label(xalign=0, margin_start=4, margin_bottom=2)
|
||||||
side = "friendly" if proposal.side == "friendly" else "hostile"
|
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(
|
lbl.set_markup(
|
||||||
f"<b>{GLib.markup_escape_text(proposal.coord)}</b> — {side}, "
|
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(lbl)
|
||||||
box.append(Gtk.Separator(margin_top=2, margin_bottom=2))
|
box.append(Gtk.Separator(margin_top=2, margin_bottom=2))
|
||||||
button(box, f"Accept as {detected.value if detected else TargetType.UNKNOWN.value}",
|
button(box, f"Accept as {detected.value if detected else TargetType.UNKNOWN.value}",
|
||||||
|
|||||||
@ -638,8 +638,14 @@ class GridCanvas(Gtk.DrawingArea):
|
|||||||
what a proposal is until the user accepts it."""
|
what a proposal is until the user accepts it."""
|
||||||
for p, coord in self._pending_proposals():
|
for p, coord in self._pending_proposals():
|
||||||
color = CATEGORY_COLOR["ally" if p.side == "friendly" else "target"]
|
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,
|
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)
|
hollow=True, coord=coord)
|
||||||
|
|
||||||
def _hit_test(self, view: _View, x: float, y: float):
|
def _hit_test(self, view: _View, x: float, y: float):
|
||||||
|
|||||||
@ -18,7 +18,6 @@ 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
|
||||||
@ -503,27 +502,28 @@ class ScoutFlight:
|
|||||||
return f"ScoutFlight#{self.id}"
|
return f"ScoutFlight#{self.id}"
|
||||||
|
|
||||||
|
|
||||||
def _next_free_id(used: set[str]) -> str:
|
def _next_free_numeric_id(used: set[str]) -> str:
|
||||||
"""Next unused id in a short, human-friendly sequence: single
|
"""Next unused id in a plain 1, 2, 3... sequence -- matches the small
|
||||||
uppercase letters (A..Z) first, then two-letter combinations
|
integer ids the game itself shows per unit (see
|
||||||
(AA..ZZ, spreadsheet-column style) once those run out, and so on.
|
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
|
Older versions of this used a letter sequence (A, B, C..., rolling
|
||||||
if c not in used)` raises StopIteration the instant all 26 letters
|
over to AA/AB/... past 26) shared across a whole group instead of
|
||||||
are taken, which used to need 26+ auto-added entities of one TYPE
|
per type -- switched away from per-type once before (see TODO.md)
|
||||||
(rare) but, once add_target()/add_ally() moved to one shared id
|
because sharing made it too easy to run past 26 letters. That's not
|
||||||
sequence per GROUP instead of per type (so a Tank and an Infantry
|
a concern here: a plain counter never runs out, there's no fixed
|
||||||
added back to back get 'A'/'B', not both 'A', see their own
|
alphabet to exhaust regardless of how it's scoped."""
|
||||||
comments), needs only 26 auto-added entities of ANY type in that
|
n = 1
|
||||||
group -- reachable in a single big screenshot import. This can't run
|
while str(n) in used:
|
||||||
out: it just grows the id length instead."""
|
n += 1
|
||||||
length = 1
|
return str(n)
|
||||||
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,14 +596,15 @@ class Board:
|
|||||||
location: Location | Coord | None = None,
|
location: Location | Coord | None = None,
|
||||||
id_: str | None = None,
|
id_: str | None = None,
|
||||||
) -> Target:
|
) -> Target:
|
||||||
# One shared A/B/C... sequence across every target regardless of
|
# Own 1/2/3... sequence per TYPE, not one shared across every
|
||||||
# type, not one sequence per type -- a Tank and an Infantry auto-
|
# target regardless of type -- a Tank and an Infantry auto-
|
||||||
# assigned back to back get 'A' and 'B', never both 'A'. Only
|
# assigned back to back both start at '1' (Tank#1, Infantry#1),
|
||||||
# targets-vs-allies is a separate id namespace (see add_ally),
|
# matching how the game itself numbers units. targets-vs-allies
|
||||||
# type never subdivides it further.
|
# is still its own separate id namespace (see add_ally); type
|
||||||
|
# now subdivides it further too. See _next_free_numeric_id.
|
||||||
if not id_:
|
if not id_:
|
||||||
used = {t.id for t in self.targets}
|
used = {t.id for t in self.targets if t.type == type_}
|
||||||
id_ = _next_free_id(used)
|
id_ = _next_free_numeric_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
|
||||||
@ -620,12 +621,12 @@ class Board:
|
|||||||
) -> 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#1
|
||||||
# and a hostile Target Tank#1 are unrelated, so auto-assignment
|
# and a hostile Target Tank#1 are unrelated, so auto-assignment
|
||||||
# here only looks at other allies, never self.targets. Same as
|
# here only looks at other allies, never self.targets. Own
|
||||||
# add_target though, that's the ONLY split: one shared A/B/C...
|
# 1/2/3... sequence per TYPE too, same as add_target -- see its
|
||||||
# sequence across every ally regardless of type, not one per type.
|
# own comment and _next_free_numeric_id.
|
||||||
if not id_:
|
if not id_:
|
||||||
used = {a.id for a in self.allies}
|
used = {a.id for a in self.allies if a.type == type_}
|
||||||
id_ = _next_free_id(used)
|
id_ = _next_free_numeric_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,9 +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 letter, so ids on the board
|
Proposal.detected_id) over an auto-assigned number, 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 only when
|
||||||
there's no detection, or it collides with an id already used in that
|
there's no detection, or it collides with an id already used for that
|
||||||
group (see _accept_proposal's own docstring).
|
type in that group (see _accept_proposal's own docstring, and
|
||||||
|
models.py's _next_free_numeric_id for the per-type auto-assignment
|
||||||
|
these fall back to).
|
||||||
|
|
||||||
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
|
||||||
@ -44,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 == "A"
|
assert win.board.targets[0].id == "1"
|
||||||
|
|
||||||
|
|
||||||
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,56 +54,58 @@ 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 'A' independently."""
|
on each side starts from '1' independently."""
|
||||||
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 == "A" # first free letter among *targets* only
|
assert t_auto.id == "2" # first free number among *target* Tanks only
|
||||||
assert a_auto.id == "A" # first free letter among *allies* only, unaffected by the target above
|
assert a_auto.id == "2" # first free number among *ally* Tanks only, unaffected by the target above
|
||||||
|
|
||||||
|
|
||||||
def test_auto_id_is_shared_across_types_within_targets_and_within_allies():
|
def test_auto_id_is_per_type_within_targets_and_within_allies():
|
||||||
"""The id namespace split is targets-vs-allies ONLY -- different types
|
"""Each TYPE gets its own independent 1/2/3... sequence within a group
|
||||||
within the same group (all targets, or all allies) share one A/B/C...
|
(all targets, or all allies) -- a Tank and an Infantry auto-assigned
|
||||||
sequence, they do NOT each get their own independent sequence. A Tank
|
back to back both start at '1' (Tank#1, Infantry#1), matching how the
|
||||||
and an Infantry auto-assigned back to back must get 'A' and 'B', never
|
game itself numbers units, rather than sharing one sequence across
|
||||||
both 'A'."""
|
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 == "A"
|
assert tank.id == "1"
|
||||||
assert infantry.id == "B" # not 'A' again just because it's a different type
|
assert infantry.id == "1" # own sequence, not '2' just because a Tank came first
|
||||||
|
|
||||||
|
second_tank = board.add_target(TargetType.TANK, _coord())
|
||||||
|
assert second_tank.id == "2" # 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 == "A"
|
assert ally_tank.id == "1"
|
||||||
assert ally_infantry.id == "B"
|
assert ally_infantry.id == "1"
|
||||||
|
|
||||||
|
|
||||||
def test_auto_id_survives_past_26_entities_in_one_group():
|
def test_auto_id_keeps_counting_past_26_entities_of_one_type():
|
||||||
"""A real crash: `next(c for c in string.ascii_uppercase if c not in
|
"""The id sequence is a plain integer counter now (see
|
||||||
used)` raises StopIteration the instant all 26 letters are taken --
|
_next_free_numeric_id), not the old letter sequence that could raise
|
||||||
reachable after accepting 26+ map-screenshot proposals into the same
|
StopIteration past 26 (see TODO.md/git history) -- nothing special
|
||||||
group (targets, or allies) in one session, since the fix making the
|
should happen crossing 26, it just keeps counting."""
|
||||||
id sequence shared across types (not per-type) made 26 much easier
|
|
||||||
to hit. 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 == "AA"
|
assert twenty_seventh.id == "27"
|
||||||
|
|
||||||
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 == "AA"
|
assert twenty_seventh_ally.id == "27"
|
||||||
|
|
||||||
|
|
||||||
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