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>
16 KiB
Bug Backlog (from user report, 2026-08-11)
Status legend: [x] fixed+tested, [~] partially addressed, [ ] open/needs input
-
Allies and enemies seem to share indices. First pass on this was wrong: I only checked that targets and allies are separate id namespaces (they are, always were) and stopped there. The actual bug was one level down:
Board.add_target/add_ally's auto-id assignment (used = {t.id for t in self.targets if t.type == type_}) was scoped per type, not per group — a Tank and an Infantry auto-added back to back both got id "A", each type getting its own independent A/B/C... sequence instead of sharing one across the whole group. Fixed: the id namespace split is targets-vs-allies ONLY, type never subdivides it further. New regression test (test_auto_id_is_shared_across_types_within_targets_and_within_allies). -
Regression FROM the fix above, caught via a real traceback: sharing one A/B/C... sequence across a whole group (instead of per-type) made it much easier to actually run out of the 26 letters --
next(c for c in string.ascii_uppercase if c not in used)raisesStopIterationthe instant all 26 are taken, silently killing whatever button click triggeredadd_target/add_ally(this is what "Accept as"/"Accept all" doing nothing turned out to be, see below). Fixed with_next_free_id(): rolls over to two-letter ids ("AA", "AB", ...) instead of raising, can't run out. New test (test_auto_id_survives_past_26_entities_in_one_group). -
Ally type 'ally' is called Enemy on map title.
icons._target_type_label(now publicicons.target_type_label) already special-cased this for the type picker, but the map's right-click popover heading, "Change type (...)" button, and toast all printedobj.type.valuedirectly instead, so an Ally with the ad-hoc TargetType.ENEMY still showed "Enemy" everywhere except the picker itself. Fixed inapp.py(_display_name, and the three spots using it). -
Reordering firing commands lags UI hard.
FiringPanel._reorder()was callingself.on_change()— app.py's full-app refresh (re-solve every target's clue graph, dedupe, redraw the map, THEN rebuild the panel) — on every single drag-drop, even though reordering touches no location/clue/coord state at all. Now calls a localself.refresh()instead. -
"Always show geo" doesn't reliably work / blast radius should stay shown too.
GridCanvas._draw_geo_overlays()'s candidate list wasreference_points + targetsonly — Allies have ashow_geo_descpin in the UI and can carry OCR'd clues too, but were never drawn. Added._draw_blast_radius()only ever looked atself.selected, ignoringshow_geo_descentirely, so pinning it and then selecting/ deselecting something else made it vanish; now iterates every selected-or-pinned target. -
Clearing the board doesn't clear allies.
Board.clear()cleared everything exceptself.allies. Fixed, plus the "clear board?" confirm-dialog's early-return guard (which skipped the whole action if only allies were on the board) now checks allies too. -
Allow right-click on Clear button: clear all enemies/units/flights, keep spotters/RPs/nest. New
Board.clear_units()+ a right-click popover on the header's Clear button wired to it. -
On map-reading error: save a screenshot locally to adapt the algo. New
debug_capture.py—save_map_read_failure()writes the PNG + the solver's rejection reason under$XDG_DATA_HOME/fenigma/debug_captures/failures/, wired intoapp.py's_start_map_import. -
When the user corrects the grid, store screenshot + ground truth too.
debug_capture.save_grid_correction(), wired into_accept_grid: fires only when the acceptedGridSolutionisn't the one auto-solve produced (the user actually dragged a handle in GridFixDialog), saves both solutions under.../debug_captures/corrections/. -
Many map screenshots seem to get read as text; if nothing relevant is found, also store the image to check whether it was actually a map.
debug_capture.save_maybe_map(), wired into_ocr_png: fires when a screenshot (not a plain-text paste) fell through to the OCR/text path and_merge_allfound nothing at all. Saved under.../debug_captures/maybe_map/. -
Unable to parse 3 given chat messages (Infantry "taking fire" fire- support requests). A different grammar from the existing Marine Garrison fire-support request: reversed shell word order ("Requesting X Shell" vs "X Shells requested"), a bare "before/by
-
Follow-up bug in the above: the bearing/distance-offset variant names TWO different places (the reporting unit's own position, and a separate fire point offset from it), but only produced one Target entity, sitting at the offset point but still labeled with the unit's own type/id (e.g. "Infantry#11" at a spot no infantry is actually at). Math itself was right; the single-entity shape wasn't. Now produces two entries: the original (Infantry#N etc.) keeps its own reported position with no shell/deadline, and a new synthetic
Strike#<TypeWord><id>entry (e.g.Strike#Infantry11) carries the shell/deadline at the computed offset coord. 2 more regression tests. -
When the user deletes/replaces the map screenshot, capture whatever units they confirmed as ground truth for it.
ScreenshotImport.baseline_targets/baseline_allies(a snapshot ofboard.targets/board.alliestaken when the grid is confirmed,Target/Allyare identity-hashable so these are plain sets of the live objects) letapp.pytell "added while this screenshot was up" apart from "was already on the board".Proposalalso now recordsconfirmed_type(what the user actually accepted it as, which can differ from the detector's own guess via "Accept as..."). All of it -- every proposal's accept/reject/undecided verdict, plus every target/ally added with no matching proposal at all (a manual add or an OCR-text merge run alongside the screenshot) -- is saved viadebug_capture.save_marker_ground_truth()under.../debug_captures/marker_ground_truth/. Wired into all three places a screenshot stops being "the active one": explicit drop, a new screenshot pasted straight over it, and window close.
Resolved via a real traceback (not guessed)
- "Accept as" / "Accept all" on proposed targets doing nothing.
A real traceback from the running app nailed it:
StopIterationfromBoard.add_ally's id auto-assignment once 26 allies existed already (see the id-namespace regression entry above) — every accept attempt after that silently died before the ally/target ever got added, popover already closed by the time it happened. Fixed there; not a separate bug. - "Accept as…" (the type-picker submenu on a proposal, and "Change
type" on an already-placed entity) opening to a visibly empty/
unchanged popover. This one left no traceback at all -- confirmed
live with temporary debug prints that the button's
clickedsignal fires, the icon grid builds successfully (all N types), andPopover.set_child()on the already-open outer popover reports the rightvisible=True/width/height afterward... but the compositor never actually repaints that reused surface, so nothing new ever appeared on screen. Fixed by not resizing the existing open popover at all: popping it down and opening a genuinely new one (fresh native surface) at the same anchor point instead. Same fix applied to both call sites (_open_proposal_menu'sshow_type,_open_entity_menu'sshow_type, the latter refactored to share the same_reopen_with()helper). - New: mark a Target as underground, at a hardening tier (1-3),
rendered as the game's own Armor-tier additive badge stacked on
the icon.
Target.underground_tier: int | None, a "Mark underground" entry in the entity-edit popover (tier picker reusing the same fresh-popover fix above), andGridCanvasdraws the badge above the marker's icon, overlapping down into it by_ADDITIVE_OVERLAP_PX-- both the diamond icon's top corner and the badge's bottom are tapered to a near-point, not a flat edge, so bbox-exact touching still read as a gap; a real pixel overlap is what actually looks contiguous (confirmed against the game's own stacked-badge screenshots). Badge is scaled/positioned off the art's real opaque content (PILgetbbox()), not its PNG canvas -- the additive files carry a lot of off-center transparent padding that made the badge look tiny and floating if sized off the raw canvas.
Needs more scope / your input before I keep going
-
[~] Enemy type detection needs to be more robust; read the entity id label so dedup is reliable; detect death from the log.
Started on the id-reading piece: `map_vision.read_marker_id` reads each marker's own small "#<N>" label (distinct from the big per-cell grid label `read_cell_label` reads) via the SAME template- correlation approach as `read_cell_label`, not OCR -- this text sits over the same aerial-photo backdrop that this module's own docstring says defeated every detection-based approach tried for grid labels, so pytesseract (already tried elsewhere in this repo, `ocr.py`, for a different image domain: flat scanned paper, not photo-textured) was skipped in favor of the approach already proven here. Wired end-to-end: `find_markers` -> `Proposal.detected_id` -> `debug_capture.save_marker_ground_truth`'s JSON. Reads against `ScreenshotImport.full_image` (sharper than the WORK_W image detection itself runs against) when available. Crop region and `MIN_MARKER_ID_SCORE` are a single-screenshot calibration (see `read_marker_id`'s own docstring) -- UNVALIDATED against a real ground-truth batch (none of the 6 existing captures have a confirmed id to check against, they all predate this). New unit tests (`tests/test_map_vision_marker_id.py`) only cover the synthetic-render round-trip, not real-screenshot accuracy. Measured type-detection reliability against the 6 existing `marker_ground_truth` captures (72 accepted proposals total, 2026-08-13): **0/72 (0%) had ANY confident `detected_unit` guess** -- `classify_marker` returned `None` on every single one, every side, every capture. Not "guesses wrong" -- never confident enough to answer at all. Spot-checked directly against one real marker crop (a hostile Infantry, confirmed by the user): best match was "Underground Fort" at score 0.376 (Infantry wasn't even in the top 8), against a `min_score=0.55` floor `classify_marker` requires -- not a close miss, a real correlation failure. The clean rendered icon templates `icon_bank()` matches against apparently don't correlate well with how markers actually look in a real screenshot (compression/blur/aerial-photo texture underneath), unlike text glyphs (`read_cell_label`'s measured 0.73-0.87 vs 0.40-0.56) where the same template-correlation idea works well. Added `unit_score`/ `unit_margin` to `Proposal`/ground-truth JSON (previously only pass/fail `unit` was logged) so every future capture shows exactly how far off a guess was, not just None -- there was no way to tell "barely missed the bar" from "wildly wrong" before this. Death-detection-from-log is still fully unstarted -- no log-parsing 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 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 sequence per TYPE within each group -- Tank#A/Infantry#A rather than Tank#A/Infantry#B. 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. 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). 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 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.