Refactor arena and lesson tasks for improved efficiency and reliability

- Updated arena.py to allow multiple battles per invocation, looping until tickets are exhausted or a rank-1 condition is met. Introduced _fight_one function for single battle logic and added cooldown handling between fights.
- Enhanced lesson.py to implement a tiered priority system for scheduling lessons based on student slots available, replacing the previous highest affection value selection. Introduced functions for scanning all regions and building a priority queue for lesson scheduling.
- Centralized return-to-home logic in ba_daily.py to ensure the game returns to the home screen before and after each task, improving robustness against navigation issues.
- Added retry mechanism for returning to home, allowing for transient navigation issues to be handled gracefully without aborting tasks.
This commit is contained in:
Nik Afiq 2026-07-13 10:32:53 +09:00
parent e67d28513e
commit c5873f4e58
8 changed files with 399 additions and 104 deletions

View File

@ -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 56 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.

View File

@ -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

View File

@ -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):

View File

@ -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.

View File

@ -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.")

View File

@ -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)

View File

@ -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

50
plan.md
View File

@ -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