From a30c3888d629ec1d94937c303756eec8984b81b2 Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Sat, 18 Jul 2026 12:31:42 +0900 Subject: [PATCH] fix: address multiple bugs causing incorrect battle handling and result screen issues - In arena.py, widened RESULT_MODAL_MAX_POLLS to 200 to accommodate real battle durations and added early exit check in _wait_for_result to prevent stuck screens. - Updated _fight_one to properly handle _wait_for_result's return value, ensuring failures stop the fight loop. - In bounty.py, implemented OCR verification in _confirm_dialog_is_sweep to ensure the correct confirmation dialog is displayed before proceeding with sweeps, preventing unintended battles. - Documented hazards and fixes in plan.md, detailing incidents from 2026-07-17 that led to these changes. --- ba_auto/config.py | 33 +++++++++++ ba_auto/reference_notes/mapping.md | 4 +- ba_auto/tasks/arena.py | 93 +++++++++++++++++++++++++++++- ba_auto/tasks/bounty.py | 62 ++++++++++++++++++++ plan.md | 25 ++++++++ 5 files changed, 212 insertions(+), 5 deletions(-) diff --git a/ba_auto/config.py b/ba_auto/config.py index 53509dc..d12708e 100644 --- a/ba_auto/config.py +++ b/ba_auto/config.py @@ -1065,6 +1065,39 @@ BOUNTY_RESULT_BUTTON_MIN_PIXELS = 3000 # date-ordinal-modulo scheme as STORY_SWEEP_ROTATION_*/EVENT_SWEEP_ROTATION_*. BOUNTY_SWEEP_COUNT = "max" +# Real-usage hazard, reported live 2026-07-17: the 任務情報 (task info) +# modal has TWO separate action buttons stacked vertically -- the cyan +# 掃討開始 (start sweep, safe, instant) this task intends to click, and a +# separate gold 任務開始 (start mission, a REAL manual battle) directly +# below it, both showing an identical "N→N-1" ticket-cost preview tooltip. +# A real run's log showed `_click_sweep_start_and_verify` succeeding +# cleanly (no retry messages) right after two failed +# `_click_max_and_verify` attempts, followed by `_watch_sweep_result` +# eventually reporting "swept" -- but the account was left showing a real +# "Battle Complete" result screen (a live combat timer, ~3 minutes, visible +# in a follow-up screenshot) instead of having done an instant sweep, and +# every task that ran afterward failed to open its own screen for the rest +# of that `daily` run. The exact mechanism that let this happen was not +# fully reproduced live (doing so would mean deliberately repeating a real +# battle), but every check along this path +# (_count_raised_above_one/_is_sweep_usage_confirm/_watch_sweep_result's +# own "swept" conditions) is a generic color/position probe with no check +# on WHAT is actually showing -- any of them could plausibly be satisfied +# by an unexpected screen (including a real battle's own UI) the same way +# login.py's single/few-point checks were repeatedly fooled by unexpected +# splash-art frames the same day (see plan.md's Phase 18 follow-up #3). +# +# Fixed with an OCR text check (_confirm_dialog_is_sweep in bounty.py) +# gating the FINAL, irreversible SWEEP_CONFIRM_BUTTON click -- ported +# directly from the same real, live-captured dialog +# ("指名手配チケットをN使用して、掃討をN回行いますか?", captured +# 2026-07-17 by reaching the real confirm dialog and cancelling before +# confirming, the same safe-calibration pattern the original Phase 15 +# bounty work used throughout) rather than trusting SWEEP_CONFIRM_CYAN's +# color match alone to mean "this is definitely the sweep confirm dialog +# and not some other cyan-styled confirmation." +BOUNTY_SWEEP_CONFIRM_TEXT_RECT = (605, 505, 1320, 615) + # Gem shop daily free package (毎日無料パッケージ), ported from # module/collect_daily_free_power.py. Reference reads: home-screen icon -> # purchase-pyroxenes dialog -> パッケージ (package) tab -> the FREE card at a diff --git a/ba_auto/reference_notes/mapping.md b/ba_auto/reference_notes/mapping.md index c97448d..80e834f 100644 --- a/ba_auto/reference_notes/mapping.md +++ b/ba_auto/reference_notes/mapping.md @@ -11,10 +11,10 @@ Maps each local feature to the corresponding `~/repo/baas-reference/module/...` | Normal/Hard story AP sweep | `module/explore_tasks/sweep_task.py`, `module/explore_tasks/task_utils.py` | `to_region` (ported: OCR region-number readout + delta-click), a scoped-down `swipe_search_target_str` (ported: OCR stage-row label matching), `start_sweep`'s named-outcome contract (ported via `navigation.wait_for_state`, this project's scoped `co_detect` port) | `ba_auto/tasks/story_sweep.py` | OCR region/stage-name matching, ported for real (Phase 10) — replaces Phase 9's "next-region arrow stops advancing, then random stage" heuristic; MAX click verified via `SWEEP_MINUS_BUTTON_PROBE` color check (reused, still correct), modal closed via its own X button (Escape doesn't close it; X-button position re-calibrated per stage-layout variant, see Phase 10) | Done (see `plan.md` Phase 10, supersedes Phase 9). Config-driven exact `(region, stage, count)` targets (`config.STORY_SWEEP_TARGETS`), not latest-region/random-stage. Opt-in only, not in default flow | | Event sweep | `module/sweep_activity.py`, `module/activities/activity_utils.py` | `activity_sweep` (main flow), `to_activity` (nav to the event's Story/Mission/Challenge tabs), `check_sweep_availability`/`color.check_sweep_availability` (SSS gate), `start_sweep`'s named-outcome contract (shared with story_sweep's, ported the same way via `navigation.wait_for_state`) | `ba_auto/tasks/event_sweep.py`, `ba_auto/navigation.py` (`return_to_home`) | `to_activity`'s bottom-nav-icon entry -> this client's home-screen event badge (`config.EVENT_BADGE_ICON`, confirmed live to be a rotating carousel -- see Status); the reference's config-string sweep-list parsing (arbitrary stage lists, per-stage float/fraction counts via `preprocess_activity_region`/`preprocess_activity_sweep_times`) -> a single date-ordinal-modulo rotation target over a fixed 9-12 sub-range, mirroring `story_sweep.py`'s own rotation, per explicit user direction; stage-number OCR (`detector.read_int`) replaces the reference's `swipe_search_target_str` template-button search, since this client's stage list only ever needs its bottom scroll extreme for the 9-12 target range | Done, with a live-reported bug fixed (see `plan.md`'s Phase 14 follow-up). Live-calibrated against nik-gpu 2026-07-10 against the currently-running "鉄道爆走事件" event (12 stages) -- zero real AP spent during calibration (every confirm dialog reached was cancelled via Escape, verified by the AP counter). The stage-info modal is structurally identical to story_sweep's (MIN/-/+/MAX stepper, same AP-confirm dialog reusing `SWEEP_CONFIRM_*`/`SWEEP_RESULT_BUTTON_REGION`), but simpler: no region navigation, one fixed modal layout confirmed across two different stages (09 and 12), and the modal closes on Escape (story_sweep's doesn't). The reference's SSS-availability gate for a never-cleared stage was never exercised (every stage 9-12 on this account was already 3-starred) -- `event_sweep.py` handles that gate the same defensive way story_sweep handles an unavailable target: if the MAX-button count-raise can't be verified, it aborts without spending AP rather than guessing. **Bug found on a real run**: the home-screen event badge turned out to be a rotating carousel (cycles between the current event's countdown and other notices, e.g. a finished event's leftover reward-claim reminder), so a click could land on a stale event's page instead. Fixed via a new shared `navigation.return_to_home` primitive (bounded press-back-until-home loop, built generically so other tasks can reuse it, per explicit user request) plus wrong-page detection in `_find_stage_row` (zero stage-row numbers OCR'd at all -> retry via `return_to_home`, up to 3 attempts). **Second bug found on the next real run** (after the above fix correctly recovered and correctly OCR'd the target row): the row's own 入場 (enter) button click had no retry, unlike every other click-then-confirm step in this module -- missed once, aborted the whole run. Fixed via `_open_stage_modal`, the same click-then-verify-then-retry pattern already used everywhere else in the file. **Third bug found on a third real run**: `_find_stage_row`'s wrong-page detection false-positived twice (a fresh navigation's list hadn't finished rendering on the first OCR pass) before self-correcting on the 3rd attempt, wasting expensive return-home retries on what was really just a timing race -- fixed with a cheap in-place rescan before concluding "wrong page." Also, once past that, the 掃討開始 (start sweep) click turned out to be the last bare, unretried click in the file -- fixed via `_click_sweep_start_and_verify`. **Fourth round, self-driven live iteration per explicit user direction (fix/deploy/run/screenshot/diagnose in a loop, no stopping to report)**: found two more root causes and reached the first confirmed real live sweep. (1) The badge carousel's auto-rotate timer is far slower than the retry window -- a run hit "wrong page" on all 3 attempts genuinely, confirmed by screenshot; fixed by discovering and clicking the carousel's own pagination dots directly (`config.EVENT_BADGE_DOT_X`) instead of hoping the ambiguous badge shows the right item. (2) A genuine cold-start settle delay (up to ~20s for the Quest tab's stage list to populate after a fresh navigation, most likely a one-time server round-trip), not a flaky race -- confirmed via standalone probe scripts polling the real OCR pipeline once/second for a minute; fixed by widening `STAGE_ROW_SCAN_ATTEMPTS`/`_RETRY_WAIT` to match. With both fixes, a real run completed end-to-end: 200 AP spent (10x MAX sweep), credits gained, clean return home, confirmed by screenshot -- see `plan.md`'s Phase 14 follow-up #4 for the full writeup. **Fifth round, reported by the user a day later**: the exact same wrong-page-looping symptom recurred. Per the user's explicit request, added a DIRECT check for the finished event's own "イベント期間が終了しました" text (Japanese OCR, newly installed on nik-gpu since this project previously only needed digit/English reads) instead of the indirect "zero valid rows" heuristic -- `config.EVENT_FINISHED_TEXT_RECT`/`_is_finished_event_page`, live-calibrated and confirmed both ways (exact phrase match on a real finished page, no false positive on the correct page). This immediately proved the badge carousel's dot-clicking (follow-up #4's fix) is NOT reliably controllable after all -- sometimes worked, sometimes didn't, on the identical badge state -- while simply waiting longer let the carousel's own auto-rotate timer land on the correct item independently. Fixed by widening `WRONG_PAGE_RETRIES`/`_WAIT` (3x3s -> 6x12s) to give the timer real room to cycle, keeping dot-clicking as a harmless supplementary nudge. Two more real sweeps confirmed this fully working (11x MAX sweep, 219 AP spent, credits gained, clean home return, confirmed by screenshot -- three real confirmed sweeps total across this whole investigation). Also found and fixed the real root cause of the lingering `"unrecognized_state"` cosmetic bug (not a budget issue after all): `_watch_sweep_result`'s "swept" condition required the stage modal to still be open, but this event's flow can auto-return all the way to the Quest list instead, a terminal state it never anticipated -- fixed with a second `ends` condition gated on having clicked at least one result button first. Not yet re-verified live (AP exhausted by the successful sweep). See `plan.md`'s Phase 14 follow-up #5 for the full writeup. **Sixth round (2026-07-11)**: the daily rotation picked stage 9 for the first time, hitting a previously-flagged-but-never-exercised gap -- rows 366/538 ("08"/"09") consistently misread by OCR ("2" and empty/None) on every psm mode, even though the crop looked completely clean by eye; confirmed NOT a navigation/timing bug since rows 710/883/1055 ("10"/"11"/"12") read fine in the same run. Root cause (`scratchpad/probe_ocr_fix_08_09.py`): tesseract's segmentation fails on this tight edge-to-edge crop (no whitespace margin) for a leading-zero digit pair specifically -- adding a plain white border around the upscaled crop before OCR fixed both digits exactly, at every psm mode, without affecting "10". Fixed via new `detector.read_int_bordered`, now used for all `EVENT_STAGE_ROW_Y` reads. **Confirmed live**: stage 9 found, sweep executed for real (AP 233->14, credits +5,892, screenshot-confirmed safe return home) -- the originally reported bug is fixed. The same run again logged `unrecognized_state`, proving follow-up #5's `clicked_any`-gate fix addressed a real but secondary issue, not the full story. Diagnosed at zero AP cost (`scratchpad/probe_result_button_fp.py`, checks `_find_result_button` against the plain Quest list with no sweep running): the shared `SWEEP_RESULT_BUTTON_REGION` (borrowed from `story_sweep.py`) reaches into this event's own character-art panel and false-positive-matches `SWEEP_CONFIRM_CYAN` there with no dialog showing at all, confirmed on both a wrong event page and the correct one's own plain list -- so `_watch_sweep_result` kept "finding" a result button after the real one was already dismissed, and its "modal closed, no result button" end condition could never match. Fixed with a new event_sweep-only `config.EVENT_SWEEP_RESULT_BUTTON_REGION` (x narrowed to exclude the character-art panel, still comfortably covering the real buttons), confirmed live against the actual false-positive condition (now returns `None` where it previously didn't) -- not yet re-confirmed via a fresh full sweep since AP was too low that day. See `plan.md`'s Phase 14 follow-up #6. | | Circle (Group/Club) daily check-in | `module/group.py` | `implement`, `to_group` | `ba_auto/tasks/circle.py` | Reference's `picture.co_detect` polling against fixed screen positions + `rgb_possible`/`img_possible` template states → this client's own 2-click nav (home → bottom-nav ソーシャル icon → サークル card) verified with the existing shared `navigation.is_modal_open`/`is_on_subscreen` (no new probe needed — pixel-confirmed both real "reached the circle screen" states, with or without the reward modal, satisfy that combined check) | Done. Live-calibrated 2026-07-15 on nik-gpu by driving the real flow end-to-end via raw xdotool/scrot — this **genuinely claimed the account's real circle check-in reward for the day** (+10 AP, confirmed via the real reward dialog "今日のサークルへの参加報酬... AP x10... 報酬はメールボックスから受け取ることができます"), which calibrated both real states from real data: the first-entry reward modal, and (by re-entering immediately after) the already-checked-in straight-to-chat state with no modal. Also live-confirmed a real `XIGNCODE` anti-cheat overlay stole a `BACK_BUTTON`-coordinate click mid-calibration (same recurring gotcha documented elsewhere in this project) — recovered via the standard `windowactivate`+`windowraise` escalation. Per explicit user direction, the task presses Escape directly to return home (confirmed live: a single Escape from the サークル screen returns straight to true home, skipping back through the intermediate ソーシャル hub page) rather than clicking `navigation.BACK_BUTTON`. The reference's `group_join-club` ("not in a circle") outcome is deliberately not ported — this account is already a member, and the task's scope (per explicit user direction) is entry only, no mailbox claim. The actual `circle.py` module was then live-tested for real via `./ba_dailies.sh circle`, correctly reading the "already checked in today" state left over from calibration and returning cleanly home. **Not yet live-tested**: the "first entry → claim reward" code path itself, since the account was already checked in for today by the time the module existed — same disclosed gap shape as gem_shop's own "available → claim" path. Added to `DEFAULT_ORDER` alongside mailbox/cafe/stamina/gem_shop, since it's a pure free reclaim with no decision to make. **A `reference-parity-reviewer` pass the same day caught and fixed three real gaps**: a too-generic terminal-state check that could false-positive "already checked in" from a stuck non-home starting state (fixed via `navigation.return_to_home(driver)` at the top of `run()`, matching arena.py/bounty.py precedent); an unverified reward-dismiss Enter press and closing Escape (fixed via `_dismiss_reward`/`_leave_circle`, bounded retry-until-verified loops directly porting `gem_shop.py`'s `_claim_free_package`/`_close_gem_shop`); and a missing `driver.focus_game()` escalation in the entry retry loop for the `XIGNCODE` overlay (which had already struck live during this task's own calibration) — fixed to match `navigation.click_back`'s own escalation convention. Re-confirmed live via `./ba_dailies.sh circle` after all three fixes. See `plan.md`'s Phase 17 for the full writeup. | -| Bounty | `module/rewarded_task.py` | `implement` (main flow, `get_task_count`/`purchase_bounty_ticket`/per-area `rewarded_task_status` loop, not ported -- see Status), `to_bounty`/`to_choose_bounty` (nav), `get_los`/`one_detect`/`bounty_common_operation` (per-row SSS-color scan + sweep) | `ba_auto/tasks/bounty.py` | `to_bounty`'s bottom-nav "bus" icon -> this client's Work-hub `指名手配` card (`config.BOUNTY_CARD`), landing directly on Location Select with no separate bus-icon sub-navigation step; `get_los`/`one_detect`'s per-row `color.check_sweep_availability` SSS-color scan across however many rows are visible -> a fixed bottom-most row click (`config.BOUNTY_LATEST_STAGE_ROW_Y`), since this client's 3 areas each have exactly 10 stages (confirmed live, scrolling past the 10th is a no-op) all already SSS-cleared, making "scroll to the bottom extreme, click the last row" equivalent to "the latest stage available" by construction; the reference's config-string per-area sweep-count list (`rewarded_task_times`, `get_task_count`) and its loop across all 3 areas -> a single date-ordinal-modulo rotation choosing ONE area per run, mirroring story_sweep.py/event_sweep.py's own rotation, per explicit user direction (2026-07-11); `purchase_bounty_ticket` (buying more tickets with real currency) -> not ported, matching plan.md's "OCR optional for coin balance/refresh logic, can skip for first version" | Done, live-tested for real -- see `plan.md` Phase 15. Live-calibrated against nik-gpu 2026-07-11 with **zero real tickets spent** during calibration (every ticket-usage confirm dialog reached was cancelled via Escape, verified by the ticket counter (6/6) unchanged before/after, across all 3 areas). All 3 areas (ハイウェイ/砂漠の線路/校舎, matching the reference's OVERPASS/DESSERT RAILWAY/CLASSROOM groupings) confirmed to share an identical layout: same area-row/stage-row/modal-button coordinates, same 10-stage-per-area structure. The 任務情報 modal's MAX stepper, minus-button raised-color check, and ticket-usage confirm dialog are pixel-identical to event_sweep.py's own stage modal / shared `SWEEP_CONFIRM_*` dialog -- confirmed live -- and reused directly. The modal-open probe could NOT be reused from `EVENT_STAGE_MODAL_PROBE`: that corner point reads dark on this screen regardless of modal state (different background art), so a fresh `BOUNTY_STAGE_MODAL_PROBE` was calibrated that does discriminate both states. **2 real sweeps confirmed live** (credits +180,000 then +36,000, clean automatic return home both times), which surfaced and fixed two real bugs: (1) `_set_sweep_count`'s count==1 path wrongly assumed the modal defaults to count=1 on open -- it actually remembers the last-used count -- causing an unintended 5-ticket spend instead of the intended 1; fixed via a new `_click_min_and_verify` that always forces a known baseline first. (2) `BOUNTY_SWEEP_RESULT_BUTTON_REGION`'s first-guess copy of event_sweep's own region overlapped `BOUNTY_SWEEP_START_BUTTON`'s real cyan pixels, causing `_find_result_button` to re-click it once tickets hit 0 -- which surfaced a real Pyroxene ticket-purchase prompt (no gem actually spent, confirmed by an unchanged balance, but a real near-miss). Fixed three ways: the region was corrected to the real pixel-scanned OK-button bbox; a new `min_pixels` parameter on `detector.find_color_centroid` (plus `config.BOUNTY_RESULT_BUTTON_MIN_PIXELS`) filters out a second, smaller contamination source (stray cyan-range pixels in the modal's own reward-icon artwork); and `_watch_sweep_result` now has an explicit `_is_ticket_purchase_prompt` guard as a named `ends` condition. **Not yet re-confirmed live**: a fresh sweep with the fixes deployed (account at 0/6 tickets as of session end) and any bulk/MAX-count sweep's result-screen flow (only count=1 was ever tested; a bulk sweep may show a SKIP-then-OK sequence like story_sweep/event_sweep instead of the single-OK dialog confirmed here). | +| Bounty | `module/rewarded_task.py` | `implement` (main flow, `get_task_count`/`purchase_bounty_ticket`/per-area `rewarded_task_status` loop, not ported -- see Status), `to_bounty`/`to_choose_bounty` (nav), `get_los`/`one_detect`/`bounty_common_operation` (per-row SSS-color scan + sweep) | `ba_auto/tasks/bounty.py` | `to_bounty`'s bottom-nav "bus" icon -> this client's Work-hub `指名手配` card (`config.BOUNTY_CARD`), landing directly on Location Select with no separate bus-icon sub-navigation step; `get_los`/`one_detect`'s per-row `color.check_sweep_availability` SSS-color scan across however many rows are visible -> a fixed bottom-most row click (`config.BOUNTY_LATEST_STAGE_ROW_Y`), since this client's 3 areas each have exactly 10 stages (confirmed live, scrolling past the 10th is a no-op) all already SSS-cleared, making "scroll to the bottom extreme, click the last row" equivalent to "the latest stage available" by construction; the reference's config-string per-area sweep-count list (`rewarded_task_times`, `get_task_count`) and its loop across all 3 areas -> a single date-ordinal-modulo rotation choosing ONE area per run, mirroring story_sweep.py/event_sweep.py's own rotation, per explicit user direction (2026-07-11); `purchase_bounty_ticket` (buying more tickets with real currency) -> not ported, matching plan.md's "OCR optional for coin balance/refresh logic, can skip for first version" | Done, live-tested for real -- see `plan.md` Phase 15. Live-calibrated against nik-gpu 2026-07-11 with **zero real tickets spent** during calibration (every ticket-usage confirm dialog reached was cancelled via Escape, verified by the ticket counter (6/6) unchanged before/after, across all 3 areas). All 3 areas (ハイウェイ/砂漠の線路/校舎, matching the reference's OVERPASS/DESSERT RAILWAY/CLASSROOM groupings) confirmed to share an identical layout: same area-row/stage-row/modal-button coordinates, same 10-stage-per-area structure. The 任務情報 modal's MAX stepper, minus-button raised-color check, and ticket-usage confirm dialog are pixel-identical to event_sweep.py's own stage modal / shared `SWEEP_CONFIRM_*` dialog -- confirmed live -- and reused directly. The modal-open probe could NOT be reused from `EVENT_STAGE_MODAL_PROBE`: that corner point reads dark on this screen regardless of modal state (different background art), so a fresh `BOUNTY_STAGE_MODAL_PROBE` was calibrated that does discriminate both states. **2 real sweeps confirmed live** (credits +180,000 then +36,000, clean automatic return home both times), which surfaced and fixed two real bugs: (1) `_set_sweep_count`'s count==1 path wrongly assumed the modal defaults to count=1 on open -- it actually remembers the last-used count -- causing an unintended 5-ticket spend instead of the intended 1; fixed via a new `_click_min_and_verify` that always forces a known baseline first. (2) `BOUNTY_SWEEP_RESULT_BUTTON_REGION`'s first-guess copy of event_sweep's own region overlapped `BOUNTY_SWEEP_START_BUTTON`'s real cyan pixels, causing `_find_result_button` to re-click it once tickets hit 0 -- which surfaced a real Pyroxene ticket-purchase prompt (no gem actually spent, confirmed by an unchanged balance, but a real near-miss). Fixed three ways: the region was corrected to the real pixel-scanned OK-button bbox; a new `min_pixels` parameter on `detector.find_color_centroid` (plus `config.BOUNTY_RESULT_BUTTON_MIN_PIXELS`) filters out a second, smaller contamination source (stray cyan-range pixels in the modal's own reward-icon artwork); and `_watch_sweep_result` now has an explicit `_is_ticket_purchase_prompt` guard as a named `ends` condition. **Not yet re-confirmed live**: a fresh sweep with the fixes deployed (account at 0/6 tickets as of session end) and any bulk/MAX-count sweep's result-screen flow (only count=1 was ever tested; a bulk sweep may show a SKIP-then-OK sequence like story_sweep/event_sweep instead of the single-OK dialog confirmed here). **Real hazard found and fixed via a real unattended `daily` cron run (2026-07-17)**: reported live by the user -- a real "Battle Complete" screen (a live ~3-minute combat timer) was found on the account instead of an instant sweep, with `_watch_sweep_result` having reported "swept" cleanly and no warnings anywhere in the log; every task that ran for the rest of that `daily` invocation failed to open its own screen. Manually reproducing the flow live (reaching the real confirm dialog and cancelling before confirming, the same safe-calibration pattern the original live-testing used) found the actual hazard: the 任務情報 modal has TWO separate action buttons stacked vertically -- the intended cyan 掃討開始 (start sweep, instant) and a separate gold 任務開始 (start mission, a REAL manual battle) directly below it, both showing an identical ticket-cost preview. Every check along the commit path (`_count_raised_above_one`/`_is_sweep_usage_confirm`/`_watch_sweep_result`'s own "swept" conditions) is a generic color/position probe with no verification of what's actually showing. Fixed with `_confirm_dialog_is_sweep`, an OCR text check (`config.BOUNTY_SWEEP_CONFIRM_TEXT_RECT`, captured live from the real dialog: "指名手配チケットをN使用して、掃討をN回行いますか?") gating the one irreversible click in this flow -- cancels rather than confirms if the dialog doesn't read as a sweep. The exact mechanism that let a real run reach 任務開始 instead of 掃討開始 was not fully reproduced live (would mean deliberately repeating a real battle); the fix closes the hazard regardless of the exact upstream cause. Not yet re-verified live against a fresh real sweep. | | Login | `core/Baas_thread.py`, `module/restart.py` | `to_main_page` (generic post-launch arrival routine, reused by every other reference feature's own navigation -- no separate "login" module exists in the reference), `restart.py`'s `implement`/`start` (check app running, launch if not) | `ba_auto/tasks/login.py` | Reference detects every one-off popup (~20 named `img_reactions`/`rgb_possibles`) via `picture.co_detect` image-template matching; this port scopes down to what was actually confirmed live (title screen via a fixed-chrome logo-color probe, a real network-error notice, the daily attendance card, S.C.H.A.L.E NEWS) plus a bounded generic Enter-press fallback for anything else recognized, mirroring co_detect's own "blind action once nothing matches" fallback shape but using this project's own established Enter-dismiss idiom rather than a blind coordinate click. `module/restart.py`'s kill-then-relaunch pattern ported directly via new `driver.kill_game`/`launch_game`/`is_game_running`/`window_exists` primitives (`pkill -f`/the account's own `/usr/local/bin/launch-blue-archive.sh`/`pgrep -f`/`xdotool search`) | Done. Live-calibrated 2026-07-16 on nik-gpu against the account's real overnight login-screen state (native 1920x1200 captures throughout, not the non-native `screenshots/daily_login/*.png` reference photos originally supplied -- same not-1:1 gap already documented for `screenshots/gem_shop/`/`screenshots/cafe/student/`). Confirmed live: the title screen's own logo reads a fixed brand-chrome color independent of rotating seasonal background art (confirmed across two different pieces), bright when clean and uniformly dimmed when a real notice is open on top of it (a genuine "ネットワークへの接続に失敗しました" network error surfaced unprompted during calibration); the daily attendance card only appears once per day (confirmed absent on an immediate same-day re-run after being claimed) and, like the network notice, responds to a plain Enter with no dedicated detection needed; the S.C.H.A.L.E NEWS popup needed a dedicated header-color probe since `navigation.is_on_subscreen`/`is_modal_open` both proved unreliable on it (same class of mismatch as `gem_shop.py`'s own dialog -- confirmed live by direct pixel comparison). **A real stuck-loading incident hit live during calibration itself**: the loading transition (a full-bleed variant with no chrome, distinct from a brief chrome-visible variant also seen) stalled past 6 minutes with zero progress, confirmed not a network/process-health issue; the user's own live guidance ("kill the game and rerun it") identified the relaunch script, and a second attempt after manually killing+relaunching completed the entire remaining flow (title tap -> attendance card -> home) in well under 15 seconds, confirming the stall was a genuine stuck state now handled automatically by `_recover`. Also observed live (twice, non-deterministically) but deliberately NOT worked around: a known pre-existing client rendering bug (per the user) where the news popup's own promo image can get stuck as a blank white rectangle after closing -- confirmed harmless to this module specifically (sits clear of every probe point used here, and the shared home-check probes still read correctly through it), and the user's own fix for it (reload the app) is already this module's existing stuck-recovery path. Added as the very first step in `DEFAULT_ORDER`, ahead of mailbox, since no other task can reach home from the title/loading/attendance-card states on its own. **Not yet live-confirmed**: the infrequent 業務復帰ログインボーナス welcome-back login bonus card from the user's own reference screenshots (account wasn't in that state during calibration) -- expected to fall through to the same generic-Enter path already confirmed for the attendance card and network notice, but not yet exercised for real; and the kill+relaunch recovery path has only been exercised once, manually, not yet through a fresh invocation of the actual `login.py` module hitting a real stuck state on its own. **Connectivity-probe fix, `_connectivity_confirmed` (2026-07-17)**: reported live by the user via real pulled cron logs -- a 4:30 AM `daily` fire logged a clean `[login] reached home`, but every task that ran afterward (cafe, event_sweep, circle, lesson, arena, both shops, gem_shop, mailbox, stamina) failed to open its own screen, persisting across multiple consecutive `q4h` fires spanning hours. Per the user's own direct knowledge: the server's daily reset or a concurrent login from another device can silently kill the session while the client keeps showing a cached-looking, visually normal home screen -- the error only surfaces as the game's own "connection lost" popup once an actual navigation/API call is attempted, which `_true_home`'s purely-visual checks never triggered. Fixed per the user's own suggested design ("enter the stamina claim/reward area then go back home"): `_connectivity_confirmed` now opens the Mission panel and closes it again before `_wait_for_home` will return success. Reproducing this live (the user logged into the account on their phone to trigger a real session kill) surfaced two FURTHER false positives in the same family, both from an unusually long, multi-frame animated loading sequence that never resolved on its own: first with `navigation.is_on_subscreen` (single-pixel) as the panel-opened check, then again after upgrading to `navigation.is_header_bar_visible` (8-point) -- each independently got fooled by a different coincidental splash frame within the same run. Fixed by requiring `_true_home` to re-verify a second time, after a short wait, before finally trusting a passed connectivity check. **Confirmed live**: the stuck session was cleared with a manual kill+relaunch, then a genuinely fresh cold-start `login` run (game process not running at all) correctly launched the game, passed through the title screen, and reached independently-verified true home (full HUD -- Lv/AP/credits/gems/bottom nav all visible, confirmed via direct screenshot and a separate `_true_home`/`is_header_bar_visible` check, not just the module's own printed message). Not yet re-confirmed against a fresh instance of the original silent-session-death failure mode specifically (the live reproduction available this session was the full-kick-to-login-screen variant, not the "still looks like home but isn't" variant from the cron logs), though the same `_connectivity_confirmed` mechanism covers both by design. | | Commissions | `module/clear_special_task_power.py` | Need to inspect | `ba_auto/tasks/commission.py` | sweep/color adaptation | Not started | -| Arena | `module/arena.py` | `implement` (main flow), `to_tactical_challenge` (nav from main page), `get_tickets` (ticket-count OCR), `choose_enemy` (self/opponent level OCR + bounded refresh-reroll loop), `check_skip_button` (skip-toggle color probe), `fight` (click fight, wait for win/lose), `collect_tactical_challenge_reward` (two reward-slot color probes) | `ba_auto/tasks/arena.py` | Ticket count/self level/opponent level/rank are plain digit OCR (`detector.read_int`/`read_text`, plus a new `read_int_white_on_dark` for the profile card's bright-on-dark level text). Skip-toggle state and the two reward-slot claimed-vs-claimable colors are plain pixel-color probes (`driver.color_at`/`_color_in_range`). The post-fight WIN/LOSE result modal and an unrelated list-refresh-expired notice are NOT detected by precisely locating a button (a color-region search proved unreliable — see Status); they're dismissed via a bounded blind-Enter-press loop matching `lesson.py`'s own `_run_one_schedule` pattern, gated by a hard safety check against the one modal where Enter is dangerous (opponent-info's own attack-formation button, checked via its fixed-position gold button). `choose_enemy`'s refresh-reroll loop is direct Python control flow, bounded by `maxArenaRefreshTimes`. Config knobs carried over from reference defaults: `ArenaComponentNumber`=1, `ArenaLevelDiff`=0, `maxArenaRefreshTimes`=10, `ArenaStopFightWhenRank1`=False | Done. Live-tested for real across all 5 of the account's daily tickets (2 WIN, 1 LOSE, 2 spent debugging the result-modal detection — see `plan.md` Phase 13 for the full writeup). Real navigation differences confirmed live: Tactical Challenge is a Work-hub card, not a bottom-nav icon; the reference's separate opponent-info and formation-edit screens are merged into one modal here with a live ticket-preview; `navigation.is_modal_open`'s shared probe reads *inverted* on this screen (own `_is_modal_open` via `config.ARENA_MODAL_PROBE`). Three real bugs fixed: a level-OCR crop too small for tesseract despite looking legible (fixed by widening the crop, not the pipeline); level text being bright-on-dark unlike every other OCR read in this project (fixed via `read_int_white_on_dark`); and the result-modal detection cycling through two failed color-based designs (fixed by switching to bounded blind-Enter dismissal with a hard safety gate — a real near-miss of the same "mistimed keypress" hazard class `CLAUDE.md` already documents from story_sweep). Deliberately opt-in only, never in `DEFAULT_ORDER` — unlike every other opt-in task so far (which spend a known-safe resource on a config-driven target list), this one fights a real ranked PvP battle that can win or lose and moves the account's actual arena rank. Per explicit user decision: fights exactly one battle per invocation, matching the reference's own per-call pacing (its `next_time = 55` background-thread rescheduling has no equivalent in this project's one-shot CLI). Not yet exercised live: the "no ticket" mid-flow race, an actual reroll click (every opponent offered was already an acceptable level), and `ArenaStopFightWhenRank1`'s rank-1 stop condition — all implemented per the reference's logic, just not yet hit by real game state. `detector.find_template`/`template_visible` (generalized named-template matcher, built during scaffolding) ended up unused — state detection stayed OCR/color-probe-driven throughout, like every other task in this project. Follow-up (2026-07-11): live bug report — this task never returned home at the end, so a second consecutive invocation starting from wherever the first left the game (the Tactical Challenge screen itself) sent `_open_tactical_challenge`'s home-relative clicks to the wrong place and failed all 3 retries. Fixed by calling the shared `navigation.return_to_home(driver)` at the very start of `run()`, reusing the generic Escape-based recovery primitive built for `event_sweep.py`'s own wrong-page recovery. Confirmed live (2026-07-11): reproduced the stuck-on-arena-screen scenario manually, then a real `arena` invocation recovered and completed a full fight normally (rank 14位's opponent-list entry moved 10位→9位, ticket 2→1, credits +1,080), with no repeat of the original failure. Follow-up #2 (2026-07-12), per explicit user request: reversed the original "exactly one battle per invocation" decision — `_fight_one` now holds the single-battle logic and `run()` loops it (`while tickets > 0 and fights < config.ARENA_MAX_FIGHTS_PER_RUN`), re-reading the OCR'd ticket count after each fight and waiting `config.ARENA_POST_BATTLE_COOLDOWN` (30s, per the user's own info about the real in-game lockout between fights) before continuing. `ArenaStopFightWhenRank1` is now re-checked before every fight in the loop, not just once. See `plan.md`'s Phase 13 follow-up #2 — not yet live-tested (spends multiple real tickets, needs the user's go-ahead first). | +| Arena | `module/arena.py` | `implement` (main flow), `to_tactical_challenge` (nav from main page), `get_tickets` (ticket-count OCR), `choose_enemy` (self/opponent level OCR + bounded refresh-reroll loop), `check_skip_button` (skip-toggle color probe), `fight` (click fight, wait for win/lose), `collect_tactical_challenge_reward` (two reward-slot color probes) | `ba_auto/tasks/arena.py` | Ticket count/self level/opponent level/rank are plain digit OCR (`detector.read_int`/`read_text`, plus a new `read_int_white_on_dark` for the profile card's bright-on-dark level text). Skip-toggle state and the two reward-slot claimed-vs-claimable colors are plain pixel-color probes (`driver.color_at`/`_color_in_range`). The post-fight WIN/LOSE result modal and an unrelated list-refresh-expired notice are NOT detected by precisely locating a button (a color-region search proved unreliable — see Status); they're dismissed via a bounded blind-Enter-press loop matching `lesson.py`'s own `_run_one_schedule` pattern, gated by a hard safety check against the one modal where Enter is dangerous (opponent-info's own attack-formation button, checked via its fixed-position gold button). `choose_enemy`'s refresh-reroll loop is direct Python control flow, bounded by `maxArenaRefreshTimes`. Config knobs carried over from reference defaults: `ArenaComponentNumber`=1, `ArenaLevelDiff`=0, `maxArenaRefreshTimes`=10, `ArenaStopFightWhenRank1`=False | Done. Live-tested for real across all 5 of the account's daily tickets (2 WIN, 1 LOSE, 2 spent debugging the result-modal detection — see `plan.md` Phase 13 for the full writeup). Real navigation differences confirmed live: Tactical Challenge is a Work-hub card, not a bottom-nav icon; the reference's separate opponent-info and formation-edit screens are merged into one modal here with a live ticket-preview; `navigation.is_modal_open`'s shared probe reads *inverted* on this screen (own `_is_modal_open` via `config.ARENA_MODAL_PROBE`). Three real bugs fixed: a level-OCR crop too small for tesseract despite looking legible (fixed by widening the crop, not the pipeline); level text being bright-on-dark unlike every other OCR read in this project (fixed via `read_int_white_on_dark`); and the result-modal detection cycling through two failed color-based designs (fixed by switching to bounded blind-Enter dismissal with a hard safety gate — a real near-miss of the same "mistimed keypress" hazard class `CLAUDE.md` already documents from story_sweep). Deliberately opt-in only, never in `DEFAULT_ORDER` — unlike every other opt-in task so far (which spend a known-safe resource on a config-driven target list), this one fights a real ranked PvP battle that can win or lose and moves the account's actual arena rank. Per explicit user decision: fights exactly one battle per invocation, matching the reference's own per-call pacing (its `next_time = 55` background-thread rescheduling has no equivalent in this project's one-shot CLI). Not yet exercised live: the "no ticket" mid-flow race, an actual reroll click (every opponent offered was already an acceptable level), and `ArenaStopFightWhenRank1`'s rank-1 stop condition — all implemented per the reference's logic, just not yet hit by real game state. `detector.find_template`/`template_visible` (generalized named-template matcher, built during scaffolding) ended up unused — state detection stayed OCR/color-probe-driven throughout, like every other task in this project. Follow-up (2026-07-11): live bug report — this task never returned home at the end, so a second consecutive invocation starting from wherever the first left the game (the Tactical Challenge screen itself) sent `_open_tactical_challenge`'s home-relative clicks to the wrong place and failed all 3 retries. Fixed by calling the shared `navigation.return_to_home(driver)` at the very start of `run()`, reusing the generic Escape-based recovery primitive built for `event_sweep.py`'s own wrong-page recovery. Confirmed live (2026-07-11): reproduced the stuck-on-arena-screen scenario manually, then a real `arena` invocation recovered and completed a full fight normally (rank 14位's opponent-list entry moved 10位→9位, ticket 2→1, credits +1,080), with no repeat of the original failure. Follow-up #2 (2026-07-12), per explicit user request: reversed the original "exactly one battle per invocation" decision — `_fight_one` now holds the single-battle logic and `run()` loops it (`while tickets > 0 and fights < config.ARENA_MAX_FIGHTS_PER_RUN`), re-reading the OCR'd ticket count after each fight and waiting `config.ARENA_POST_BATTLE_COOLDOWN` (30s, per the user's own info about the real in-game lockout between fights) before continuing. `ArenaStopFightWhenRank1` is now re-checked before every fight in the loop, not just once. See `plan.md`'s Phase 13 follow-up #2 — not yet live-tested (spends multiple real tickets, needs the user's go-ahead first). **This DID subsequently run live via cron and surfaced a real hazard (2026-07-17)**: the log showed "battle skip already on" for all 5 fights and a clean "fought 5 battle(s) this run", but the account was left showing a real "Battle Complete" screen with a ~3-minute combat timer -- battles had run in full, not skipped -- and every task for the rest of that `daily` invocation failed to open its own screen. Two compounding bugs: (1) `_ensure_skip_on`'s "on" detection was never actually verified to reject a real "off" state (`ARENA_SKIP_ON_RGB` was only ever confirmed against a session where skip happened to already be on); (2) `_wait_for_result` had no early-exit signal and always ran its full fixed 15s budget before unconditionally declaring success -- far too short for a real battle -- and `_fight_one` discarded `_wait_for_result`'s return value entirely, so even a correctly detected failure never stopped the fight loop. Fixed by widening `RESULT_MODAL_MAX_POLLS` substantially (tolerating a real battle's full duration regardless of whether the skip-mode detection gets fully root-caused) plus a new `_back_on_challenge_list` early exit (so the normal fast case doesn't slow down), and by making `_fight_one` actually stop on a `_wait_for_result` failure. The skip-toggle's own "off" detection was not independently re-verified (would need a real ticket at a moment the account had none left) -- deliberately structured so the fix is safe even if that root cause isn't fully resolved. | | Common Shop | `module/shop/common_shop.py`, `module/shop/shop_utils.py` | `implement`, `to_common_shop`, `get_item_position`/`ensure_choose`/`buy` (shared, see Tactical Shop row) | `ba_auto/tasks/shop_common.py`, `ba_auto/tasks/shop_utils.py` | `get_item_position`'s color+template item-state scan → fixed grid-position targets (`config.COMMON_SHOP_TARGETS`) + price-digit OCR verify, since the reference's own item-identification here indexes an external static price table (`self.static_config.common_shop_price_list`, fetched from a remote resource) this repo doesn't have — not per-item OCR, so this isn't an OCR-avoidance shortcut. Purchase-confirm dialog + reward-acquired banner handled via a single overlay-darkness probe (`config.SHOP_OVERLAY_PROBE`) instead of tracking each dialog's own layout | Done. Live-tested with real purchases (all 8 configured targets bought, cost matched exactly). Discovered live: these items have a per-refresh-cycle purchase cap not shown as a visible counter (unlike the 青輝石 tab's "あと1回購入可能" labels) — confirmed by re-running the task after purchase and observing it correctly detect the now-unselectable items (checkbox + individual 購入 button both unresponsive) and safely decline rather than guess. A fresh, everything-available run hasn't been re-verified since the account had already exhausted this cycle's purchases via that same test | | Tactical Shop | `module/shop/tactical_challenge_shop.py`, `module/shop/shop_utils.py` | `implement`, `goto_shop_by_name`, shared `get_item_position`/`ensure_choose`/`buy` | `ba_auto/tasks/shop_tactical.py`, `ba_auto/tasks/shop_utils.py` | `goto_shop_by_name`'s OCR swipe-search over the shop-tab list → fixed click (`config.SHOP_TAB_TACTICAL`): this account's tab list is only 7 entries and fits on screen with no scroll needed, confirmed live, so there's nothing to search for — not an OCR-avoidance shortcut. Same grid-position + price-OCR-verify + overlay-probe design as Common Shop, sharing `shop_utils.run_shop_tab` | Done. Live-tested with real purchases (both configured AP-recovery drinks bought; AP and tactical-coin balance changes matched exactly) | | Lesson/Schedule | `module/lesson.py` | `implement`, `to_lesson_location_select`/`to_select_location`/`to_all_locations` (nav state machine), `get_lesson_region_num`/`switch_lesson_region_page`/`to_lesson_region` (paged region nav), `get_lesson_each_region_status`+`check_region_availability` (per-cell status via isometric-parallelogram pixel scan), `get_lesson_relationship_counts` (per-cell affection pip count via color count), `choose_lesson` (selection policy), `execute_lesson`/`to_location_info`/`start_lesson` (click cell -> info panel -> start -> result) | `ba_auto/tasks/lesson.py` | `picture.co_detect` -> `navigation.wait_for_state`-style bounded Enter-press loop (see below); the reference's paged-arrow region nav (needing OCR to know current position) -> this client renders the 12 regions as a scrollable list instead, which only ever settles at two scroll positions (`config.LESSON_REGION_ROW_Y`), so navigation is direct index-based clicking with nothing to OCR-locate; the reference's isometric `Parallelogram`/`Triangle` per-cell scan (tuned to the reference's own screen layout) -> reading each portrait's heart-shaped affection badge via a dedicated OCR path (`detector.read_int_on_heart_badge`) needs no isometric geometry at all | Done. Config-driven scope only in the sense of the *policy* (affection-first selection, sweep every unlocked region until tickets/lessons run out, no ticket purchasing, no favor-student targeting) -- unlike shop, no user-specific target list was needed since the reference's own `lesson_region_name.JP` (embedded directly in its `default_config.py`, not externally fetched) already names all 12 regions, used here only for logging. Live-tested for real: 5 real tickets spent across 3 regions with correct outcomes (ticket count, cleanup navigation, home-screen return all verified). Two real bugs were found and fixed from that run -- see below and `plan.md`'s Lesson phase. **Regression fix (Phase 12 follow-up)**: `LESSON_TICKET_OCR_RECT`'s left edge clipped in a stray katakana fragment next to the first digit, making tesseract drop the whole leading digit (`"7/7"` -> `"/7"`) and aborting every run outright; fixed by tightening the rect, re-validated with a full real run (7 tickets spent, correct count re-read after every schedule, clean stop and home-screen return). **Regression fix (Phase 12 follow-up #2)**: clicking the schedule icon doesn't always land on the Location Select list -- the game can resume directly on whichever region's per-region isometric map was last open (confirmed live: a previous run's Ctrl-C interruption left it stuck there, breaking `_open_region_grid` for every region in the next run identically). Fixed via a new `lesson._ensure_location_select_list` recovery check (detects the per-region map's own "すべてのスケジュール" button already showing before any row's been clicked, and returns via the back button if so); validated by deliberately reproducing the stuck state and confirming a real run recovered, spent the account's real remaining ticket, and finished cleanly. **Selection-policy rewrite (Phase 12 follow-up #3, 2026-07-13)**: per explicit user direction, replaced "always pick the single highest affection value" with a tiered min/max-farming priority -- any 3-student cell anywhere first, then any 2-student cell anywhere, then single-student cells sorted lowest-affection-first. This needs the whole board's state before deciding, not just the current region's, so the flow is now scan-all-then-execute (`_scan_all_regions`/`_build_priority_queue`/`_run_queue`) rather than the old per-region sweep-and-pick-best loop (`_find_best_cell`/`_sweep_region`, both removed). Verified offline against a synthetic board (correct tier ordering) and live against the real board via a zero-cost scan-only call (69 real schedulable cells found across 12 regions -- 7 triples, 30 doubles, 32 singles -- correctly bucketed and the singles tail exactly ascending by real affection value), with zero tickets spent since the account was fully out that day. Real ticket-spending execution (`_run_queue` actually running schedules) is not yet live-tested -- deferred to the user once tickets regenerate. | diff --git a/ba_auto/tasks/arena.py b/ba_auto/tasks/arena.py index 1efd367..2604cfd 100644 --- a/ba_auto/tasks/arena.py +++ b/ba_auto/tasks/arena.py @@ -67,12 +67,58 @@ that mean nothing from wherever the previous run left the game. Fixed by calling the shared navigation.return_to_home(driver) at the very start of run(), before _open_tactical_challenge -- the same generic Escape-based recovery primitive event_sweep.py uses for its own wrong-page recovery. + +Real hazard found via a real unattended `daily` cron run (2026-07-17), +reported live by the user: the log showed "battle skip already on" printed +for all 5 fights and a clean "fought 5 battle(s) this run", but the account +was left showing a real "Battle Complete" result screen with a ~3-minute +combat timer -- battles had actually run in full, not skipped -- and every +task that ran for the rest of that `daily` invocation failed to open its +own screen. Two compounding bugs, both fixed: (1) `_ensure_skip_on`'s "on" +detection has never actually been verified to reject a real "off" state +(see config.py's ARENA_SKIP_ON_RGB comment -- skip happened to already be +on every session calibrated so far), so a wrong "already on" reading was +never caught and the toggle was never actually clicked; (2) +independently, and more directly responsible for the stuck screen, +`_wait_for_result` had no early-exit signal at all and always ran its full +fixed budget (10*1.5=15s) before unconditionally declaring success -- far +too short for a real, non-skipped battle, and `_fight_one` on top of that +discarded `_wait_for_result`'s return value entirely, so even a correctly +*detected* failure (including the dangerous opponent-info-modal case its +own hard safety gate exists to catch) never actually stopped `run()`'s +fight loop from proceeding to the next ticket. Fixed by widening +`RESULT_MODAL_MAX_POLLS` substantially (to tolerate a real battle's full +duration regardless of whether the skip-mode detection bug gets fully +root-caused) while adding a new `_back_on_challenge_list` early exit (so +the normal fast-skip-mode case doesn't also slow down), and by making +`_fight_one` actually stop the loop when `_wait_for_result` reports +failure. The skip-toggle's own "off" detection was NOT independently +re-verified (would require spending a real ticket at a moment the account +had none left) -- this fix is deliberately structured to make the +consequences safe even if that underlying detection is still wrong, rather +than depending on fully root-causing it first. """ from ba_auto import detector, navigation OPEN_RETRIES = 3 -RESULT_MODAL_MAX_POLLS = 10 +# Widened from 10 (real hazard found live 2026-07-17, reported by the user): +# this loop originally always ran its full fixed budget with no early exit, +# so at the old 10*1.5=15s bound it silently assumed success even when +# _ensure_skip_on's "already on" check was wrong and a fight actually ran +# as a real, non-skipped battle (confirmed live: a "Battle Complete" result +# screen with a ~3-minute combat timer was found on the account afterward, +# and every task that ran for the rest of that `daily` invocation failed to +# open its own screen). _ensure_skip_on's own "off" detection has never +# actually been verified live (see config.py's ARENA_SKIP_ON_RGB comment -- +# it was only ever confirmed to recognize "on", ON by default every session +# so far), so this bound is widened generously to tolerate a real battle +# regardless of whether that gets fixed, rather than trusting skip mode to +# always engage. The new _back_on_challenge_list early-exit (see +# _wait_for_result) means this larger bound doesn't slow down the normal, +# already-working case -- a fast skip-mode fight still exits within the +# first couple of polls, same as before. +RESULT_MODAL_MAX_POLLS = 200 RESULT_POLL_INTERVAL = 1.5 @@ -221,6 +267,19 @@ def _opponent_info_modal_showing(driver, config): return _color_in_range(driver.color_at(*config.ARENA_ATTACK_FORMATION_BUTTON), config.ARENA_REWARD_CLAIMABLE_RGB) +def _back_on_challenge_list(driver, config): + # The natural "nothing left to dismiss, the fight genuinely wrapped up" + # signal -- the same navigation.is_on_subscreen check + # _open_tactical_challenge itself trusts to confirm this exact screen, + # combined with this module's own (inverted-reading) _is_modal_open to + # rule out a dialog still open on top of it. Added alongside widening + # RESULT_MODAL_MAX_POLLS (2026-07-17) so a real, non-skipped battle's + # much longer wait doesn't also slow down the normal fast-skip-mode + # case -- lets _wait_for_result's loop exit as soon as a fight is + # actually done instead of always blindly running its full budget. + return navigation.is_on_subscreen(driver) and not _is_modal_open(driver, config) + + def _wait_for_result(driver, config): """Dismiss whatever unpredictable sequence of screens follows Sortie: the "対戦結果" WIN/LOSE modal, and occasionally an unrelated "list @@ -250,18 +309,38 @@ def _wait_for_result(driver, config): rather than risk an unintended second ticket spend the way a mistimed keypress caused a real hazard elsewhere in this project (see CLAUDE.md's story_sweep writeup). + + Real hazard found live (2026-07-17, reported by the user): this loop + originally had no early exit at all -- it always ran its full fixed + budget (10*1.5=15s), assuming success once that budget elapsed. When + _ensure_skip_on's "battle skip already on" check was wrong (see + config.py's ARENA_SKIP_ON_RGB comment -- its "off" detection was never + actually verified live), a fight ran as a real, non-skipped battle + (confirmed via a screenshot: a "Battle Complete" result screen with a + ~3-minute combat timer), and this loop declared the result handled + after 15s regardless -- while the real battle was still playing. Every + task that ran for the rest of that `daily` invocation then failed to + open its own screen, because the game was never actually back at a + known state. Fixed two ways: RESULT_MODAL_MAX_POLLS widened + substantially to tolerate a real battle's full duration regardless of + whether the skip-mode bug gets fixed separately, and a new + _back_on_challenge_list early exit so this widened budget doesn't also + slow down the normal fast-skip-mode case -- the loop now exits as soon + as a fight is genuinely confirmed over, not after a fixed guess. """ for _ in range(RESULT_MODAL_MAX_POLLS): if _opponent_info_modal_showing(driver, config): print("[arena] warning: opponent-info modal unexpectedly showing during result wait -- stopping without pressing Enter to avoid an unintended ticket spend") return False + if _back_on_challenge_list(driver, config): + return True driver.keypress(config.ARENA_RESULT_CONFIRM_KEY) driver.wait(RESULT_POLL_INTERVAL) if _opponent_info_modal_showing(driver, config): print("[arena] warning: opponent-info modal showing after result-wait timeout -- leaving as-is without pressing Enter") return False - return True + return _back_on_challenge_list(driver, config) def _fight_one(driver, config): @@ -292,7 +371,15 @@ def _fight_one(driver, config): driver.keypress(config.ARENA_SORTIE_CONFIRM_KEY) driver.wait(2) - _wait_for_result(driver, config) + # Real bug found alongside the _wait_for_result fixes above + # (2026-07-17): this used to discard _wait_for_result's return value + # and always report success regardless, so even a detected problem -- + # including the dangerous opponent-info-modal case its own hard safety + # gate exists to catch -- never actually stopped run()'s fight loop + # from proceeding to the next ticket. + if not _wait_for_result(driver, config): + print("[arena] result not confirmed after the fight -- stopping without attempting further fights") + return False return True diff --git a/ba_auto/tasks/bounty.py b/ba_auto/tasks/bounty.py index c045cf7..fdaf1b2 100644 --- a/ba_auto/tasks/bounty.py +++ b/ba_auto/tasks/bounty.py @@ -86,6 +86,38 @@ bulk/MAX-count sweep's result-screen flow specifically -- only count=1 was ever tested (ticket scarcity forced it), so it's not yet confirmed whether a bulk sweep shows the same single-OK dialog or a SKIP-then-OK sequence like story_sweep/event_sweep's own bulk-sweep result screens. + +Real hazard found and fixed via a real unattended `daily` cron run +(2026-07-17), reported live by the user: a real "Battle Complete" screen (a +live combat timer, ~3 minutes) was found on the account instead of an +instant sweep having happened, and every task that ran afterward in that +same `daily` invocation failed to open its own screen for the rest of the +run -- this task's own `_watch_sweep_result` had reported "swept" cleanly, +with no warnings anywhere in the log. Manually reproducing the flow live +(reaching the real confirm dialog and cancelling before confirming, same +safe-calibration pattern as the original Phase 15 work) found the actual +hazard: the 任務情報 modal has TWO separate action buttons stacked +vertically -- the intended cyan 掃討開始 (start sweep, instant) and a +separate gold 任務開始 (start mission, a REAL manual battle) directly below +it, both showing an identical "N→N-1" ticket-cost preview. Every check +along this task's own commit path (`_count_raised_above_one`, +`_is_sweep_usage_confirm`, `_watch_sweep_result`'s own "swept" conditions) +is a generic color/position probe with no verification of WHAT is actually +showing -- the exact same class of false-positive risk that repeatedly +fooled login.py's single/few-point checks the same day (see plan.md's +Phase 18 follow-up #3), just with much higher stakes here (an actual +battle, not a misread loading screen). The precise mechanism that let a +real run reach 任務開始 instead of 掃討開始 was not fully reproduced live +(doing so would mean deliberately repeating a real battle) -- but the fix +closes the hazard regardless of the exact upstream cause: `_confirm_dialog_ +is_sweep` OCR-verifies the confirm dialog's actual text +("指名手配チケットをN使用して、掃討をN回行いますか?", captured live from +the real dialog) before the one irreversible click in this whole flow, and +cancels rather than confirms if it doesn't read as a sweep. Not yet +re-verified live against a fresh real sweep (would need to spend another +real ticket) -- the dialog-text capture and cancel-without-confirming were +both done live, but the actual gate has not yet been exercised by a +genuine full run. """ import datetime @@ -119,6 +151,23 @@ def _is_ticket_purchase_prompt(driver, config): return _color_in_range(driver.color_at(*config.SWEEP_CONFIRM_BUTTON), config.SWEEP_CONFIRM_GOLD) +def _confirm_dialog_is_sweep(driver, config): + """OCR-verify the confirm dialog reached after clicking 掃討開始 is + genuinely the sweep-usage confirm ("指名手配チケットをN使用して、掃討を + N回行いますか?"), not some other cyan-styled confirmation that happens + to satisfy _is_sweep_usage_confirm's color-only check -- see + config.py's BOUNTY_SWEEP_CONFIRM_TEXT_RECT for the real incident this + guards against (a real battle got triggered instead of a sweep, and + every check along that path was a generic color/position probe with no + check on what was actually showing). Substring match on "掃討" rather + than an exact match, matching this project's own established + dialog-classification convention (cafe.py's 衣装/隣 checks, + event_sweep.py's "終了" check). + """ + text = detector.read_text(config.BOUNTY_SWEEP_CONFIRM_TEXT_RECT, psm=6, lang="jpn") + return "掃討" in text + + def _find_result_button(driver, config): return detector.find_color_centroid( config.BOUNTY_SWEEP_RESULT_BUTTON_REGION, *config.SWEEP_CONFIRM_CYAN, @@ -345,6 +394,19 @@ def _sweep_latest_stage(driver, config, area_index, count): _close_stage_modal(driver, config) return "inadequate_ticket" + # Hard safety gate before the one irreversible click in this whole + # flow -- see config.py's BOUNTY_SWEEP_CONFIRM_TEXT_RECT for the real + # incident (a real battle got triggered instead of a sweep) this + # closes. _is_sweep_usage_confirm already passed (a color match), but + # that alone isn't proof this is really the sweep dialog -- verify the + # actual text before committing rather than trusting color alone. + if not _confirm_dialog_is_sweep(driver, config): + print("[bounty] confirm dialog text did not read as a sweep confirmation -- cancelling without confirming (see config.py's BOUNTY_SWEEP_CONFIRM_TEXT_RECT)") + driver.click(*config.SWEEP_CONFIRM_CANCEL_BUTTON) + driver.wait(1) + _close_stage_modal(driver, config) + return "unrecognized_state" + driver.click(*config.SWEEP_CONFIRM_BUTTON) driver.wait(1.5) print("[bounty] sweep confirmed, waiting for results") diff --git a/plan.md b/plan.md index 39174ea..aeae7f6 100644 --- a/plan.md +++ b/plan.md @@ -587,6 +587,19 @@ This is a deliberate reversal of the original Phase 13 design decision (exactly **Not yet live-tested.** Deployed and remote-compiled; running this for real will fight multiple real ranked battles and take several minutes (multiple 30s cooldowns) — confirm with the user before spending real arena tickets on a live test, per this project's established pattern for any live test of a newly-changed resource-spending flow. +#### Phase 13 follow-up #3: battles ran unskipped, and a stuck result screen broke the rest of a real `daily` run (2026-07-17) + +Follow-up #2's multi-fight loop did eventually run live, via cron, and fought all 5 of the account's real daily tickets — but the user reported the `daily` run left the game stuck, with a real "Battle Complete" screen visible (a live ~3-minute combat timer). The log had shown "battle skip already on" for all 5 fights and "fought 5 battle(s) this run" with no warnings, yet the battles had clearly run in full, not skipped — and every task that ran afterward in that same `daily` invocation failed to open its own screen for the rest of the run (this was the same incident whose actual stuck screen the user later pinpointed to bounty, not arena — see Phase 15 follow-up above — but the arena skip-mode issue found along the way was independently real and confirmed). + +Two compounding bugs, both fixed: + +1. `_ensure_skip_on`'s "on" detection (`ARENA_SKIP_ON_RGB`) has never actually been verified live to reject a real "off" state — the config.py comment already flagged this at calibration time: skip happened to already be on by default every session tested so far, so only the "on" color was ever confirmed, not the boundary against "off". If the toggle were ever genuinely off, this check could plausibly still read "already on" and never attempt to click it. +2. Independently, and more directly responsible for the stuck screen: `_wait_for_result` had no early-exit signal at all — it always ran its full fixed budget (`RESULT_MODAL_MAX_POLLS=10 * RESULT_POLL_INTERVAL=1.5s` = 15s) before unconditionally declaring success, far too short for a real, non-skipped battle. On top of that, `_fight_one` discarded `_wait_for_result`'s return value entirely (`_wait_for_result(driver, config); return True`), so even a *correctly detected* failure — including the dangerous opponent-info-modal case its own hard safety gate exists to catch — never actually stopped `run()`'s fight loop from proceeding to the next ticket. + +Fixed without requiring a full root-cause of bug 1 (which would need spending a real ticket to re-verify, and the account had none left that day): `RESULT_MODAL_MAX_POLLS` widened substantially (200 polls, up to 300s) to tolerate a real battle's full duration regardless of whether the skip-mode detection is ever separately fixed, paired with a new `_back_on_challenge_list` early-exit check (`navigation.is_on_subscreen` plus the module's own inverted-reading `_is_modal_open`) so the normal, already-working fast-skip-mode case doesn't also get slowed down — a fight that genuinely finishes quickly still exits the loop within the first couple of polls, same as before. `_fight_one` now correctly propagates `_wait_for_result`'s return value, so a detected failure actually stops the loop instead of being silently ignored. + +Deliberately structured so the fix is safe even if the skip-toggle detection bug is never separately root-caused: the worst case is now "a real battle takes as long as it takes and the code correctly waits for it," not "the code guesses wrong after 15 seconds and leaves the game in an unknown state for every subsequent task to fail against." Not yet re-verified live (arena tickets were at 0 by the time this was diagnosed) — both the widened wait and the `_fight_one` propagation fix, and the skip-toggle's own "off" detection remains unverified. + ### Phase 14: Event sweep **Status: Implemented, calibrated live against zero real AP spend, NOT yet live-tested with a real sweep.** Ported `module/sweep_activity.py` -> `module/activities/activity_utils.py`'s `activity_sweep`/`start_sweep` to `ba_auto/tasks/event_sweep.py`, per explicit user request (2026-07-10): "the current event has up to 12 stages, randomly choose stage 9-12, same mod%4 date method as story sweep." @@ -709,6 +722,18 @@ With that fixed, the user approved spending the account's one remaining ticket o **Confirmed real**: 2 live sweeps total, credits gained both times (+180,000 then +36,000), clean automatic return home both times, zero manual intervention, zero real Pyroxene spent despite the near-miss. **Not yet re-confirmed live**: a fresh sweep with today's fix deployed (account is at 0/6 tickets as of session end) and any bulk/MAX-count sweep's result-screen flow specifically (only count=1 was ever tested, since ticket scarcity forced it — a bulk sweep may show a SKIP-then-OK sequence like story_sweep/event_sweep rather than the single-OK dialog confirmed here). +#### Phase 15 follow-up: real battle triggered instead of a sweep (2026-07-17) + +A real unattended `daily` cron run reported live by the user: instead of an instant sweep, the account was left showing a real "Battle Complete" result screen (a live ~3-minute combat timer, visible in a follow-up screenshot), and every task that ran for the rest of that `daily` invocation failed to open its own screen. `_watch_sweep_result` had reported "swept" cleanly, with no warnings anywhere in the log — the false success was silent. + +The user first suspected arena (which had its own, separate, genuinely confirmed skip-mode bug the same day — see Phase 13 follow-up #3 below), but direct real-time observation ("When you run the script I saw the screen go out from bounty") placed the actual stuck screen specifically at bounty's own run. + +Diagnosed by safely reproducing the real flow live (reaching the actual confirm dialog and cancelling before confirming — the same safe-calibration pattern the original Phase 15 work used throughout, applied again rather than guessing): the 任務情報 modal has TWO separate action buttons stacked vertically — the intended cyan 掃討開始 (start sweep, instant, safe) and a separate gold 任務開始 (start mission, a REAL manual battle) directly below it, both showing an identical "N→N-1" ticket-cost preview tooltip. Every check along `_sweep_latest_stage`'s own commit path (`_count_raised_above_one`, `_is_sweep_usage_confirm`, `_watch_sweep_result`'s own "swept" ends conditions) is a generic color/position probe with zero verification of *what* is actually showing — the same class of false-positive risk that independently, repeatedly fooled login.py's single/few-point checks the same day (Phase 18 follow-up #3), just with far higher stakes here. + +Live-captured the real confirm dialog's actual text for the first time — "指名手配チケットを1使用して、掃討を1回行いますか?" — by carefully driving `bounty.py`'s own functions one step at a time (open the stage 10 modal — genuinely 3-starred/cleared, contradicting an early "uncleared stage" theory — MAX then MIN the count back down to the safest 1, click 掃討開始, screenshot, then cancel via Escape without ever clicking OK). Fixed with a new `_confirm_dialog_is_sweep` OCR check (`config.BOUNTY_SWEEP_CONFIRM_TEXT_RECT`, substring match on "掃討" matching this project's established dialog-classification convention — cafe.py's 衣装/隣 checks, event_sweep.py's "終了" check) gating the one truly irreversible click in this whole flow: if the dialog doesn't read as a sweep confirmation, cancel rather than confirm. + +The exact mechanism that let a real run reach 任務開始 instead of 掃討開始 was not fully reproduced live — doing so would mean deliberately repeating a real battle, which wasn't warranted once the actual hazard (no text verification anywhere in this path) was clearly identified and closed. The fix addresses the hazard regardless of the exact upstream cause. Not yet re-verified live against a fresh real sweep (would need to spend another real ticket). + ### Phase 16: Gem shop daily free package (2026-07-15) Reference: `module/collect_daily_free_power.py`. Local: `ba_auto/tasks/gem_shop.py`. Requested directly by the user with reference screenshots (`screenshots/gem_shop/1-4.png`): claim the 毎日無料パッケージ (daily free package, +10 AP / +10,000 credits, 0 yen, once per day) inside the 青輝石購入 (gem purchase) dialog. This is the exact piece Phase 8 deliberately deferred (see its follow-up above) — that phase stopped at "this menu has real-money purchase buttons visible immediately," but the specific free card is a fixed, non-selectable, no-decision claim, same category as Phase 8's own Mission-panel claim.