FEnigma/TODO.md
Dominik Roth 5a35ea7776 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>
2026-08-13 19:49:30 +02:00

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) raises StopIteration the instant all 26 are taken, silently killing whatever button click triggered add_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 public icons.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 printed obj.type.value directly instead, so an Ally with the ad-hoc TargetType.ENEMY still showed "Enemy" everywhere except the picker itself. Fixed in app.py (_display_name, and the three spots using it).

  • Reordering firing commands lags UI hard. FiringPanel._reorder() was calling self.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 local self.refresh() instead.

  • "Always show geo" doesn't reliably work / blast radius should stay shown too. GridCanvas._draw_geo_overlays()'s candidate list was reference_points + targets only — Allies have a show_geo_desc pin in the UI and can carry OCR'd clues too, but were never drawn. Added. _draw_blast_radius() only ever looked at self.selected, ignoring show_geo_desc entirely, 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 except self.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.pysave_map_read_failure() writes the PNG + the solver's rejection reason under $XDG_DATA_HOME/fenigma/debug_captures/failures/, wired into app.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 accepted GridSolution isn'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_all found 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 of board.targets/board.allies taken when the grid is confirmed, Target/Ally are identity-hashable so these are plain sets of the live objects) let app.py tell "added while this screenshot was up" apart from "was already on the board". Proposal also now records confirmed_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 via debug_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: StopIteration from Board.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 clicked signal fires, the icon grid builds successfully (all N types), and Popover.set_child() on the already-open outer popover reports the right visible=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's show_type, _open_entity_menu's show_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), and GridCanvas draws 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 (PIL getbbox()), 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.