diff --git a/CLAUDE.md b/CLAUDE.md index bda0367..6c26b99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -641,12 +641,13 @@ When debugging on `nik-gpu`, copy relevant screenshots or debug images back into ## Existing features -Current project state: mailbox, cafe, stamina, story_sweep, shop_common, shop_tactical, lesson, and arena are all migrated to real Python. No Bash feature logic remains. +Current project state: mailbox, cafe, stamina, story_sweep, event_sweep, shop_common, shop_tactical, lesson, arena, and bounty are all migrated to real Python. No Bash feature logic remains. - `ba_dailies.sh` is a thin launcher that execs `ba_daily.py` -- `ba_daily.py` dispatches `mailbox`, `cafe`, `stamina`, `story_sweep`, `shop_common`, `shop_tactical`, `lesson`, `arena`, and default flow to `ba_auto/tasks/` +- `ba_daily.py` dispatches `mailbox`, `cafe`, `stamina`, `story_sweep`, `event_sweep`, `shop_common`, `shop_tactical`, `lesson`, `arena`, `bounty`, and default flow to `ba_auto/tasks/` - default flow is `mailbox`, `cafe`, `stamina` -- `story_sweep`, `shop_common`, `shop_tactical`, `lesson`, and `arena` are opt-in only since they spend AP/credits/tactical coin/lesson tickets/an arena ticket rather than reclaiming something free +- `story_sweep`, `event_sweep`, `shop_common`, `shop_tactical`, `lesson`, `arena`, and `bounty` are opt-in only since they spend AP/credits/tactical coin/lesson tickets/an arena ticket/a bounty ticket rather than reclaiming something free +- every task, whether run individually or as part of the default flow, self-heals back to the home screen both before it starts and after it ends — `ba_daily.py`'s `_run_task()` calls a retrying `_ensure_home()` before dispatch and wraps the dispatch itself in a `try/finally` calling `navigation.return_to_home()`, so cleanup runs regardless of success, an early-return failure, or an uncaught exception. The pre-task check is self-healing, not a hard gate, per explicit user direction: if `_ensure_home()` still can't confirm home after its own bounded retries, the task is attempted anyway rather than aborted, trusting each task's own click-then-verify steps to fail safely if the starting state really was bad. This is centralized rather than duplicated per-task; see plan.md's "Return-to-home audit" (and its self-heal-not-abort follow-up) for why (a real audit found most tasks had little to no reliable cleanup on several paths) and for a real bug this surfaced and fixed in `navigation.is_on_subscreen`/`return_to_home` itself (a modal open on top of a subscreen was indistinguishable from the true home screen using the header-brightness probe alone — fixed by also checking `is_modal_open`) - `ba_auto/tasks/mailbox.py` and `ba_auto/tasks/cafe.py` click with `ba_auto/driver.py` primitives and verify state with `driver.color_at` and `ba_auto/navigation.py` - mailbox and cafe were ported from the reference patterns around `module/mail.py` and `module/cafe_reward.py` - no legacy bridge remains @@ -663,7 +664,7 @@ Current project state: mailbox, cafe, stamina, story_sweep, shop_common, shop_ta - lesson was live-tested with real tickets spent (see `plan.md` Phase 12), which surfaced two real bugs from that assumption gap plus an OCR contamination issue — both fixed; see Phase 12 for the full writeup - a later regression broke `LESSON_TICKET_OCR_RECT` entirely (a stray katakana fragment at the crop's left edge made tesseract drop the leading digit, `"7/7"` reading as `"/7"`, aborting every run) — fixed by tightening the rect; re-validated live with 7 real tickets spent and correct re-reads after every schedule. See `plan.md`'s "Phase 12 follow-up" - a second regression then surfaced: 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 (a previous run's Ctrl-C interruption left it stuck there), which broke navigation for every region identically since the list's scroll/row-click logic doesn't apply to that screen. Fixed via `lesson._ensure_location_select_list`, which detects the per-region map's own "all schedules" button already showing and returns via the back button before sweeping. Validated by deliberately reproducing the stuck state and confirming recovery with a real ticket spend. See `plan.md`'s "Phase 12 follow-up #2" -- `ba_auto/tasks/arena.py` fights exactly one ranked Tactical Challenge (Arena) battle per invocation (not "spend every ticket" — the reference itself only fights one per call, relying on its own background scheduler for pacing, which this project has no equivalent for), then collects both reward slots +- `ba_auto/tasks/arena.py` fights ranked Tactical Challenge (Arena) battles in a loop until the OCR'd ticket count reaches 0 (per explicit user direction, 2026-07-12 — this reverses the original one-battle-per-invocation design, which matched the reference's own per-call pacing via its background scheduler; see `plan.md`'s Phase 13 follow-up #2), waiting `config.ARENA_POST_BATTLE_COOLDOWN` (30s) between fights for the real in-game lockout, then collects both reward slots once at the end - Tactical Challenge is reached via a card inside the お仕事 (Work) hub, 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-count preview confirming the real fight-commit click - arena was live-tested for real across all 5 of the account's daily tickets (2 WIN, 1 LOSE, 2 spent debugging), which surfaced three real bugs: a level-OCR crop too small for tesseract despite looking legible to the eye, level text being bright-on-dark unlike every other OCR read in this project (fixed via a new `detector.read_int_white_on_dark`), and — most importantly — the post-fight WIN/LOSE result modal proving undetectable by precisely locating its own confirm button (WIN and LOSE are different heights; widening the search region to cover both then caught stray cyan-ish pixels in the opponent list's own portrait art, false-positive-clicking into an unrelated opponent's info modal). Fixed by abandoning per-button color detection for 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 genuinely dangerous — the opponent-info modal's own attack-formation button is also Enter-bound and spends a real ticket. See `plan.md` Phase 13 for the full writeup - `ba_auto/detector.py` has `find_cafe_sparkle()`, the sparkle template-match ported in-process from the now-deleted `scripts/detect_and_click.py`, and `find_template()`/`template_visible()`, a generalized named-template matcher built for arena but ultimately unused there — state detection stayed OCR/color-probe-driven throughout, like every other task @@ -691,6 +692,8 @@ Live testing found both the mailbox-icon and cafe-icon fixed coordinates were fl See `plan.md` Phases 5–6 for the full writeup. This is the concrete reason every task now verifies state before acting rather than trusting fixed coordinates or a single click blindly. +`is_on_subscreen`'s header-brightness probe alone cannot tell "the true home screen" apart from "a subscreen with a modal open on top" — a modal's own screen-wide dimming overlay darkens the header probe point the same way home's own background art can, confirmed live via direct pixel comparison (2026-07-12). `navigation.return_to_home` now checks a combined `_not_home` helper (`is_on_subscreen(driver) or is_modal_open(driver)`) instead of `is_on_subscreen` alone, so it can no longer mistake a stuck modal for having reached home. See `plan.md`'s "Return-to-home audit" for the live incident this was found from (a task's fixed home-relative click coordinates landed on the wrong screen entirely because of this exact false reading) and the fix. + Rank-up popups mid-pat-loop are now handled. A pat that crosses an affection-rank threshold shows a full-screen `絆ランクアップ!` cutscene with no cafe header. `find_cafe_sparkle()` can never recognize this because it is nothing like the sparkle template. The loop used to spin uselessly against it for the rest of the room's click budget. diff --git a/ba_auto/config.py b/ba_auto/config.py index 13832c4..bf2ba14 100644 --- a/ba_auto/config.py +++ b/ba_auto/config.py @@ -564,6 +564,24 @@ ARENA_LEVEL_DIFF = 0 # accept an opponent up to this many levels above self (ne ARENA_MAX_REFRESH_TIMES = 10 # give up rerolling for an acceptable opponent after this many refreshes ARENA_STOP_FIGHT_WHEN_RANK1 = False # if True and current rank OCRs as 1, skip fighting and just collect rewards +# Per explicit user direction (2026-07-12): arena.py now loops fighting +# battles until the OCR'd ticket count reaches 0, rather than exactly one +# battle per invocation (see arena.py's module docstring for the full +# history of that earlier design and why it changed). ARENA_MAX_FIGHTS_ +# PER_RUN is a defensive upper bound only -- not a hardcoded assumption of +# the account's real daily ticket count (5, per the user, but read live via +# OCR every run like everything else in this project) -- guarding against a +# runaway loop if ticket-count OCR ever misreads persistently, matching the +# project's established bounded-retry convention (OPEN_RETRIES, +# RETURN_HOME_MAX_ROUNDS, etc.). ARENA_POST_BATTLE_COOLDOWN is the real +# in-game lockout after a battle finishes before the next one can be +# queued, per the user's explicit info -- not yet independently +# live-confirmed against the exact UI symptom (a disabled button vs. a +# genuinely unresponsive click) since the user described it directly rather +# than this being discovered through live probing. +ARENA_MAX_FIGHTS_PER_RUN = 10 +ARENA_POST_BATTLE_COOLDOWN = 30 + # Live-calibrated against nik-gpu on 2026-07-09 (see scratchpad/arena_calib_* # for the captured screenshots this was pixel-scanned/OCR-tested against). # This client does NOT expose Tactical Challenge as a bottom-nav icon like diff --git a/ba_auto/navigation.py b/ba_auto/navigation.py index f845d40..451beb1 100644 --- a/ba_auto/navigation.py +++ b/ba_auto/navigation.py @@ -28,20 +28,41 @@ def is_modal_open(driver): return r < MODAL_DIM_MAX_CHANNEL and g < MODAL_DIM_MAX_CHANNEL and b < MODAL_DIM_MAX_CHANNEL +def _not_home(driver): + # "Home" means neither on a subscreen NOR under an open modal. Checking + # only is_on_subscreen was found live (2026-07-11, ba_daily.py's + # centralized return-to-home audit) to be unsound whenever a modal is + # open on top of a subscreen: the modal's own screen-wide dimming + # overlay darkens SUBSCREEN_HEADER_PROBE right along with everything + # else, so it reads exactly like the true home screen's own dark + # header art -- is_on_subscreen returns False in BOTH cases, and + # return_to_home was mistaking "subscreen with a stuck modal open" for + # "home reached" and stopping immediately without ever pressing + # anything. Confirmed via direct pixel comparison: a real stuck event + # stage-info modal read (80,81,82) at SUBSCREEN_HEADER_PROBE (fails the + # >200 check, same as home), while MODAL_DIM_PROBE correctly read dark + # there (51,37,28) and correctly read NOT dark on the true home screen + # (126,136,210) -- so checking both together tells the two apart. + return is_on_subscreen(driver) or is_modal_open(driver) + + def return_to_home(driver): """Bounded "press back until the home screen is reached" loop -- the shared recovery path for any task that ends up on an unexpected or wrong subscreen (e.g. event_sweep landing on a stale/finished event's page instead of the current one, see plan.md's Event sweep phase). Generic - across tasks: it only depends on is_on_subscreen and the shared - BACK_BUTTON position above, not on any task-specific state. + across tasks: it only depends on is_on_subscreen/is_modal_open and the + shared BACK_BUTTON position above, not on any task-specific state. - Checks is_on_subscreen before every single press and stops the instant - it reads False -- never presses Escape/clicks back while already on the + Checks _not_home before every single press and stops the instant it + reads False -- never presses Escape/clicks back while already on the home screen. This matters: CLAUDE.md documents a real hazard where a blind Escape press on the home screen itself raises Blue Archive's own "exit the game?" confirmation, which is exactly the failure mode a - naive fixed-count blind-press loop could cause here. + naive fixed-count blind-press loop could cause here. Escape maps to + Cancel on that confirmation dialog too (confirmed live across every + dialog in this project), so even a wrongly-timed press against it is + safe -- it never presses Enter/OK. Tries Escape first each round (works for most subscreens, confirmed for mailbox/cafe/event's own stage modal) and falls back to clicking @@ -52,15 +73,15 @@ def return_to_home(driver): don't guess further" rather than assume home was reached. """ for _ in range(RETURN_HOME_MAX_ROUNDS): - if not is_on_subscreen(driver): + if not _not_home(driver): return True driver.keypress("Escape") driver.wait(1) - if not is_on_subscreen(driver): + if not _not_home(driver): return True driver.click(*BACK_BUTTON) driver.wait(1) - return not is_on_subscreen(driver) + return not _not_home(driver) def wait_for_state(driver, config, reactions, ends, max_iterations=30, poll_interval=1.0): diff --git a/ba_auto/reference_notes/mapping.md b/ba_auto/reference_notes/mapping.md index 59dba3b..a6d1f30 100644 --- a/ba_auto/reference_notes/mapping.md +++ b/ba_auto/reference_notes/mapping.md @@ -12,9 +12,9 @@ Maps each local feature to the corresponding `~/repo/baas-reference/module/...` | Group/Club AP | `module/group.py` | Need to inspect | `ba_auto/tasks/group.py` | fixed click + state check via local driver | Not started | | 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). | | 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. | +| 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). | | 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 | +| 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. | Do not implement a feature without filling at least the relevant row. diff --git a/ba_auto/tasks/arena.py b/ba_auto/tasks/arena.py index 75c4da4..1efd367 100644 --- a/ba_auto/tasks/arena.py +++ b/ba_auto/tasks/arena.py @@ -19,16 +19,25 @@ This client differs from the reference in a few confirmed ways: screen specifically (confirmed live: the list's own background art is darker than the modal's white card at that exact point) -- this module has its own `_is_modal_open` using config.ARENA_MODAL_PROBE instead. -- Per explicit user decision (2026-07-09): this fights exactly ONE battle - per invocation, matching the reference's own per-call pacing. The - reference relies on its always-running background thread rescheduling - itself 55 minutes later (`self.next_time = 55`) for the next ticket; this - project's one-shot-per-invocation CLI has no equivalent, so spending - additional tickets means rerunning this task (e.g. via cron), not an - internal loop. Reward collection runs unconditionally at the end of every - invocation instead of the reference's "only if this was the last ticket" - rule -- both reward slots are idempotent/harmless to check every run, and - there's no scheduler here to guarantee a later invocation will do it. +- Originally (2026-07-09) this fought exactly ONE battle per invocation, + matching the reference's own per-call pacing (the reference relies on its + always-running background thread rescheduling itself 55 minutes later via + `self.next_time = 55`, which this project's one-shot CLI has no + equivalent for). Per explicit user direction (2026-07-12), this now loops + internally instead: `_fight_one` fights a single battle, and `run()` calls + it repeatedly until the OCR'd ticket count reaches 0 (or + config.ARENA_MAX_FIGHTS_PER_RUN, a defensive bound only -- not a + hardcoded assumption of the account's real daily ticket count, which is + read live every run like everything else in this project), waiting + config.ARENA_POST_BATTLE_COOLDOWN (30s, per the user) between fights for a + real in-game lockout after a battle finishes before the next one can be + queued. `config.ARENA_STOP_FIGHT_WHEN_RANK1` is now re-checked before + every fight in the loop, not just once before the first -- rank can + change mid-run from fighting. Reward collection still runs unconditionally + once at the end regardless of how the loop exits (ticket exhaustion, a + rank-1 stop, or a fight that didn't complete cleanly) rather than the + reference's "only if this was the last ticket" rule -- both reward slots + are idempotent/harmless to check regardless. Live-discovered gotchas around the post-fight result (see config.py's ARENA_RESULT_CONFIRM_KEY comment for the full writeup -- three real bugs @@ -255,6 +264,38 @@ def _wait_for_result(driver, config): return True +def _fight_one(driver, config): + """Fight exactly one battle: reroll to an acceptable opponent, commit to + attack formation (spends a ticket), sortie, and wait out the result. + Returns True once through to the result being dismissed, False if any + step along the way couldn't be confirmed -- callers should stop looping + on False rather than guess whether it's safe to try again immediately. + """ + slot_index = config.ARENA_COMPONENT_NUMBER - 1 + _choose_enemy(driver, config, slot_index) + + if not _open_opponent_modal(driver, config, slot_index): + print("[arena] opponent-info modal not detected, aborting without spending a ticket") + return False + + print("[arena] committing to attack formation (spends a ticket)") + driver.click(*config.ARENA_ATTACK_FORMATION_BUTTON) + driver.wait(2) + + if not navigation.is_on_subscreen(driver): + print("[arena] attack-formation screen not detected after commit -- ticket may already be spent, check manually. Aborting without pressing further keys") + return False + + _ensure_skip_on(driver, config) + + print("[arena] sortie") + driver.keypress(config.ARENA_SORTIE_CONFIRM_KEY) + driver.wait(2) + + _wait_for_result(driver, config) + return True + + def run(driver, config): driver.focus_game() @@ -281,47 +322,31 @@ def run(driver, config): return print(f"[arena] tickets: {tickets}") - if tickets <= 0: - print("[arena] no arena tickets available -- collecting rewards only") - _collect_rewards(driver, config) - print("[arena] Done.") - return + fights = 0 + while tickets > 0 and fights < config.ARENA_MAX_FIGHTS_PER_RUN: + if config.ARENA_STOP_FIGHT_WHEN_RANK1: + rank = _read_rank(driver, config) + if rank == 1: + print("[arena] already rank 1 -- not fighting") + break + print(f"[arena] current rank: {rank}") - if config.ARENA_STOP_FIGHT_WHEN_RANK1: - rank = _read_rank(driver, config) - if rank == 1: - print("[arena] already rank 1 -- not fighting, collecting rewards only") - _collect_rewards(driver, config) - print("[arena] Done.") - return - print(f"[arena] current rank: {rank}") + if not _fight_one(driver, config): + print("[arena] fight did not complete cleanly -- stopping without attempting further fights") + break + fights += 1 - slot_index = config.ARENA_COMPONENT_NUMBER - 1 - _choose_enemy(driver, config, slot_index) + new_tickets = _read_ticket_count(driver, config) + if new_tickets is None: + print("[arena] could not re-read ticket count after the fight -- stopping rather than guess whether more remain") + break + tickets = new_tickets + print(f"[arena] tickets remaining: {tickets}") - if not _open_opponent_modal(driver, config, slot_index): - print("[arena] opponent-info modal not detected, aborting without spending a ticket") - return - - print("[arena] committing to attack formation (spends a ticket)") - driver.click(*config.ARENA_ATTACK_FORMATION_BUTTON) - driver.wait(2) - - if not navigation.is_on_subscreen(driver): - print("[arena] attack-formation screen not detected after commit -- ticket may already be spent, check manually. Aborting without pressing further keys") - return - - _ensure_skip_on(driver, config) - - print("[arena] sortie") - driver.keypress(config.ARENA_SORTIE_CONFIRM_KEY) - driver.wait(2) - - _wait_for_result(driver, config) - - new_tickets = _read_ticket_count(driver, config) - if new_tickets is not None: - print(f"[arena] tickets remaining: {new_tickets}") + if tickets > 0 and fights < config.ARENA_MAX_FIGHTS_PER_RUN: + print(f"[arena] waiting {config.ARENA_POST_BATTLE_COOLDOWN}s for the post-battle cooldown before the next fight") + driver.wait(config.ARENA_POST_BATTLE_COOLDOWN) + print(f"[arena] fought {fights} battle(s) this run") _collect_rewards(driver, config) print("[arena] Done.") diff --git a/ba_auto/tasks/lesson.py b/ba_auto/tasks/lesson.py index 3a96b16..3306281 100644 --- a/ba_auto/tasks/lesson.py +++ b/ba_auto/tasks/lesson.py @@ -1,9 +1,23 @@ """Lesson/Schedule. Reference: baas-reference/module/lesson.py. -Scoped for v1 per plan.md's "Suggested first version" and explicit user -direction: affection-first selection (mirrors the reference's -lesson_relationship_first=True), sweep every unlocked region in a fixed -order until either lesson tickets or scoreable lessons run out, no +Selection priority, per explicit user direction (2026-07-13), superseding +the original v1 "always pick the single highest affection value" rule +(lesson_relationship_first=True, ported unchanged from the reference) with +a min/max-farming-focused tiered priority instead: + +1. Any cell (location card) with all 3 student slots schedulable -- a + single ticket raises 3 students' affection at once, so these are done + first, in any region. +2. Once no 3-available cell remains anywhere, any cell with 2 schedulable + slots. +3. Once no 2-or-3-available cell remains anywhere, single-slot cells -- + lowest current affection value first, to catch up whichever student is + furthest behind rather than keep maxing out whoever's already highest. + +This requires knowing the full board (every region's every cell) before +deciding what to do next, not just the current region's -- see +`_scan_all_regions`/`_build_priority_queue` below. Sweeps every unlocked +region until either lesson tickets or queued cells run out; no lesson-ticket purchasing (same real-currency-adjacent caution as Daily Free Power / the shop's manual refresh button -- see CLAUDE.md), no favor-student targeting (deferred, matching plan.md). @@ -173,18 +187,71 @@ def _read_slot_affection(driver, config, row, col, slot): return value -def _find_best_cell(driver, config): - best_score, best_cell = -1, None +def _scan_open_grid_cells(driver, config): + """Scan the CURRENTLY OPEN grid modal's 9 cells. Returns a list of + (row, col, available_count, values) for every cell with at least one + schedulable (not already-done-today, has a relationship) student slot + -- `values` is that cell's available slots' affection numbers, in slot + order. Cells with zero schedulable slots (locked, or every student + already done/absent) are omitted entirely. + """ + cells = [] for row in range(GRID_ROWS): for col in range(GRID_COLS): - cell_score = -1 + values = [] for slot in range(GRID_SLOTS): value = _read_slot_affection(driver, config, row, col, slot) - if value is not None and value > cell_score: - cell_score = value - if cell_score > best_score: - best_score, best_cell = cell_score, (row, col) - return best_cell, best_score + if value is not None: + values.append(value) + if values: + cells.append((row, col, len(values), values)) + return cells + + +def _close_region_grid(driver, config): + _close_grid_modal(driver, config) + driver.wait(0.5) + driver.click(*config.LESSON_BACK_BUTTON) + driver.wait(1.5) + + +def _scan_all_regions(driver, config): + """Open every region's grid once, record its schedulable cells, close it + again -- a pure read, spends no tickets. Needed because the priority + below (3-available cells anywhere > 2-available anywhere > lowest + affection anywhere) requires knowing the whole board, not just + whichever region a fixed sweep order would visit first. Returns a flat + list of (region_index, row, col, available_count, values) across all + regions that opened successfully -- a region whose grid can't be + confirmed open is skipped (logged, not fatal), matching this project's + existing "abort without pressing further keys" convention for a single + step, not the whole run. + """ + all_cells = [] + for region_index in range(TOTAL_REGIONS): + name = config.LESSON_REGION_NAMES[region_index] + if not _open_region_grid(driver, config, region_index): + print(f"[lesson] could not confirm schedule grid opened for {name} during scan, skipping") + continue + cells = _scan_open_grid_cells(driver, config) + print(f"[lesson] scanned {name}: {len(cells)} cell(s) with a schedulable student") + for row, col, count, values in cells: + all_cells.append((region_index, row, col, count, values)) + _close_region_grid(driver, config) + return all_cells + + +def _build_priority_queue(all_cells): + """Order scanned cells by the user's min/max priority (2026-07-13): + triples first (any order), then doubles (any order), then singles + sorted ascending by their one available student's affection value (the + most-behind student goes first). Returns an ordered list of + (region_index, row, col). + """ + triples = [c for c in all_cells if c[3] == 3] + doubles = [c for c in all_cells if c[3] == 2] + singles = sorted((c for c in all_cells if c[3] == 1), key=lambda c: c[4][0]) + return [(region_index, row, col) for region_index, row, col, _count, _values in triples + doubles + singles] def _click_cell(driver, config, row, col): @@ -221,32 +288,51 @@ def _run_one_schedule(driver, config, row, col): return _is_grid_idle(driver, config) -def _sweep_region(driver, config, region_index, remaining_tickets): - name = config.LESSON_REGION_NAMES[region_index] +def _run_queue(driver, config, queue, tickets): + """Execute a priority-ordered queue of (region_index, row, col) targets, + one ticket per cell, stopping when tickets run out or the queue is + exhausted. Only reopens a region's grid when the target's region + differs from whichever is currently open, since consecutive queue + entries are often in the same region (all of one region's triples tend + to sort together, for instance). - if not _open_region_grid(driver, config, region_index): - print(f"[lesson] could not confirm schedule grid opened for {name}, skipping") - return remaining_tickets - - while remaining_tickets > 0: - cell, score = _find_best_cell(driver, config) - if cell is None: - print(f"[lesson] no schedulable lesson found in {name}") + A cell that fails to run (_run_one_schedule returns False -- the info + panel didn't show, or the schedule never settled back to the idle grid) + forces a re-open of its region before the next queued cell, rather than + assuming the grid is still in a good state; this mirrors the recovery + every other click-then-verify step in this project already does after a + miss. + """ + open_region = None + for region_index, row, col in queue: + if tickets <= 0: + print("[lesson] out of lesson tickets -- stopping") break - row, col = cell - print(f"[lesson] {name}: best available affection {score} at row {row} col {col}") + + if region_index != open_region: + if open_region is not None: + _close_region_grid(driver, config) + name = config.LESSON_REGION_NAMES[region_index] + if not _open_region_grid(driver, config, region_index): + print(f"[lesson] could not reopen {name} to run its queued schedule(s), skipping") + open_region = None + continue + open_region = region_index + + name = config.LESSON_REGION_NAMES[region_index] + print(f"[lesson] {name}: running queued cell row {row} col {col}") if not _run_one_schedule(driver, config, row, col): - print(f"[lesson] warning: could not confirm return to the schedule grid after {name} row {row} col {col} -- stopping this region") - break - new_count = _read_ticket_count(driver, config) - remaining_tickets = new_count if new_count is not None else remaining_tickets - 1 - print(f"[lesson] tickets remaining: {remaining_tickets}") + print(f"[lesson] warning: could not confirm return to the schedule grid after {name} row {row} col {col} -- will reopen before the next queued cell") + open_region = None + continue - _close_grid_modal(driver, config) - driver.wait(0.5) - driver.click(*config.LESSON_BACK_BUTTON) - driver.wait(1.5) - return remaining_tickets + new_count = _read_ticket_count(driver, config) + tickets = new_count if new_count is not None else tickets - 1 + print(f"[lesson] tickets remaining: {tickets}") + + if open_region is not None: + _close_region_grid(driver, config) + return tickets def run(driver, config): @@ -265,11 +351,14 @@ def run(driver, config): if tickets <= 0: print("[lesson] no lesson tickets available, nothing to do") else: - for region_index in range(TOTAL_REGIONS): - if tickets <= 0: - print("[lesson] out of lesson tickets -- stopping") - break - tickets = _sweep_region(driver, config, region_index, tickets) + print("[lesson] scanning all regions for schedulable students") + all_cells = _scan_all_regions(driver, config) + queue = _build_priority_queue(all_cells) + triple_count = sum(1 for c in all_cells if c[3] == 3) + double_count = sum(1 for c in all_cells if c[3] == 2) + single_count = sum(1 for c in all_cells if c[3] == 1) + print(f"[lesson] priority queue: {triple_count} triple(s), {double_count} double(s), {single_count} single(s)") + _run_queue(driver, config, queue, tickets) driver.click(*config.LESSON_BACK_BUTTON) driver.wait(1.5) diff --git a/ba_daily.py b/ba_daily.py index abf7054..aadf6f9 100644 --- a/ba_daily.py +++ b/ba_daily.py @@ -2,7 +2,7 @@ """Python CLI entry point: ba_dailies.sh -> ba_daily.py -> ba_auto/tasks/*.py""" import sys -from ba_auto import config, driver +from ba_auto import config, driver, navigation from ba_auto.tasks import arena, bounty, cafe, event_sweep, lesson, mailbox, shop_common, shop_tactical, stamina, story_sweep TASKS = { @@ -26,6 +26,95 @@ TASKS = { # module docstring. DEFAULT_ORDER = ["mailbox", "cafe", "stamina"] +# How many times _ensure_home retries navigation.return_to_home as a whole +# (not to be confused with that function's own internal +# RETURN_HOME_MAX_ROUNDS press-loop) before giving up, and how long it waits +# between attempts. A single return_to_home call already presses through +# ordinary stuck subscreens/modals -- this outer retry exists for the +# genuinely transient case a single pass can't help with (a screen still +# mid-transition/loading at the moment it was checked, a slow network +# hiccup dialog that needs a few seconds to settle), giving that condition +# real time to resolve on its own between attempts rather than hammering +# the same check back-to-back. +PRE_TASK_HOME_RETRIES = 3 +PRE_TASK_HOME_RETRY_WAIT = 5 + + +def _ensure_home(name): + """Self-healing pre-task recovery: retry navigation.return_to_home + across multiple bounded attempts (with a wait between, for a genuinely + transient condition to clear) rather than giving up after a single + pass. Returns True once home is confirmed, False if still not home + after PRE_TASK_HOME_RETRIES attempts -- callers should NOT abort on + False (see _run_task's own docstring for why), just proceed with + whatever confidence they have. + """ + for attempt in range(1, PRE_TASK_HOME_RETRIES + 1): + if navigation.return_to_home(driver): + return True + print(f"[{name}] still not confirmed home after recovery attempt {attempt}/{PRE_TASK_HOME_RETRIES}") + if attempt < PRE_TASK_HOME_RETRIES: + driver.wait(PRE_TASK_HOME_RETRY_WAIT) + return False + + +def _run_task(name): + """Run one task, guaranteeing the game is at the home screen both before + it starts and after it ends -- success, a task's own early "abort + without pressing further keys" return, or an uncaught exception. + + Centralized here rather than duplicated inside every task's own run() + so it can't be missed by an early-return path a task's own author + didn't think to guard against, or skipped entirely by a new task that + forgets to add it. An audit (2026-07-11, per explicit user request) + found this was a real, widespread gap: mailbox/cafe/stamina/shop_*/ + lesson only pressed a single conditional Escape on some paths (nothing + at all on several early-failure paths), story_sweep.py had no home- + return cleanup anywhere including its own success path, and arena.py + only called navigation.return_to_home at the START of a run (recovering + from the *previous* run's leftover state) rather than at the end of its + own. Only event_sweep.py and bounty.py already called it on every path + -- see plan.md's "Return-to-home audit" entry for the full writeup. + + The pre-task call turned out to matter just as much as the post-task + one, confirmed live the same session: every task's own navigation + (MAILBOX_ICON, WORK_ICON, etc.) is a fixed, home-screen-relative + coordinate. mailbox.py was run once with the game left mid-navigation + on an unrelated Event Quest screen (a leftover from an unrelated + network-disconnect popup, nothing to do with this project) -- its + MAILBOX_ICON click landed on that screen instead, and a chain of + false-positive state checks (see navigation.return_to_home's own + _not_home fix from the same incident) let it click blindly into a + completely unrelated event stage-info modal rather than the mailbox. + No resource was actually spent (confirmed by unchanged AP/credits), but + calling return_to_home before every task closes the gap outright rather + than relying on chance. + + Per explicit user direction (2026-07-12): this is self-healing, not a + hard gate. _ensure_home retries across several bounded attempts rather + than giving up after one, and even if it still can't confirm home, the + task is attempted anyway -- never hard-aborted here. A task's own + individual navigation steps already verify their own state before + acting (mailbox's MAILBOX_ICON click, story_sweep's task-screen check, + etc.), so a task started from an unconfirmed state still fails safely + at its own first click-verify step rather than cascading blindly, the + same protection every task already has for a missed click mid-run. + + A bare try/finally (no except) is used deliberately for the post-task + call: navigation.return_to_home always runs before control leaves this + function, but any exception a task raises still propagates normally + afterward rather than being swallowed -- a crash should still surface + as a crash, just with the game safely back at home first instead of + stuck wherever it failed. + """ + if not _ensure_home(name): + print(f"[{name}] warning: could not confirm starting from the home screen after {PRE_TASK_HOME_RETRIES} attempts -- proceeding anyway") + try: + TASKS[name](driver, config) + finally: + if not navigation.return_to_home(driver): + print(f"[{name}] warning: could not confirm return to home screen after task finished") + def main(argv): args = argv[1:] @@ -40,7 +129,7 @@ def main(argv): if not args: for name in DEFAULT_ORDER: - TASKS[name](driver, config) + _run_task(name) print("All done.") return 0 @@ -50,7 +139,7 @@ def main(argv): print(f"Valid phases: {' '.join(TASKS.keys())}", file=sys.stderr) return 1 - TASKS[command](driver, config) + _run_task(command) return 0 diff --git a/plan.md b/plan.md index 3e1e80f..2b8c18f 100644 --- a/plan.md +++ b/plan.md @@ -434,6 +434,20 @@ Validated live in two passes: first, a full real run from the recovered list (7 This is the same class of finding CLAUDE.md already documents from mailbox/cafe's fixed-coordinate click flakiness and story_sweep's OCR-based navigation: a screen's identity can't be safely assumed from "what button did we intend to click here," it has to be verified — this task's `_open_schedule_screen` had gotten away without that check only because the list had always been the landing screen in every session up to now. +#### Phase 12 follow-up #3: min/max affection-farming priority (2026-07-13) + +Per explicit user direction, replacing v1's "always pick the single highest affection value" rule (`lesson_relationship_first=True`, ported unchanged from the reference) with a tiered, efficiency-first priority instead: + +1. Any cell (location card) with all 3 student slots schedulable, in any region — one ticket raises 3 students at once. +2. Once no such cell remains anywhere, any cell with 2 schedulable slots. +3. Once no 2-or-3-available cell remains anywhere, single-slot cells, lowest current affection value first — catch up whichever student is furthest behind rather than keep maxing out whoever's already highest. + +This requires knowing the *whole board* before deciding what to do next, not just whichever region a fixed sweep order visits first — a genuinely different shape from v1's per-region loop. Restructured into three phases: `_scan_all_regions` opens every region's grid once (pure read, spends no tickets) and records every cell with at least one schedulable slot; `_build_priority_queue` buckets those into triples/doubles/singles and sorts singles ascending by affection value; `_run_queue` then executes the resulting ordered list of `(region_index, row, col)` targets, one ticket per cell, only reopening a region's grid when the next target's region differs from whichever is currently open (consecutive queue entries are often in the same region). `_find_best_cell` and `_sweep_region` (v1's per-region highest-value picker and its driving loop) are removed, superseded by this. + +**Verified twice before any ticket was spent.** First, offline against a synthetic board (no game interaction): 3 triples/doubles/singles with known values fed through `_build_priority_queue` produced exactly the expected order (triples, then doubles, then singles strictly ascending). Second — since the account was fully out of lesson tickets this session (per the user: "you can make change to the code first I will test it tomorrow after the ticket regenerate") — `_scan_all_regions`/`_build_priority_queue` were called directly against the real live board (bypassing `run()`'s own ticket-gate, since scanning alone spends nothing): found 69 real schedulable cells across the account's 12 regions (7 triples, 30 doubles, 32 singles), and the resulting priority queue's tail exactly matched strict ascending order by real affection value (2, 2, 4, 5, 5, 8, 8, 9, 10, 11, 13, 14, 15×5, 16×5, 18, 18, 19, 19, 20, 20, 22, 43, 44, 47) with all 7 triples and all 30 doubles correctly sorted ahead of every single. Zero tickets spent confirming this. + +**Not yet live-tested for real ticket execution** (`_run_queue` actually running schedules) — deployed and both offline- and live-scan-verified; the user will test the real execution path once tickets regenerate. + ### Phase 13: Arena / Tactical Challenge **Status: Done.** Ported `module/arena.py`'s `implement` flow (`get_tickets`, `choose_enemy`, `check_skip_button`, `fight`, `collect_tactical_challenge_reward`) to `ba_auto/tasks/arena.py`. Live-tested for real across all 5 of the account's daily tickets — 3 real fights (2 WIN, 1 LOSE), plus real reward claims (credits, pyroxene, tactical coin all changed as expected). @@ -476,6 +490,14 @@ This is the same class of finding CLAUDE.md already documents from mailbox/cafe' **Status: confirmed live (2026-07-11).** Reproduced the exact bug scenario by manually navigating to and leaving the game stuck on the Tactical Challenge screen (mirroring what a completed prior `arena` invocation leaves behind), then ran `./ba_dailies.sh arena` for real. It recovered via `navigation.return_to_home`, reopened Tactical Challenge, read 2 tickets, fought, and completed normally — confirmed by screenshot showing rank moved 14位's opponent list entry to 9位, ticket count 2→1, credits +1,080. No repeat of the "tactical challenge screen not detected" failure from the original bug report. +#### Phase 13 follow-up #2: fight until tickets are exhausted, not just one battle (2026-07-12) + +Per explicit user request: "Can you update the arena part to run until all ticket (5 ticket daily) is used? They have 30s of timeout after you finish battle." + +This is a deliberate reversal of the original Phase 13 design decision (exactly one battle per invocation, matching the reference's own per-call pacing with no internal loop — see the module docstring's original writeup). `arena.py`'s single-fight logic (choose enemy, open opponent modal, commit attack formation, sortie, wait for result) was extracted unchanged into `_fight_one(driver, config)`, returning `True`/`False` instead of directly `return`-ing out of `run()`. `run()` now calls it in a loop: `while tickets > 0 and fights < config.ARENA_MAX_FIGHTS_PER_RUN`, re-reading the OCR'd ticket count after each fight to decide whether to continue, waiting `config.ARENA_POST_BATTLE_COOLDOWN` (30s, per the user's own info about the real in-game lockout between fights — not independently discovered through live probing) before the next fight if tickets remain. `config.ARENA_STOP_FIGHT_WHEN_RANK1` is now re-checked before every fight in the loop rather than only once before the first, since rank can change mid-run from fighting. `ARENA_MAX_FIGHTS_PER_RUN` (10) is a defensive bound only, guarding against a runaway loop if ticket-count OCR ever misreads persistently — not a hardcoded assumption of the account's real daily ticket count (5, per the user), which stays OCR-read live every run like everything else in this project. Reward collection is unchanged: still runs unconditionally once at the end regardless of how the loop exits. + +**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 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." @@ -576,6 +598,34 @@ 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). +### Return-to-home audit (2026-07-12) + +Per explicit user request: "I want all the script to return to home page after the script ended, no matter the script ended in success or failure. You can use the Esc button to return or use module to reuse the logic." + +**Audit found the concern was valid and widespread.** Checked every task's `run()` for home-screen cleanup on every exit path (success, early failure, everything): + +| Task | Before this fix | +|---|---| +| `bounty.py` | Already called `navigation.return_to_home` on every path | +| `event_sweep.py` | Called it on most paths, but skipped it on one early return (`no rotation configured`) | +| `arena.py` | Called it only at the **start** of a run (recovering from the *previous* run's leftover state, per Phase 13's own fix) — never at the end of its own run | +| `mailbox.py`, `cafe.py`, `stamina.py`, `shop_common.py`, `shop_tactical.py`, `lesson.py` | Only a single, conditional `Escape` press on some paths — several early-failure `return`s did nothing at all | +| `story_sweep.py` | Zero home-return cleanup anywhere, including its own success path | + +**Fix**: centralized in `ba_daily.py` rather than patched into all 9 files individually — `_run_task(name)` now wraps every `TASKS[name](driver, config)` call in a bare `try/finally`, calling `navigation.return_to_home(driver)` both *before* the task starts and *after* it ends (success, an early return at any nesting level, or an uncaught exception — `finally` always runs regardless of which `return` fired, and a bare `try/finally` with no `except` lets any real exception still propagate afterward rather than being silently swallowed). This is the single choke point every task already goes through (`TASKS` dict dispatch), so it can't be missed by a new task or an early-return path its author didn't think to guard, and it required zero changes to any individual task's own tested internals. + +**A live regression test of this fix immediately found a second, deeper bug.** Running `mailbox` from a state where the game had been left on the Event Quest screen with a stage-info modal open (unrelated leftover from a real network-disconnect popup encountered mid-session) revealed that `mailbox.py`'s `MAILBOX_ICON` click — a fixed, home-screen-relative coordinate — landed on the wrong screen entirely, and a false-positive state check let it click blindly into the unrelated modal instead of the real mailbox panel. No resource was lost (AP/credits confirmed unchanged), but this was a genuine control-flow hazard, not just a cosmetic one. + +Root cause, confirmed via direct pixel comparison of saved screenshots: `navigation.is_on_subscreen`'s header-brightness probe (`SUBSCREEN_HEADER_PROBE`, `(500,10)`) reads **dark** both on the true home screen (this account's current home art happens to be dark there) *and* on a subscreen with a modal open on top (the modal's own screen-wide dimming overlay darkens the header right along with everything else — confirmed reading `(80,81,82)` in the stuck state, essentially identical in brightness to genuine home). `is_on_subscreen` alone therefore can't tell "subscreen with a stuck modal" apart from "home reached" — `return_to_home`'s loop was seeing `False` in both cases and stopping immediately, having pressed nothing. + +The existing `is_modal_open` primitive (`MODAL_DIM_PROBE`, `(960,200)`), already used elsewhere in the project, correctly told the two apart when checked directly: `(51,37,28)` (dark, correctly "modal open") in the stuck state vs. `(126,136,210)` (correctly "no modal") on the confirmed true home screen. **Fix**: `navigation.py` gained a new `_not_home(driver)` helper — `is_on_subscreen(driver) or is_modal_open(driver)` — and `return_to_home`'s loop now checks that instead of `is_on_subscreen` alone. Escape continues to map to Cancel on every dialog in this project (including Blue Archive's own "quit the game?" confirmation, confirmed live again during this investigation), so even a mistimed extra press against an unexpected dialog stays safe — it never risks confirming anything, just self-heals on the next loop iteration. + +**Confirmed live**: deliberately reproduced the exact stuck state (bounty's own stage-10 modal left open over its Location Select screen, 0 tickets held so nothing further could be spent even by an errant click), verified `is_on_subscreen` alone read `False` (would have looked like home) while the new combined `_not_home` check correctly read `True`, then ran `./ba_dailies.sh mailbox` for real through the actual CLI entry point end-to-end: it correctly recovered to home *before* attempting its own navigation, ran its own logic correctly ("nothing to claim" — accurate, since a real claim had already happened earlier), and the game was confirmed cleanly on the true home screen afterward. AP/credits unchanged throughout. + +#### Return-to-home audit follow-up: self-heal, don't hard-abort (2026-07-12) + +First pass made the pre-task `return_to_home` call a hard gate: if it failed, `_run_task` printed an error and skipped the task entirely without attempting it. Per explicit user correction — "I don't want to abort, I want it to be able to self heal. Return to home first, then proceed with the script." — this was reverted in favor of a self-healing design: a new `_ensure_home(name)` retries `navigation.return_to_home` across `PRE_TASK_HOME_RETRIES` (3) bounded attempts, `PRE_TASK_HOME_RETRY_WAIT` (5s) apart, giving a genuinely transient condition (a screen still mid-transition when first checked, a slow network hiccup dialog) real time to clear rather than hammering the same check back-to-back. Even if all 3 attempts still can't confirm home, the task is attempted anyway — never hard-aborted. Each task's own individual navigation already verifies its own state before acting (e.g. mailbox's own click-then-check on `MAILBOX_ICON`), so a task started from an unconfirmed state still fails safely at its own first step rather than cascading blindly — the same protection every task already has against a single missed click mid-run, just relied on one level higher up instead of gating on it. Confirmed live: normal case (game already home) still runs cleanly through the real CLI with no behavior change. + ## Prerequisites ### OCR