# ba-auto-daily implementation plan Personal Blue Archive JP daily-automation project. This project controls the PC/Steam/Proton Blue Archive client running on `nik-gpu` through local desktop automation: - xdotool - scrot - Python - OpenCV - OCR, ported wherever the reference implementation uses it for a feature (see `CLAUDE.md` → "OCR policy" — this is no longer a "later, when needed" deferral) Development happens on `nik-macbookair`. The reference implementation lives at: ``` ~/repo/baas-reference/ ``` The reference project should be treated as the behavioral blueprint. This project should avoid recreating feature logic from scratch when the reference already implements it. ## Core project direction This project is now Python-first. The goal is not to grow a large Bash script. The goal is to build a small local Python automation framework that adapts the reference project's Blue Archive logic to this user's unique PC/Steam/Proton environment. `ba_dailies.sh` should only be a launcher. Feature logic should live in Python. ## Target architecture ``` ~/repo/ba-auto-daily/ ├── ba_dailies.sh ├── ba_daily.py ├── ba_auto/ │ ├── __init__.py │ ├── config.py │ ├── driver.py │ ├── detector.py │ ├── navigation.py │ ├── tasks/ │ │ ├── __init__.py │ │ ├── mailbox.py │ │ ├── cafe.py │ │ ├── stamina.py │ │ ├── group.py │ │ ├── bounty.py │ │ ├── commission.py │ │ ├── arena.py │ │ ├── shop_common.py │ │ ├── shop_tactical.py │ │ ├── lesson.py │ │ └── ... │ └── reference_notes/ │ └── mapping.md ├── assets/ ├── screenshots/ ├── scripts/ ├── setup.sh ├── CLAUDE.md └── plan.md ``` This is the intended direction. It does not have to be completed all at once. ## Project layout | Path | What it is | |---|---| | `~/repo/ba-auto-daily/ba_dailies.sh` | Thin launcher only. It should call the Python entry point. Do not add new feature logic here. | | `~/repo/ba-auto-daily/ba_daily.py` | Main Python CLI entry point. Dispatches tasks such as mailbox, cafe, stamina, group, etc. | | `~/repo/ba-auto-daily/ba_auto/driver.py` | Local PC/Steam/Proton control backend. Wraps xdotool, scrot, waits, clicks, swipes, keypresses, screenshots, and window focus. | | `~/repo/ba-auto-daily/ba_auto/detector.py` | OpenCV/template/color matching helpers. Currently has `find_cafe_sparkle()`, ported in-process from the retired `scripts/detect_and_click.py`. | | `~/repo/ba-auto-daily/ba_auto/navigation.py` | Shared navigation/state-probe helpers: `is_on_subscreen`, `is_modal_open`, used by both `mailbox.py` and `cafe.py`. | | `~/repo/ba-auto-daily/ba_auto/tasks/` | Feature implementations. Each task should adapt the relevant `baas-reference/module/...` logic where possible. | | `~/repo/ba-auto-daily/ba_auto/reference_notes/mapping.md` | Reference mapping table: local feature → reference module → local implementation → driver gaps. | | `~/repo/ba-auto-daily/assets/` | Locally captured template images, such as cafe sparkle. Do not blindly copy assets from the reference repo. | | `~/repo/ba-auto-daily/screenshots/` | Human reference screenshots, mostly Moonlight/game captures, used for calibration and debugging. | | `~/repo/ba-auto-daily/setup.sh` | Bootstrap/deploy helper for `nik-gpu`. Should install/check dependencies and copy runtime files. | | `~/repo/baas-reference/` | Read-only GPL-3.0 reference clone. Study and adapt. Never edit. | ## Runtime paths on nik-gpu Preferred runtime layout: | Path | What it is | |---|---| | `nik-gpu:~/ba_dailies.sh` | Thin launcher. | | `nik-gpu:~/ba_daily.py` | Python CLI entry point. | | `nik-gpu:~/ba_auto/` | Python package copied from this repo. | | `nik-gpu:~/ba_assets/` | Runtime assets/templates. | | `nik-gpu:~/.venvs/ba-auto-daily/` | Python virtual environment. | `nik-gpu:~/ba_scripts/` may still contain `detect_and_click.py` and `ba_dailies_legacy.sh` left over from before both mailbox and cafe were migrated off them. Neither is deployed or referenced by anything anymore (`setup.sh` stopped copying them once Phase 6 landed) — safe to delete manually on `nik-gpu`, just not automated here. ## Implementation strategy For each feature: 1. Read the matching `~/repo/baas-reference/module/...` file. 2. Summarize the reference flow. 3. Identify reusable logic: - state checks - retry loops - navigation sequence - battle/sweep/shop rules - detection method - failure handling 4. Identify backend-specific calls that cannot be reused directly. 5. Implement missing generic primitives in `ba_auto/driver.py` or `ba_auto/detector.py`. 6. Implement the feature in `ba_auto/tasks/.py`. 7. Add CLI command dispatch in `ba_daily.py`. 8. Keep `ba_dailies.sh` unchanged unless launcher behavior changes. 9. Test syntax locally. 10. Deploy to `nik-gpu`. 11. Run against the live game. 12. Update this plan. The intended result is not a Bash automation script. The intended result is a Python automation framework using the reference repository as the behavioral blueprint. ## Reference mapping table Maintain this table in `ba_auto/reference_notes/mapping.md`. Initial seed: | Local feature | Reference file | Reference functions/classes | Local file | Backend replacements | Status | |---|---|---|---|---|---| | Mailbox | Need to confirm in reference | Need to inspect | `ba_auto/tasks/mailbox.py` | tap/click via xdotool, screenshot via scrot | Existing Bash behavior; migrate to Python | | Cafe | `module/cafe_reward.py` | `to_cafe`, `interaction_for_cafe_solve_method3`, `collect` | `ba_auto/tasks/cafe.py` | `picture.co_detect`/`color.rgb_in_range` → `driver.color_at` pixel-probe checks; sparkle template match ported in-process into `ba_auto/detector.py` | Migrated: real Python, state-verified via color probes, no legacy bridge | | Stamina/AP | `module/collect_daily_free_power.py`, `module/collect_daily_task_power.py` | `to_tasks`/`implement` (task-power, ported); `to_purchase_pyroxenes_menu`/`detect_free_power_availability` (free-power, not ported — real-money purchase menu) | `ba_auto/tasks/stamina.py` | `color.rgb_in_range` → `driver.color_at`; reference's per-tab claim loop replaced by the live UI's single "一括受取" (claim-all) button, triggered via Enter | Partially migrated (Phase 8): Mission-panel task/weekly/achievement claim done. Daily Free Power deliberately not implemented. | | Normal/Hard story AP sweep | `module/explore_tasks/sweep_task.py`, `module/explore_tasks/task_utils.py` | `to_region`/`to_normal_event` + OCR-driven per-stage claim loop (ported, Phase 10) | `ba_auto/tasks/story_sweep.py` | OCR-based region/stage-name matching, ported for real (Phase 10); reference's per-stage claim loop → this client's stage-info modal's self-contained 掃討 (sweep) sub-panel (MIN/-/+/MAX stepper + start button) | Done (Phase 10, supersedes Phase 9's random-pick design) | | 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` | Need to inspect | `ba_auto/tasks/bounty.py` | sweep/color/OCR adaptation | Not started | | Commissions | `module/clear_special_task_power.py` | Need to inspect | `ba_auto/tasks/commission.py` | sweep/color adaptation | Not started | | Arena | `module/arena.py` | Need to inspect | `ba_auto/tasks/arena.py` | auto-fight + OCR + local driver | Not started | | Common Shop | `module/shop/common_shop.py`, `module/shop/shop_utils.py` | Need to inspect | `ba_auto/tasks/shop_common.py` | OCR + tab navigation + local clicks | Not started | | Tactical Shop | `module/shop/tactical_challenge_shop.py`, `module/shop/shop_utils.py` | Need to inspect | `ba_auto/tasks/shop_tactical.py` | OCR + tab navigation + local clicks | Not started | | Lesson/Schedule | `module/lesson.py` | Need to inspect | `ba_auto/tasks/lesson.py` | OCR + template/portrait search + local driver | Not started | Do not implement a feature without filling at least the relevant row. ## Status snapshot | Feature | Current status | Target status | |---|---|---| | Mailbox claim | Migrated: `ba_auto/tasks/mailbox.py` uses `driver.color_at` to verify the panel opened before acting (found live-testing bug: a marginal icon coordinate could miss and cascade into pressing Escape on the home screen, which triggers Blue Archive's own exit-game confirmation) | Done | | Cafe pats + income | Migrated: `ba_auto/tasks/cafe.py` verifies each room/dialog opened via `driver.color_at` before acting; sparkle detection now runs in-process via `ba_auto/detector.py` instead of a per-click subprocess | Done | | Stamina/AP (mission claim) | Migrated (partial, Phase 8): `ba_auto/tasks/stamina.py` claims the Mission panel's bulk "一括受取" button. Daily Free Power (real-money purchase menu) intentionally not implemented | Daily Free Power still not started | | Normal/Hard story AP sweep | Done (Phase 10, supersedes Phase 9's random-pick design): `ba_auto/tasks/story_sweep.py` sweeps a config-driven list of exact `(region, stage, count)` targets (`config.STORY_SWEEP_TARGETS`), navigating to each via OCR (region-number read + delta-click, stage-label OCR match) instead of "latest region, random stage." Opt-in only (`story_sweep` command), not part of the default daily flow | Done | | Common Shop / Tactical Shop | Done (Phase 11): `ba_auto/tasks/shop_common.py` / `shop_tactical.py` share a checkbox-grid-then-bulk-buy flow (`ba_auto/tasks/shop_utils.py`) against config-driven `(row, col, name, expected_price)` targets, price-OCR-verified before each click. Live-tested with real purchases in both shops. Opt-in only (`shop_common`/`shop_tactical` commands), not part of the default daily flow | Done | | Lesson/Schedule | Done (Phase 12): `ba_auto/tasks/lesson.py` sweeps every unlocked region's schedule grid, picking the highest-affection available lesson each time via a dedicated heart-badge OCR read (`detector.read_int_on_heart_badge`) until tickets or lessons run out. Live-tested with real tickets spent; two real bugs (checkmark-doesn't-blank-the-number, badge OCR misreads) found and fixed. Opt-in only (`lesson` command), not part of the default daily flow | Done | | Arena / Tactical Challenge | Done (Phase 13): `ba_auto/tasks/arena.py` fights exactly one ranked battle per invocation and collects both reward slots. Live-tested across all 5 of the account's daily tickets (2 WIN, 1 LOSE); the post-fight WIN/LOSE result modal proved undetectable by precise button-color search and was fixed via a bounded blind-Enter loop gated by a hard safety check. A later consecutive-invocation navigation bug (Phase 13 follow-up, fixed via `navigation.return_to_home` at start of `run()`) is also confirmed live. Opt-in only (`arena` command), not part of the default daily flow | Done | | Event sweep | Done (Phase 14 + follow-ups 1-6): `ba_auto/tasks/event_sweep.py` sweeps one config-rotated stage (9-12) of the currently-running event per invocation. First confirmed real live sweep 2026-07-10 (200 AP spent, 10x MAX sweep, credits gained, confirmed by screenshot) after fixing a badge-carousel navigation bug (pagination dots must be clicked directly, not just the ambiguous rotating badge) and a cold-start OCR timing bug (stage list can take ~20s to populate after a fresh navigation). Follow-up #6 (2026-07-11) fixed a stage 08/09 OCR misread (`detector.read_int_bordered`) and the real root cause of the `unrecognized_state` mislabeling (a false-positive color match on the event's own character art, fixed via `EVENT_SWEEP_RESULT_BUTTON_REGION`) -- both confirmed live, the second via the actual false-positive condition rather than a fresh full sweep (AP was too low that day). Opt-in only (`event_sweep` command), not part of the default daily flow | Done, one outcome-logging fix awaiting one more full live sweep to confirm the log itself reads "swept" | | Shared driver | `ba_auto/driver.py` built (`run_command`, `focus_game`, `click`, `move_mouse`, `scroll`, `keypress`, `screenshot`, `wait`, `color_at`); `click()` now splits `mousemove`/`click` into two xdotool calls (Phase 8 finding — fixes a real source of click flakiness); wired into `mailbox.py`, `cafe.py`, `stamina.py`, `story_sweep.py`, `shop_common.py`, `shop_tactical.py`, `lesson.py` | Extend with new primitives as future tasks need them | | Python CLI | Built: `ba_daily.py` dispatches `mailbox`/`cafe`/`stamina`/`story_sweep`/`shop_common`/`shop_tactical`/`lesson`/default flow | Extend as new tasks are added | | Reference mapping | Built: `ba_auto/reference_notes/mapping.md` | Fill in reference file/function columns per feature | | Everything else | Not started | Implement reference-first in Python | ## Migration phase Before adding new game features, migrate the existing working implementation. ### Phase 1: Python skeleton **Status: Done.** Create: ``` ba_daily.py ba_auto/__init__.py ba_auto/config.py ba_auto/driver.py ba_auto/detector.py ba_auto/navigation.py ba_auto/tasks/__init__.py ba_auto/tasks/mailbox.py ba_auto/tasks/cafe.py ba_auto/reference_notes/mapping.md ``` ### Phase 2: Launcher **Status: Done.** Change `ba_dailies.sh` into a thin launcher: ```bash #!/usr/bin/env bash set -euo pipefail VENV_PYTHON="${VENV_PYTHON:-$HOME/.venvs/ba-auto-daily/bin/python3}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" exec "$VENV_PYTHON" "$SCRIPT_DIR/ba_daily.py" "$@" ``` Keep compatibility with: ``` ./ba_dailies.sh ./ba_dailies.sh mailbox ./ba_dailies.sh cafe ``` ### Phase 3: Driver extraction **Status: Done.** Primitives (including `color_at`, added during the mailbox/cafe hardening work) are wired into both `ba_auto/tasks/mailbox.py` and `ba_auto/tasks/cafe.py`. Move shell interactions into `ba_auto/driver.py`. Driver primitives should include: ``` focus_game() click(x, y) double_click(x, y) keypress(key) screenshot(path=None) swipe(...) wait(seconds) wait_until(...) ``` ### Phase 4: Detector extraction **Status: Done (scoped).** `scripts/detect_and_click.py`'s sparkle-matching logic (masked template match against `assets/cafe_sparkle.png`) was ported into `ba_auto/detector.py` as `find_cafe_sparkle()`, called in-process from `ba_auto/tasks/cafe.py` — this removed the old per-click Python cold start (a fresh `cv2`/`numpy` import per subprocess call) that CLAUDE.md's driver-layer guidance specifically warns against. `scripts/detect_and_click.py` had no remaining callers once this landed, so it was deleted rather than kept as a compatibility wrapper. The more generic primitives listed below (`load_template`, `match_template`, etc.) have not been built — only the one concrete sparkle-matching function needed so far exists; generalize when a second detector use case actually needs it. Detector primitives, generalize later if needed: ``` load_template(...) match_template(...) find_best_match(...) find_and_click_template(...) color_mask(...) debug_write_match(...) ``` ### Phase 5: Mailbox migration **Status: Done.** Live testing surfaced a real bug: the old `MAILBOX_ICON` coordinate `(1726, 60)` sat on the edge of the icon's hitbox and intermittently missed, and the fixed click sequence had no way to notice — it cascaded into pressing Escape on the bare home screen, which triggers Blue Archive's own "exit the game?" confirmation (dismissed safely with Cancel during testing; no game state was lost). The Python port in `ba_auto/tasks/mailbox.py` fixes the coordinate and, following `module/mail.py`'s `rgb_in_range` pattern, verifies the panel actually opened (and whether "claim all" is disabled) via `driver.color_at` before pressing any further keys, with a bounded retry and a safe abort if the panel never appears. Move mailbox logic from Bash to: ``` ba_auto/tasks/mailbox.py ``` The CLI should call it through Python. ### Phase 6: Cafe migration **Status: Done.** Same root cause as the mailbox bug (Phase 5), confirmed by step-by-step live replay with screenshots: `CAFE_ICON` clicks are flaky (missed on the first attempt, worked on retry at the identical coordinate — this is xdotool/Proton click-registration flakiness, not a coordinate-precision problem), and the old sequence had zero verification across its ~12 steps (open → dismiss notice → pat loop ×15 → switch room → dismiss notice → pat loop ×15 → claim income ×2 Enter → ×2 Escape). A missed click anywhere cascades into blind actions on whatever screen is actually showing, which — same as mailbox — very likely ends with an unverified Escape hitting the home screen and triggering Blue Archive's own exit-game confirmation. `ba_auto/tasks/cafe.py` now verifies state at every transition using `driver.color_at`, following `module/cafe_reward.py`'s `picture.co_detect`/`rgb_in_range` pattern: - opening the cafe icon and the room-switch button both retry (bounded) and confirm the panel actually opened via the same subscreen-header probe as mailbox, now shared in `ba_auto/navigation.is_on_subscreen` - the "visited student list" notice that appears on every room entry is dismissed with Enter; this is harmless as a no-op if no popup is actually present (verified live), so no separate presence check was needed there - the income dialog's own dimmed-overlay backdrop is checked (`navigation.is_modal_open`) before pressing Enter to claim, and the "receive" button's disabled-grey color is checked before attempting to claim at all (mirrors `collect()`'s `rgb_in_range` gate in the reference) - the closing Escape(s) only fire when a subscreen/modal is confirmed still open, never blindly Verified live (two full runs against the real game, plus a manual step-by-step replay of every transition): - both rooms open and pat correctly - sparkle detection still works and now runs in-process (see Phase 4) instead of shelling out per click - cafe income claim works (confirmed gold +81,251 / AP +61 on an actual claim) and correctly no-ops when there's nothing to collect - the reference's `zoom_out` step (camera zoom before sparkle detection — CLAUDE.md's "view centering/zoom" gap) was **not** ported: detection matched at 0.99 confidence without it in live testing, so it wasn't reproducibly broken here. Left as a documented open risk below rather than added speculatively. Not verified / open risks: - whether zoom/pan state could drift over a long unattended run and eventually break sparkle detection (see above — no evidence of this yet, but the reference project treats it as necessary) #### Phase 6 follow-up: rank-up popups mid-pat-loop ("it will freeze a bit") A user report during real usage: a pat that causes a bond-rank-up makes the loop "freeze a bit." Confirmed as a real, previously-unhandled gap, not a timing fluke — `find_cafe_sparkle()` was being asked to recognize a full-screen "絆ランクアップ!" cutscene (no cafe header, no chrome at all — see `screenshots/cafe/student/01`/`02`) as if it were the sparkle template, which it obviously never matches, so the loop just spun `driver.wait(1)` uselessly for the rest of the room's click budget. The reference's own `to_cafe()` navigation (`module/cafe_reward.py`) already treats `relationship_rank_up` as a recognized, reactively-dismissed popup checked after every pat round — this project's port had never carried that over. Fix: `cafe.py`'s `_dismiss_rank_up_if_shown()`, called after every pat (click + Enter + move-mouse), reuses the *existing* `navigation.is_on_subscreen` header-brightness probe rather than adding a new one — directly confirmed against the user-provided screenshots: the header probe point reads `(183, 220, 240)` during the cutscene (r<200, fails the check) vs. `(248, 249, 250)` on the normal cafe screen (r>200, passes). Presses Enter (bounded, `config.CAFE_RANK_UP_DISMISS_RETRIES = 5`) until `is_on_subscreen` confirms the cafe room is back, rather than assuming one Enter is enough; if it never clears, the pat loop stops rather than continuing to click blindly. Not yet live-confirmed against a real rank-up trigger — it's semi-random (tied to hitting an affection threshold) and didn't happen to occur during this session's testing. The fix is grounded in the user's own captured screenshots (a real observed state, precisely measured), not a guess, but a live run actually hitting this path and recovering cleanly is still open. #### Phase 6 follow-up: "farming affection doesn't happen" report A later report claimed pats weren't landing at all, with the original `ba_dailies.sh` `do_cafe_room`/`do_cafe` pasted as the expected-behavior reference. Re-reading that Bash carefully changed the diagnosis: the original `detect_and_click.py` did one screenshot → detect → click per invocation and the Bash loop only kept calling it back-to-back while hits kept landing, breaking immediately on the first miss (`grep -q "^MATCH" || break`) — i.e. give-up-on-first-miss was the *original design*, not a regression introduced by the Python port. Detection math (mask, threshold `0.97`, click offset `(75, 47)`) ported over byte-for-byte identical. Changes made this round: - `ba_auto/detector.py`: `find_cafe_sparkle()` now tries multiple template scales (`SPARKLE_SCALES`) instead of one fixed size, since the cafe camera's zoom isn't reset before farming and isn't guaranteed to match whatever zoom the template was captured at. Strictly more permissive than the original single-scale match — no observed downside — but not confirmed as the actual root cause of the report (no live zoom-mismatch case was reproduced/observed). - `ba_auto/tasks/cafe.py`: `_pat_room` now polls for the full `CAFE_MAX_CLICKS_PER_ROOM` budget with a 1s wait between misses instead of breaking on the very first miss. This is a deliberate deviation from the original design (see above) — cheap (adds at most ~15s per room when nothing is available) and covers the case where a screenshot lands mid-animation right after the room transition. - `driver.move_mouse` added and called after each pat to park the cursor away from the sparkle area, per `screenshots/cafe/sparkle/02_*_cursor_on_head.png` showing the cursor can occlude the icon. What was directly verified live after these changes: - the room-entry/no-modal state probes (`navigation.is_on_subscreen`, `is_modal_open`) read correctly on real captured frames from both rooms - neither room had a visible sparkle on any student at the time of testing (confirmed by eye on the actual screenshots, not inferred from the "no sparkle found" log) — this is the most likely explanation for why a same-session automated run kept reporting no matches: this session's own manual+automated testing had already consumed the available per-student affection interactions, which regenerate on a real-world cooldown far longer than one room visit **Not yet verified**: an actual end-to-end pat (detect → click → affection-up dialog dismissed) succeeding after this round's changes, because no interactable sparkle was available during testing to exercise it against. Re-run `~/ba_dailies.sh cafe` once interactions have had time to regenerate and confirm `[cafe] patted N sparkle(s)` appears with N > 0. #### Phase 6 follow-up #2: student invitation (2026-07-14) Per explicit user direction: "The cafe have a pink button with 招待券 label under it. I need you to invite student into the cafe if it's available (it will show as 招待可能). Same student invite (even if different variation) will prompt student to move room, prevent that from happening. The invite should happen before affection farming since newly invited student can be farmed." Ports `module/cafe_reward.py`'s `invite_girl`/`invite_by_affection`/`checkConfirmInvite`. Reference inspection found `checkConfirmInvite` already implements exactly the "prevent the move-room prompt" behavior the user asked for, gated behind two config flags (`cafe_reward_allow_exchange_student`/`cafe_reward_allow_duplicate_invite`) that both default to `False` in the reference — this project has no equivalent config, so both are just always disallowed, matching the reference's own default. Live investigation (zero real tickets spent — every dialog encountered during calibration was cancelled) found the invite flow is a "MomoTalk" student-list modal (175 students, sortable by 絆ランク/bond rank, defaulting to descending) opened by the pink 招待券 button. Confirmed 3 distinct dialog outcomes from clicking a row's own 招待 button, all sharing this project's already-familiar shared "通知"-style dialog component (`SWEEP_CONFIRM_BUTTON`/`SWEEP_CONFIRM_CANCEL_BUTTON` reused directly, pixel-identical position/color): - Normal (title "通知"): safe to confirm. - "衣装替え" (costume change): the target is a different costume variant of a student already seated in the CURRENT room — reproduced live by inviting "ミカ" while "ミカ(水着)" was already seated, showing both portraits with a swap arrow between them. - "隣のカフェの生徒を招待" (invite from the neighboring cafe): the target is currently seated in the OTHER room — reproduced live via a student ("ノノミ") showing a "2号店" tag on her portrait. Per the user's explicit request, asked which selection criterion to use (lowest/highest affection, or just top-of-list) since the reference's own default (`starred`, i.e. manually-favorited students) doesn't fit this project's no-favorite-config posture — user chose **highest affection first**, which happens to match the list's own default sort, though the code still explicitly (re-)selects it every run rather than trusting a prior manual session's state. Implementation: `_ensure_invite_sort` explicitly sets the sort field and verifies descending order by comparing the top two rows' own OCR'd affection values (rather than reading the direction-toggle icon's arrow glyph) — confirmed live in both directions (descending 38/35, ascending 1/2 on the same account). `_try_invite_row` clicks a candidate, OCRs the resulting dialog's title, and treats any title containing "衣装" or "隣" as a warning to cancel (Escape) rather than confirm — mirroring `event_sweep.py`'s own OCR-substring-match reasoning for tolerance to OCR noise. Tries up to 5 visible candidates (matching the reference's own `invite_by_affection` bound — no scrolling) before giving up. Affection OCR reuses `detector.read_int_on_heart_badge` directly, unmodified — pixel-sampled live and confirmed to be the exact same pink-heart/navy-digit widget `lesson.py`'s own badges already use. All 5 heart-badge reads and all 3 dialog-title classifications were verified offline against the real saved calibration screenshots before deploying (exact match, zero mismatches) — this caught one real bug before it ever ran live: `_open_invite_list`'s original check used `navigation.is_modal_open`, which needs a darker reading than "list open, no nested dialog yet" actually produces (confirmed live: `MODAL_DIM_PROBE` read `(252,145,165)` there, failing `is_modal_open`'s all-channels-under-150 check) — fixed to check `not navigation.is_on_subscreen` instead, the same "modal/list dims the subscreen header" mechanism `navigation.py`'s own `_not_home` helper already documents. **Confirmed live with real tickets spent, both rooms**: room 1 skipped one 衣装替え candidate then invited row 1 cleanly; room 2 skipped three consecutive 隣のカフェの生徒を招待 candidates (expected, since room 1's invite had just taken the account's highest-affection students) then invited row 3 cleanly. Both newly-invited students were immediately patted successfully in the same run — confirming the user's own stated reason for the invite-before-farm ordering — income was claimed, and the task returned cleanly to home with no warnings anywhere in the log. #### Phase 6 follow-up #3: horizontal camera panning before farming (2026-07-14) Per explicit user direction: "due to my screen size, you need to move screen horizontally left-right or the you might miss a student. Can you add update to move screen most right and most left then farm? No need for vertical move since it will mess with the view." This directly addresses a gap this file already flagged under cafe's "Not yet verified" list: "whether camera zoom/pan can drift over a long unattended run... the reference project zooms out before detecting, this project currently does not." The reference's own fix for this (`module/cafe_reward.py`'s `zoom_out`) is to shrink the whole room into view via a pinch/scroll zoom, not to pan to two extremes — but the user explicitly asked for panning instead, so this ports the *intent* (see every student regardless of room width) via the user's own specified mechanism rather than the reference's. This project had no drag primitive at all before this — `driver.scroll()` is wheel-based and explicitly documented as NOT working for gestures that need real mousedown/move/mouseup (that finding was about list-scroll widgets specifically, though, not a room-view camera) — so a new `driver.drag(start_x, start_y, end_x, end_y, duration, steps)` was added: mousedown at the start, several incremental mousemoves over the given duration, mouseup. Live-calibrated on nik-gpu (free — panning and patting don't spend a limited resource, unlike the invite ticket): a drag from x=1500 to x=400 (dragging the mouse leftward) reveals new content on the right side of the room (confirmed — furniture/students appeared that weren't visible in the original view); the reverse drag (400→1500) reveals content on the left (confirmed — fully exposed the train-track corner and an escalator/kiosk area that were partly cut off by default). HUD elements (top status bar, the invite ticket buttons) stay fixed on screen regardless of pan, confirmed across all 3 calibration screenshots — so no camera reset is needed before `CAFE_INCOME`/`CAFE_ROOM_SWITCH`/the invite flow's own fixed-coordinate clicks run afterward. One drag already reached the true extreme in testing (2 more drags in the same direction produced an identical screenshot); `CAFE_PAN_DRAG_REPEATS` (3) keeps a small safety margin anyway, matching this project's established scroll/drag-past-the-extreme-is-a-harmless-no-op pattern from `lesson.py`/`event_sweep.py`'s own list scrolling. `_pat_room` was restructured: the existing single-view sparkle-hunting loop is now `_pat_current_view` (unchanged logic), called once after panning to the right extreme and once after panning to the left, with a new `_pan_camera` helper doing the repeated drags. No vertical panning was added, per the user's own explicit instruction that it would mess with the view. **Confirmed live with real game-state changes** (patting affects real affection, though it's a free, repeatable daily action, not a limited resource like the invite ticket): a full `cafe` run patted a real sparkle in room 2 (`score=0.997`) specifically after panning to an extreme — the sparkle would not necessarily have been visible from the room's default load position — while room 1 found nothing in either view that run (expected: sparkles are on a per-student cooldown, most checks legitimately find nothing). The task completed cleanly end-to-end (both rooms' invite-then-farm sequence, income claimed, confirmed clean return to home) with no warnings anywhere in the log. #### Phase 6 follow-up #4: invite ticket cooldown misread as "list opened" (2026-07-14) Real-usage bug report with a screenshot, filed once the account's invite ticket (spent twice during follow-up #2's live test, same day) went on cooldown: "I have problem with the script keep pressing the invite when it's not available and causing this popup and fail." The screenshot showed a "通知" dialog reading "待機時間が経過した後に、再度招待することができます。" ("you can invite again after the wait time passes") with a single OK/Enter button, no cancel. Root cause: `_open_invite_list`'s check (`not navigation.is_on_subscreen(driver)`, added in follow-up #2 above) verifies only "something opened that dims the header" — true for the real MomoTalk list AND for this cooldown notice alike, since both dim `SUBSCREEN_HEADER_PROBE` the same way. It returned `True` (list opened) for the cooldown case too, so `_invite_student` proceeded straight into `_ensure_invite_sort`/`_try_invite_row`'s fixed sort-dropdown/row coordinates — none of which exist on this small single-button dialog — causing the cascading wrong clicks and failure the user reported. Fix: check `navigation.is_modal_open` (the darker "real dialog" reading, not the list's own lighter dim) immediately after the ticket-icon click, before the list-opened check. A dialog appearing at this exact point can only mean the click itself raised one directly — no row has been clicked yet, so the normal per-row invite-confirm dialog can't be it. When detected, `_open_invite_list` now OCRs the title for logging, dismisses via the shared `SWEEP_CONFIRM_BUTTON` (same "通知" component position reused throughout this project), and returns `False` — which `_invite_student`'s existing (unchanged) fallback already handles correctly: skip the invite, log it, move on to farming. **Confirmed live** (`live-test-runner` agent, real `cafe` run with the ticket confirmed still on cooldown for both rooms): both rooms logged `invite ticket click opened a dialog instead of the list (title: '...') -- likely on cooldown, treating invitation as unavailable` immediately, no cascading errors or misclicks followed, and the task proceeded normally to farming/income-claim and ended cleanly (exit 0, true home screen confirmed via screenshot). OCR of the dialog title came back noisy (`'通和 X'` instead of `'通知'`) but this doesn't matter — the fix's branch doesn't depend on the title text, only on `is_modal_open` firing before any row was clicked. #### Phase 6 follow-up #5: rank-up cutscene misread + cooldown-notice dismiss misalignment (2026-07-15) Two real bugs reported together in `next_fix.md` after a live `cafe` run: **Bug 1**: a pat that triggered a real 絆ランクアップ (bond rank-up) cutscene caused the run to skip the rest of that room's farming instead of self-healing through it, matching a gap CLAUDE.md had already flagged as theoretical ("is_on_subscreen's fixed header probe reads inconsistently across different characters' cutscene art") — now confirmed live. Root cause: `_dismiss_rank_up_if_shown` checked `navigation.is_on_subscreen`, a single header pixel at `(500,10)`; for this run's character, that pixel happened to read bright, so the loop believed the cutscene had already cleared without ever pressing Enter, then `_pat_current_view`'s loop kept polling `find_cafe_sparkle()` against the still-showing cutscene for the rest of its budget. Fixed by adding `navigation.is_header_bar_visible` (8 spread-out header-row x-positions, all must read bright — a real header bar is uniformly flat-colored across its width, unlike a photo-real character composition) and switching `_dismiss_rank_up_if_shown` to use it. Best-effort: could not force a live rank-up on demand to confirm end-to-end this session. **Bug 2**: when the invite ticket is on cooldown, the cooldown-notice dismiss (added in follow-up #4 above) clicked `SWEEP_CONFIRM_BUTTON`'s fixed coordinate, which could miss this single-OK notice's own button (not necessarily laid out like the two-button dialogs that coordinate was calibrated against) — a missed click left the notice open, and the camera-pan drags that followed landed on the still-open dialog instead of the room view. First fix attempt switched to a plain `driver.keypress("Escape")`; an independent `reference-parity-reviewer` pass caught that this was itself unverified (an unconfirmed keypress is no more trustworthy than the unconfirmed click it replaced, and this project's own `config.py` documents at least one dialog where Escape does *not* close it) — so the actual fix presses Escape and verifies with `navigation.is_modal_open`, retrying up to `ROOM_OPEN_RETRIES` times and logging clearly if it's still stuck, rather than assuming one press worked. A related efficiency finding from the same review: `is_header_bar_visible`'s 8-point check was calling `driver.color_at()` 8 times (8 full-screenshot round-trips) for one logical check. Added `driver.colors_at(points)` — one screenshot, multiple points sampled from it, also atomic (no drift across sequentially-captured frames) — and switched `is_header_bar_visible` to use it. Both fixes deployed to nik-gpu; the reviewer independently pixel-verified `is_header_bar_visible` against real captured screenshots (`screenshots/cafe/room1.png`, `screenshots/cafe/student/04_*.png`) and found no false-negative risk against normal cafe/dialog screens. Neither fix has been re-confirmed against a fresh live rank-up or cooldown notice post-deploy — worth watching the next time either occurs naturally. ### Phase 7: setup.sh update **Status: Done — `setup.sh` deploys `ba_daily.py` and `ba_auto/`.** `scripts/ba_dailies_legacy.sh` and `scripts/detect_and_click.py` were deleted once mailbox and cafe both migrated off them (Phases 5–6); `setup.sh` no longer references either. Update `setup.sh` so it deploys: ``` ba_dailies.sh ba_daily.py ba_auto/ assets/ ``` to the expected runtime paths on `nik-gpu`. ### Phase 8: Stamina/AP mission claim **Status: Partially done.** Read `module/collect_daily_task_power.py` (the "Tasks" menu claim loop — `to_tasks` + `rgb_in_range` checks against two fixed pixel pairs, click, dismiss, repeat) and `module/collect_daily_free_power.py` (a `picture.co_detect` state-machine walk into the Pyroxene Purchase menu's Package tab to claim a genuinely free 10 AP item). Live reconnaissance on the home screen found the direct local equivalents: a `ミッション` (Mission) icon opening a panel with per-tab claim buttons *and* a single bulk "一括受取" (claim all) button whose keyboard shortcut is literally Enter — much simpler than porting the reference's per-tab color-probe loop. `ba_auto/tasks/stamina.py` opens the Mission panel, checks whether "一括受取" is enabled (bright yellow vs. flat grey background probe at `config.MISSION_CLAIM_PROBE`), and if so presses Enter to claim, Enter again to dismiss the reward-reveal card (same "harmless no-op if absent" assumption as cafe's room-entry dismiss), bounded to a few rounds in case multiple rewards queue up. The 青輝石購入 (Pyroxene Purchase) icon — reference's Daily Free Power entry point — was opened once to confirm the free-claim flow's location, but turned out to be a real-money purchase menu (¥3,000–¥4,900 package buttons visible immediately) with the genuinely-free item buried in a further tab. Given `plan.md`'s own purchase-safety rules ("avoid unbounded spending", "avoid buying unknown items"), this was **deliberately not automated this round** — the dialog was closed without navigating further. Treat this as a separate, explicitly-confirmed piece of future work, not an oversight. Two real bugs found and fixed during live calibration, both worth remembering for future coordinate-hunting: - **Visual gridline coordinate estimates were wrong twice in a row.** Reading icon bounds off a scaled/annotated screenshot crop by eye put the Mission icon's center at `(146, 352)` — which is actually in the dead space between the Mission and Pyroxene-Purchase icons, close enough to the latter's edge that clicks there landed on Pyroxene Purchase instead. The fix was sampling actual pixel colors along a scanline (`img.getpixel`) to find each icon's true left/right edge against the background, rather than eyeballing gridlines — this put the real center at `(75, 350)`, squarely inside the icon graphic, confirmed live. Lesson: for icon coordinates, prefer a pixel-boundary scan over a visual grid-overlay estimate. - **`driver.click()`'s combined `xdotool mousemove X Y click 1` invocation is unreliable; splitting it fixed a chunk of this project's long-documented click flakiness.** Repeated single-click tests at a *verified-correct* coordinate still missed intermittently until the mousemove and click were issued as two separate `xdotool` calls with a short (0.2s) pause between them — after that, every subsequent click registered. This plausibly explains some of the "icon click missed on the first attempt, worked on retry" flakiness documented in Phases 5–6 (mailbox/cafe icons). Applied to `driver.click()` itself (project-wide, since all tasks share it) rather than special-cased in `stamina.py`; regression-tested live against `mailbox` and `cafe` after the change — both still work. Not yet done: Group/Club AP, and Daily Free Power (see above). #### Phase 8 follow-up: Daily Free Power built as its own task (2026-07-15, see Phase 16) The 青輝石購入 menu deliberately left unautomated above turned out to gate a real-money purchase UI in general, but the specific card this was always about (`module/collect_daily_free_power.py`'s 毎日無料パッケージ, 0 yen, AP+credits) is safe to automate on its own — it's a fixed, non-selectable, always-free claim with no purchase decision to make, same category as this phase's own Mission-panel claim. Built as a separate `ba_auto/tasks/gem_shop.py` rather than folded into `stamina.py`, since it's a different reference file/UI entry point. See Phase 16. ### Phase 9: Normal/Hard story AP sweep **Status: Done.** Read `module/explore_tasks/sweep_task.py` and `module/explore_tasks/task_utils.py` — the reference flow reads the current region number and matches stage-name text via OCR (`swipe_search_target_str`) to navigate to a configured target stage, then runs a per-stage claim loop. This client exposes a much simpler path to the same goal (burn AP via already-3-starred stages) that avoids porting the OCR-based lookup entirely: each stage's own 任務情報 (task info) modal has a self-contained 掃討 (sweep) sub-panel with a MIN/-/+/MAX count stepper and a start button. Per explicit user direction on target selection: rather than a fixed configured stage (the plan's original "suggested first version"), `ba_auto/tasks/story_sweep.py` gets the *latest unlocked* region by spamming the "next region" arrow until it stops advancing (a plain state-change check, no OCR — clicking past the last region is a harmless no-op, verified live), then picks one of that region's stages essentially at random (`_pick_random_stage_row`: scroll the stage list to one of its two extremes at random, then pick a random one of the 4 visible rows there — not perfectly uniform since middle stages are reachable from both extremes, but avoids OCR/generic scroll-enumeration). AP spend is bounded by the in-game MAX button per explicit user direction (no additional cap layered on top). Three real bugs were found and fixed during live calibration, all specific to the fact that this task spends real AP (unlike every other task so far, which only claims free rewards): - **`navigation.is_modal_open`'s default probe `(960, 200)` false-negatives on this modal.** The 任務情報 modal is wide enough that `(960, 200)` lands on the modal's own white card, not the dimmed backdrop. Fixed with a task-specific `config.STAGE_MODAL_PROBE = (1870, 600)` and a local `_is_stage_modal_open()` check. First live test aborted safely on this false negative (correctly spent 0 AP) before the fix. - **The MAX button click was never verified, and silently under-delivered.** A live run completed without error but only spent ~10 AP (one sweep) instead of the ~190 AP a real MAX (19 sweeps) should cost — diagnosed by comparing the actual AP/gold delta against the AP preview text seen during manual calibration. Root cause: the same general click-flakiness documented in Phase 8, just unverified here because nothing checked it. Fixed with `_count_raised_above_one()`: the sweep count's "-" stepper button is flat grey at the default count of 1 and turns vivid orange once raised, so probing `config.SWEEP_MINUS_BUTTON_PROBE` after the MAX click cheaply confirms it landed, without needing OCR on the count itself. Wrapped in a bounded retry (`MAX_BUTTON_RETRIES = 3`), aborting with zero AP spent if it never confirms. A subsequent live test hit 0/3 on this retry (a real flakiness cluster, not a logic bug) and correctly aborted without spending; the very next live run succeeded on attempt 1 with a genuine MAX (count 1→19, AP 191→1 confirmed by screenshot), so the retry+verify mechanism does its job on both sides — safe abort on failure, correct spend on success. - **Escape does not close this modal, and the fallback dismiss loop was a latent hazard.** After a real sweep, the post-sweep dismiss loop pressed Enter a fixed number of times to clear reward-summary popups; live testing showed that once those popups run out, the *same* underlying 任務情報 modal reappears — and its Enter hotkey is bound to the live "任務開始" (start manual mission) button, not a no-op. The original fixed round count (3) happened to land exactly on the modal's reappearance without going further, but a different reward-popup count on another run could just as easily have pressed one Enter too many and started a real manual battle attempt. Two fixes: `_dismiss_sweep_result` now checks `_is_stage_modal_open` before every Enter press and stops immediately once the modal reappears, instead of trusting a fixed count; and closing now happens via a new `_close_stage_modal()` that clicks the modal's own X button (`config.STAGE_MODAL_CLOSE_BUTTON`, pinned via pixel-scanline scan of the glyph, not visual estimate) with a bounded retry+verify, since two Escape presses were confirmed live to leave the modal open. If the X-click ever fails to confirm closed, the task logs a warning and stops rather than pressing any further keys blindly. Verified live: work-hub → task-screen navigation, latest-region advance, random stage pick, stage-modal-open detection, MAX click+verify, a genuine MAX sweep (19 runs, AP 191→1, gold +9,144), and the modal-close-via-X-button fix (confirmed via direct scripted click that it reliably closes and returns to the stage list). Not yet re-verified end-to-end in one single run: the fixed dismiss-loop-then-X-close sequence together, since AP was down to 1/240 after the successful test and there wasn't a further real sweep available to test against before the fix was deployed — each half was verified independently instead. Re-run `~/ba_dailies.sh story_sweep` once AP has regenerated to confirm the full sequence end-to-end. `story_sweep` is deliberately **not** in `ba_daily.py`'s `DEFAULT_ORDER` — it spends AP on a randomly-picked stage rather than reclaiming something free, which is a real resource decision the default unattended run shouldn't make blindly. It must be invoked explicitly (`~/ba_dailies.sh story_sweep`). **Retrospective — OCR avoidance was a mistake here.** Three of this phase's four live bugs (wrong modal probe, unverified MAX click, Escape-doesn't-close-modal plus the latent accidental-battle-start hazard) trace back to one decision: avoiding the reference's OCR-driven, deterministic stage targeting in favor of a heuristic substitute (random-pick + pixel-probes). A deterministic "go to configured stage X" flow, ported from the reference the way `module/explore_tasks/sweep_task.py`/`task_utils.py` actually do it, would not have needed to guess whether a modal opened via an easily-mismatched color probe, nor would it have left ambiguity about what's under the cursor when dismissing reward popups. This project's policy is now to port the reference's OCR-driven logic when the reference uses OCR for a feature, rather than inventing a non-OCR substitute to avoid the setup cost (see `CLAUDE.md` → "OCR policy"). `story_sweep.py`'s random-stage-pick design is not being reverted retroactively without user direction, but any future rework of this task should prefer porting the reference's actual region/stage-name OCR matching over the current random-pick approach. ### Phase 10: story_sweep OCR/state-machine port (supersedes Phase 9's random-pick design) **Status: Done.** Acted on Phase 9's retrospective: set up OCR for real (`pytesseract` + the `tesseract-ocr` apt package) and ported the reference's actual deterministic stage targeting, replacing the random-pick heuristic. See `Handoff.md`'s history (deleted once this phase landed) for the full brief; summary of what changed: - **OCR primitive**: `ba_auto/detector.py`'s `read_text()`/`read_int()` crop a screenshot to a pixel rect, threshold it to pure black/white (this measurably fixed real digit misreads that survived every `psm` mode when left anti-aliased — see below), upscale 3x, and run `pytesseract`. `lang="eng"` is enough; the region-number and stage-label reads are pure digits/dashes, no Japanese trained data needed. - **Config-driven targets**: `config.STORY_SWEEP_TARGETS = [(region, stage, count_or_"max"), ...]`, mirroring the reference's `unfinished_normal_tasks` shape. Ships with a placeholder `(1, 1, "max")` entry per explicit user direction — edit it to your own already-cleared stage(s) before running for real. - **Deterministic region navigation** (`_go_to_region`, porting `task_utils.py::to_region`): OCR the region-number readout, click the exact left/right-arrow delta, re-check, bounded loop. Region-arrow presence (locked/last-region detection) is a `detector.region_contains_color` box scan, not a single fixed point — a centroid-derived single point landed in the concave notch of the "<"/">" chevron and read "absent" even while the arrow was clearly rendered a few pixels away. - **Deterministic stage search** (`_find_stage_row`, a scoped-down `swipe_search_target_str`): OCR each of the 4 visible stage-row labels at both already-calibrated scroll extremes, matching by the label's suffix after the dash (e.g. "2" in "30-2") rather than the full string — the font's leading region digit reads unreliably even after threshold preprocessing (e.g. "3" as "2"), but the suffix read correctly on every row tested, and the region digit is redundant anyway since `_go_to_region` already confirmed it independently. - **Scoped `co_detect` port**: `navigation.wait_for_state(driver, config, reactions, ends, max_iterations)` — checks named `ends` first each iteration (stop, return the name), then named `reactions` (run an action, keep polling), else waits and retries up to a bound. Generic and reusable beyond this task. - **Named outcomes**: `_sweep_target` returns `"swept"`, `"inadequate_ap"`, `"region_unavailable"`, `"stage_not_found"`, or `"unrecognized_state"` instead of a single generic "Done". Four real, live-discovered findings, on top of what Phase 9 already found: - **Regular numbered stages (30-1..30-5) render a different, taller modal layout than the "-A" bonus stage Phase 9 exclusively calibrated against.** Regular stages add a "集中指揮"/"簡易攻略" tab row and a manual "任務開始" panel below the sweep sub-panel that "-A" doesn't have. Phase 9's `SWEEP_MAX_BUTTON`/`SWEEP_MINUS_BUTTON_PROBE`/`SWEEP_START_BUTTON` all missed by ~40-46px vertically against a real numbered stage (30-3) — caught live when the MAX-click retry correctly failed 3/3 and aborted without spending AP, rather than silently misfiring. Re-calibrated against the tabbed layout via pixel-scanning (not eyeballing); the "-A" layout's original Phase 9 coordinates are no longer what these constants hold, so a future sweep of a "-A" stage specifically would need its own re-check. - **The modal's own X-close button also moves with the layout.** Not just the sweep sub-panel — the whole card is vertically positioned by its own content height rather than anchored at a fixed absolute position, so the X button sits at a different absolute Y (225 vs Phase 9's 271) in the taller tabbed layout. Caught live: `_close_stage_modal` correctly reported "not closed" (3/3 retries) against the stale coordinate, rather than silently believing it had closed. - **Clicking 掃討開始 always raises an AP-usage-confirmation dialog the design had never accounted for at all.** ("APを`N`使用して、掃討を`M`回行いますか?", OK/Cancel.) A first live attempt at porting the "wait for outcome" step read this dialog's dimmed backdrop as a false "inadequate_ap" through an early, unverified placeholder probe — worth remembering: an uncalibrated placeholder check can be actively *wrong*, not just inert, if given a chance to run before it's confirmed. Fixed by explicitly clicking through this confirmation before watching for the real result. - **Genuine insufficient-AP is a visually near-identical dialog at the exact same OK-button position, told apart only by color.** Deliberately triggered live (by emptying the sweep count via MAX/"+" at low AP) rather than guessed: a real "AP不足" case shows a dialog titled "AP購入" (spend real Pyroxene to buy more AP) whose OK button is gold/yellow, vs. the safe usage-confirm's cyan — same position, different color. `_is_ap_purchase_prompt`/`_is_sweep_usage_confirm` tell them apart by that color and only ever click the cyan one; the gold one is always cancelled, never clicked, matching this project's purchase-safety rules. Also fixed in passing, found only because live testing exercised the actual home-screen click path repeatedly: `config.TASK_CARD`'s old coordinate `(1370, 450)` sat close enough to the 任務 card's bottom edge that one run missed and landed on the "総力戦" (Total War) card below it instead — confirmed via screenshot, safely backed out with zero AP spent, moved to `(1250, 380)` (squarely on the "任務" title text). Verified live end-to-end at least once, real AP spent: a genuine 5x sweep of 30-3 (AP 53→~5, confirmed via the "掃討完了" results screen's reward totals), including clicking through the usage-confirm dialog, the SKIP animation-skip screen, and the final reward-totals OK, landing back on the bare stage-info modal afterward. The genuine insufficient-AP path was also verified live (correctly cancelled the real "AP購入" purchase prompt without spending Pyroxene). Not yet re-verified end-to-end with the final rewritten code specifically (the color-based dynamic button-finding in `_watch_sweep_result`) at a nonzero AP balance — the manual walkthrough that discovered the dialogs used direct scripted clicks before the code was rewritten to match; the rewritten code's color-matching logic was separately verified offline against the exact screenshots captured live (all four dialog states correctly classified), but a fresh live run once AP regenerates would close that last gap. Not verified: sweeping a "-A" bonus stage (needs its own layout re-check, see above), an integer (non-"max") configured count actually being clicked via `SWEEP_PLUS_BUTTON`, and Hard-mode tab stages. **Phase 10 follow-up (user-reported):** the user ran `story_sweep` for real and it spent AP on region 1 stage 1 instead of region 30 (their actual current last region). Not a code bug — `config.STORY_SWEEP_TARGETS` still held the literal placeholder `(1, 1, "max")` shipped with this phase, and the user hadn't edited it yet. Rather than just filling in one static `(30, N, "max")` entry, the user asked for the stage within region 30 to rotate daily across all 6 of that region's stages instead of grinding one fixed stage every run. Added `config.STORY_SWEEP_ROTATION_REGION`/`STORY_SWEEP_ROTATION_STAGE_COUNT`/`STORY_SWEEP_ROTATION_COUNT` and `story_sweep._rotation_target()`, which computes `(region, stage, count)` from `datetime.date.today().toordinal() % stage_count` — a plain date-ordinal modulo rather than calendar day-of-year, so the 6-day cycle doesn't skip or repeat around a year boundary. This target is appended to (not a replacement for) whatever's in `STORY_SWEEP_TARGETS`, which is now empty by default. Verified the computed target offline (region 30, stage 6, on the date this was fixed) but not yet re-run against the live game since AP hadn't regenerated. ### Phase 11: Common Shop + Tactical Shop **Status: Done.** Ported `module/shop/common_shop.py` / `module/shop/tactical_challenge_shop.py`'s `implement()` and the shared `module/shop/shop_utils.py` (`to_common_shop`, `get_item_position`/`ensure_choose`/`buy`) to `ba_auto/tasks/shop_common.py` / `shop_tactical.py`, sharing control flow through a new `ba_auto/tasks/shop_utils.py`. Key design call, made after live-capturing both shop tabs before writing any code: the reference's own item identification inside the grid is **not** OCR-based. `get_item_position` scans fixed pixel columns and matches a purchasable-state color plus a currency-icon template, then maps that grid position to an item identity via `self.static_config.common_shop_price_list` — a table sourced from an external resource this repo doesn't contain (the reference dataclass just declares the field; the actual values are fetched elsewhere, at BAAS's own runtime). So porting this feature couldn't mean "OCR the item names" (the reference doesn't do that either) — it meant building our own local equivalent of that static table by live-capturing the real catalog and letting the user pick their buy list from it, same shape as `STORY_SWEEP_TARGETS`. `config.COMMON_SHOP_TARGETS` / `config.TACTICAL_SHOP_TARGETS` are `(row, col, item name (comment only), expected price)` tuples; identification is by fixed grid position, with price-digit OCR (something the reference doesn't even do per-item) layered on as an extra live-catalog-drift safety net, consistent with this project's verify-before-spend pattern elsewhere. What was found live, captured before writing any code (see the session's live-capture screenshots, not kept in the repo): - **Both shop tabs share one UI**: a 4-column checkbox grid per item, then a single bulk "購入" (Buy) button that appears once ≥1 item is checked, rather than the reference's per-item purchase flow. Checked state renders a distinct vivid yellow-green on the checkbox glyph, easily told apart from the plain white/grey unchecked state by `detector.region_contains_color` — no template matching needed. - **The tactical shop's tab list (7 entries) fits on screen with no scrolling**, so `config.SHOP_TAB_TACTICAL` is a fixed click rather than a port of the reference's `goto_shop_by_name` OCR swipe-search — there's nothing to search for on this account, so a fixed click is the faithful choice here, not a shortcut around OCR (see `CLAUDE.md`'s OCR policy: only skip OCR where the reference's own need for it doesn't apply). - **Price-digit OCR needed real calibration**, same as Phase 10's stage labels: a rect wide enough for the widest configured price (500,000) without also catching the neighboring column's card, and narrow enough on the left to exclude the currency icon (which OCR otherwise misreads as a spurious leading digit — confirmed live, e.g. the top-bar credit balance read "454920755" instead of "154920755" until the icon was excluded). - **One shared corner-pixel probe (`config.SHOP_OVERLAY_PROBE`, a bottom-left point) detects both the purchase-confirm dialog and the post-purchase "報酬獲得!" (reward acquired) banner** — both dim it away from pure white; it stays pure white with nothing open, confirmed stable across tab switches and scrolling. `shop_utils.confirm_purchase` presses Enter in a bounded loop until it reads idle again, rather than tracking each dialog's own layout individually. - **Live-tested with real purchases, not just offline-verified.** With the user's explicit buy lists confirmed first (8 Common Shop items: 初級/中級/上級/最上級レポート + 初級/中級/上級/最上級強化珠; 2 Tactical Shop items: 初級/中級栄養ドリンク), both shops were run for real: Common Shop total cost matched the pre-calculated 1,211,500 credits exactly (confirmed against the game's own confirmation-dialog total); Tactical Shop's AP gain (+90) and coin spend (-45) matched exactly. Both purchases resolved cleanly back to an idle screen via the overlay-probe loop. - **Discovered live, not anticipated going in: these shop items have a per-refresh-cycle purchase cap that isn't shown as a visible counter** (unlike the Pyroxene-shop tab's explicit "あと1回購入可能" labels — Common Shop items just look normal until you've already bought them, then go quietly unresponsive). Found by re-running the actual `shop_common` CLI task shortly after the manual purchase above: every configured item's checkbox failed to register as checked, and a direct test of the individual per-item "購入" button and the page's own "全て選択" (select-all) control confirmed those specific items are genuinely non-interactive right now (select-all successfully picked up *other*, not-yet-purchased items further down the list), while credits stayed unchanged throughout. The task correctly reported "nothing selected, cancelling" and spent nothing, rather than misfiring — a real edge case the price-verify + checkbox-confirm design caught safely, not a bug in it. Net effect: a fully "fresh, everything-available" unattended run hasn't been re-verified end-to-end today, since this account's cycle allowance for these specific items was already spent via the same session's manual calibration. Next run after the shop's own refresh timer (~5h, shown in-game as "更新まで") should exercise that path for real. - **Not verified**: non-fully-visible target rows requiring a scroll (both current buy lists happen to be fully visible without scrolling, so `shop_utils` has no scroll/pagination logic yet — would need porting `buy()`'s `last_checked_idx`/swipe-diff tracking if a future buy list needs it); the pre-purchase "insufficient assets" abort path (both currencies were comfortably sufficient this run); the paid manual shop-refresh flow (`更新` button) — deliberately not automated for v1, same reasoning as Daily Free Power (real-currency spend needs explicit human intent, not a default unattended path). ### Phase 12: Lesson/Schedule **Status: Done.** Ported `module/lesson.py`'s control flow (`implement`, the `to_*` navigation state machine, `get_lesson_each_region_status`/`get_lesson_relationship_counts`, `choose_lesson`, `execute_lesson`) to `ba_auto/tasks/lesson.py`. Scope, decided with the user before writing any code (see the two `AskUserQuestion` answers): affection-first selection (mirrors `lesson_relationship_first=True` — matches this feature's own "affection farming" framing, not raw reward-tier grinding), sweep every unlocked region in a fixed order until either lesson tickets or scoreable lessons run out (no per-region target list needed from the user, unlike shop's per-item buy list), no lesson-ticket purchasing and no favor-student targeting (both deferred, matching plan.md's original "Suggested first version"). Key finding before writing any code: **this client's UI is structurally the same two-level layout the reference describes (12 named regions, each with up to 9 individual lesson locations) but rendered completely differently** — a scrollable list of regions instead of the reference's paged single-region swipe view, and a clean "すべてのスケジュール" grid-card modal instead of the reference's raw isometric map + `Parallelogram`/`Triangle` pixel-scanned status grid. Two consequences: - **No OCR is needed for region navigation at all.** The reference OCRs the current region name because its paged arrows leave position ambiguous. This client's region list only ever settles at two scroll positions (scrolled to top: regions 0-5; scrolled to bottom: regions 6-11, confirmed live — repeated scroll-down clicks don't keep scrolling past this), so navigation is just "scroll to the right state, click the row at a fixed Y" — deterministic, nothing to locate. The reference's own 12 JP region names (`core/config/default_config.py`'s `lesson_region_name.JP` — embedded directly there, not fetched externally like the shop price table) are kept locally purely for log readability, confirmed to match this account's list exactly, row for row. - **No isometric geometry is needed for per-cell status either.** Each grid-modal card shows up to 3 student portraits with a heart-shaped affection-count badge — reading that number via OCR needs no geometry at all, replacing the reference's isometric pixel-scan outright. Three real bugs were found and fixed via a live test run (5 real tickets spent across 3 regions, ticket accounting and cleanup navigation both confirmed correct throughout): - **A checkmark does not blank the badge number — it renders alongside it.** The initial design assumed an "already done today" portrait would fail the digit-whitelisted OCR read (no number left to read), the same way a "no relationship yet" portrait does, and treated both as identically "not a candidate." Live testing showed this was wrong: a done portrait keeps its unchanged number and gets a small green checkmark added at top-right instead. Without separately checking for that checkmark, the same already-done cell could be re-picked immediately after being completed. Fixed by detecting the checkmark directly via its own fixed color/offset (`config.LESSON_GRID_CHECKMARK_*`, `lesson._is_slot_already_done`) rather than inferring done-ness from the OCR read. - **The badge's own OCR read was unreliable, and not for the reason first suspected.** Reusing the project's existing generic `detector.read_int` (grayscale + hard threshold, tuned for this UI's normal dark-text-on-light-card look) on the pink/magenta heart badge produced wildly oversized results live — "13" read as "113", "19" as "119", "18" as "418". Root cause, confirmed by rendering the exact same threshold step locally: the heart's own darker outline stroke has a grayscale value that happens to land on the same side of the threshold as the digit glyph, surviving as stray black marks that tesseract sometimes fuses into extra leading digits. Fixed with a dedicated `detector.read_int_on_heart_badge`, which masks on the color relationship "R < G" (true for the navy digit glyph in every sample, false for every pink/magenta badge tone, light or dark) instead of raw brightness — this alone fixed most cases. A few slots still occasionally fuse a stray digit from the character's own portrait art bleeding into the crop's edge (this is character-art-dependent, not fixable by OCR config alone — confirmed reproducible across every psm mode tried). Rather than chase perfect crop geometry per-character, `config.LESSON_GRID_BADGE_MAX_PLAUSIBLE` discards any reading of 100+ as certain contamination (real affection values never reach that range in practice) — a second line of defense, not the primary fix. - **A transient "2x schedule" campaign event (active on this account, ~6 days remaining) doubles how many intermediate screens appear after starting a lesson**, and a bond-rank-up cutscene can appear too (same full-screen "絆ランクアップ!" style already handled in `cafe.py`, confirmed live here for the first time against a real trigger). Rather than special-case either, `lesson._run_one_schedule` presses Enter in a bounded loop, checking two fixed markers each round: the grid modal's own title underline color (visible only when it's frontmost and idle — both the results modal and the cutscene cover it, confirmed live against screenshots of all three states) as the "done" signal, and the results modal's OK button color (shared with the info panel's Start button, confirmed by direct pixel sample) as the one intermediate state worth clicking precisely rather than folding into the blind-Enter fallback. Also confirmed live: the "保有チケット N/M" ticket counter is directly visible on the region-list screen (no submenu needed, unlike the reference's `to_purchase_lesson_ticket`/OCR-a-modal approach) — `lesson._read_ticket_count` just OCRs it directly and re-reads it after every schedule to track spend, rather than assuming exactly 1 ticket per schedule (the 2x campaign event doesn't change the ticket cost, only the reward/cutscene count, but re-reading rather than assuming keeps this robust either way). **Not verified**: a region with more than 9 currently-unlocked locations (would need scroll support inside the grid modal — not implemented, not yet seen on this account, both regions tested topped out at 7-8); the "no lesson tickets" abort-immediately path (tickets hit exactly 0 mid-sweep during the real test, not at the start); a full 12-region sweep in one run (the test's 5 tickets ran out partway through region 3 of 12); a truly fresh "everything available, nothing done yet" run (this account had already done some lessons manually during calibration before the automated run started). Worth re-running `~/ba_dailies.sh lesson` after tickets next refill to exercise the untested tail of the region list. ### Phase 12 follow-up: ticket-count OCR regression fix Between sessions, `~/ba_dailies.sh lesson` started failing immediately every run with `[lesson] could not OCR ticket count, aborting without pressing further keys` — a full regression of a read that worked during Phase 12's own live test. Root cause, confirmed by pulling a live screenshot back to `scratchpad/` and testing `detector.read_text` against the exact configured rect in isolation: `LESSON_TICKET_OCR_RECT`'s left edge (`x=295`) clipped in a stray few pixels of the "チケット" label's own trailing katakana glyph, immediately adjacent to the first digit. That fragment was enough to make tesseract drop the whole leading digit from its read — `"7/7"` came back as `"/7"`, confirmed reproducible across a fresh screenshot every single time (not a one-off render glitch, not a transient game-side layout change). Splitting `"/7"` on `/` gives an empty head, so `_read_ticket_count` returned `None`, exactly matching the failure. Fixed by tightening the rect to `(315, 135, 375, 170)` — pixel-column analysis of the crop located the actual digit glyphs' bounding box and the new rect starts past the stray fragment. Re-confirmed stable across 6 consecutive fresh OCR reads before deploying, then validated with a full real run: 7 real lesson tickets correctly spent, ticket count correctly re-read after every single schedule (7→6→5→4→3→2→1→0), clean stop at 0, and a clean return to the home screen confirmed via a final screenshot. This is the same class of fragility as the heart-badge OCR contamination fix from the original Phase 12 writeup above (a plausible-looking crop still picking up unrelated UI content at its edge) — worth keeping in mind for any other tightly-cropped OCR rect in this project if a similarly "worked before, fails now" regression shows up elsewhere. ### Phase 12 follow-up #2: schedule-icon navigation regression fix ("stuck on a region map") Immediately after the ticket-OCR fix above, a full real run (`./ba_dailies.sh lesson`) still failed completely — this time past ticket reading (`starting tickets: 1` printed correctly), but `[lesson] schedule grid not detected for region index N (attempt 1-3/3)` for literally every region in sequence, 0 through 8, until the user Ctrl-C'd. The user's own suspicion was that `driver.click` was doing a click-and-hold instead of a single click; `driver.click`'s implementation was checked and is an ordinary `mousemove` + `click 1`, unchanged — that wasn't it. Root cause, confirmed live by screenshotting the actual screen right after clicking `LESSON_ICON`: **the game does not always land on the Location Select list when the schedule icon is clicked.** It remembers the last-viewed region and reopens directly to that region's per-region isometric map instead — confirmed by reproducing the exact scenario (manually opening a region's map, then running the real task without returning to the list first) and seeing the identical failure signature. The previous session's run had been left stuck exactly like this by its own Ctrl-C interruption, mid-sweep, on some region's map — every subsequent `_open_region_grid` call for every region_index then fired its scroll/row-click sequence against that same stuck per-region map screen, which doesn't respond to any of it the way the list does, so nothing ever matched and every region failed identically. This isn't a one-off leftover-state fluke either: since the game itself decides whether the schedule icon resumes on a region or resets to the list, any future interruption (or even manual browsing) before a run could reproduce it again. Fixed with a new `lesson._ensure_location_select_list`, called right after `_open_schedule_screen` confirms a subscreen is open: it checks whether `LESSON_ALL_SCHEDULES_BUTTON`'s position already shows that button's known color *before any row here has been clicked* — that button only exists on the per-region map, never on the list (confirmed by direct pixel sample: same coordinate reads as plain dark background on the list) — and if so, presses `LESSON_BACK_BUTTON` (bounded, `OPEN_RETRIES` attempts) to return to the list before the sweep begins. Validated live in two passes: first, a full real run from the recovered list (7→...→0 tickets from the earlier ticket-OCR fix test) confirmed the list-based flow itself was never broken. Second, the actual regression was reproduced on purpose — manually left the game on a region's per-region map, then ran the real task — and the new recovery step printed `schedule screen resumed on a specific region's map instead of the Location Select list -- returning`, correctly returned to the list, read the account's 1 remaining real ticket, spent it on a real schedule, and finished cleanly with a confirmed clean return to the home screen. 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). **Scope decisions, made with the user before writing any code** (see the two `AskUserQuestion` answers, 2026-07-09): - Fights exactly **one battle per invocation**, matching the reference's own per-call pacing, rather than looping to spend every available ticket in one run. 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 repeated ticket spend means rerunning the task (e.g. via cron) rather than an internal loop. - Full port including the actual auto-fight (not deferred to a later phase), since this is inherently a real-PvP-consequences feature and a ticket-only/no-fight v1 wouldn't be testable in a way that matters. - Reward collection (`collect_tactical_challenge_reward`) runs unconditionally at the end of every invocation, unlike the reference's "only if this was the last ticket" rule — both reward slots are idempotent/harmless to check every time, and there's no scheduler here to guarantee a later invocation will do it. **Real navigation differences found live, before writing the real logic:** - **Tactical Challenge is a card inside the お仕事 (Work) hub** (`config.ARENA_WORK_HUB_CARD`), not a bottom-nav icon on the main page the way the reference's `to_tactical_challenge` assumes — the same "hub card, not a nav icon" pattern already found for story_sweep's task browser. - **The reference's two separate screens — opponent-info, then a distinct formation-edit ("攻撃編成") screen — are merged into one modal here**, showing the matchup and the attack-formation button together with a live ticket-count preview (e.g. "5→4") that confirms it's the actual fight-commit step before ever clicking it for real. - **`navigation.is_modal_open`'s shared darkness probe reads INVERTED on this specific screen**: the arena list's own background art at that probe point is darker than the modal's white card there, the opposite of every other screen using that probe. `arena.py` has its own `_is_modal_open` using `config.ARENA_MODAL_PROBE` with the opposite rule, rather than silently reusing the shared one wrong. - **Self/opponent level OCR** (`choose_enemy`'s reroll logic) is read directly off the list screen — matching the reference's own `self_level_region`/`opponent_level_region`, both read there before any modal opens — not from inside the opponent-info modal. **Real bugs found live** (across the session's 5 real tickets — 2 were spent purely debugging one design mistake, which is exactly why the third fix was validated offline against saved screenshots before risking the last ticket on it): 1. **A level-OCR crop that looked legible was still too small for tesseract.** The self/opponent-level "90" digit crop, tight-cropped to just the glyphs, OCR'd as `None` even against a correctly time-matched live screenshot. Debugged by dumping the exact thresholded image tesseract actually sees (`scratchpad/probe_arena_level_ocr*.py`): legible to the eye, but marginal enough at 3x upscale that tesseract succeeded on some rows/psm modes and not others. A few extra pixels of padding on each side fixed it outright across every row, using the same existing OCR pipeline — no upscale/threshold change needed, just a less tight crop. 2. **Self/opponent-level text is bright-on-dark, the opposite of this project's usual dark-on-light OCR assumption.** `detector.read_int` (built around a plain `THRESH_BINARY`) returned `None` against the profile card's white "Lv.90" text on its dark navy background. Added `detector.read_int_white_on_dark` (the same crop pipeline with `THRESH_BINARY_INV`) rather than reworking the shared path, since every other OCR read in this project genuinely is dark-on-light. 3. **The post-fight "対戦結果" WIN/LOSE result modal is not reliably detectable by precisely locating its own confirm button.** Three compounding problems, found in this order: - A screenshot taken ~2s after Sortie showed the arena list already fully updated (rank, ticket count, a new "待機時間" cooldown) with no result modal visible at all — nearly mistaken for "no result screen needed here, this client resolves fights differently than the reference assumes." Wrong: the modal was still pending and only rendered after the next interaction (confirmed by clicking an opponent row afterward and getting the queued WIN screen instead of that row's own info). Must poll for it explicitly rather than trust a fixed delay, same as the reference's own `fight()` waiting on `arena_battle-win`/`arena_battle-lost`. - WIN and LOSE modals are not the same height — WIN shows a reward showcase above its confirm button that LOSE doesn't, so LOSE's confirm button sits noticeably higher on screen. A fixed probe point calibrated only against WIN missed LOSE entirely, which *also* made reward collection silently read "not claimable" right after — it was actually checking the reward buttons while the still-undetected LOSE modal covered them, not a real reward-state problem. - Widening the button search into a region spanning both known positions (the same fix already used for story_sweep's own two-position SKIP/OK button) caught WIN and LOSE correctly, but a live rerun then got stuck: the region also matched stray cyan-ish pixels in the opponent list's own portrait art (which changes every list refresh), and a false-positive centroid click there opened a *completely unrelated* opponent's info modal instead of confirming anything. A narrower, offline-revalidated region (checked against every saved WIN/LOSE/opponent-info/plain-list screenshot before risking the session's last ticket on it) hit the *identical* stuck state on the very next live run — the false-positive source is fundamentally unpredictable per-refresh portrait art, not something a fixed region can rule out. Given the opponent-info modal's own attack-formation button is *also* Enter-bound and spends a real ticket, this was a real near-miss of exactly the class of hazard `CLAUDE.md` already documents from story_sweep ("a mistimed keypress could have started a real battle"). Fixed by abandoning per-button color detection entirely: `_wait_for_result` now presses Enter in a bounded blind-retry loop — the same pattern `lesson.py`'s `_run_one_schedule` already uses for its own "variable sequence of post-action screens" problem, and confirmed live to safely dismiss both the WIN modal, the LOSE modal, and an unrelated "リストの更新時間を超過しました" (list-refresh-expired) notice that can also appear if the season list's own countdown lapses mid-fight. A hard safety gate, checked before every single press, stops immediately without pressing Enter if the opponent-info modal is ever detected (via its own gold button at a fixed, reliable position, not a color-region search) — that modal should never legitimately be showing at this point in the flow, and the two prior tickets were spent confirming exactly how a stray click could get there. **Bonus finding, not anticipated going in**: the "戦闘スキップ" (Battle Skip) toggle was already ON by default on this account, confirmed live via pixel-grid-scan. `check_skip_button`'s reroll-if-off logic is ported (`arena._ensure_skip_on`) but its "off" branch has never been exercised live, since no off state has been seen yet to calibrate against. **Not verified**: the "no ticket" popup race the reference guards against (`get_tickets` going stale between the initial read and the attack-formation click — this account's tickets never hit exactly 0 mid-flow during testing); `choose_enemy`'s actual reroll click (`config.ARENA_REFRESH_LIST_BUTTON` is wired up per the reference's logic, but every opponent offered during testing was already an acceptable level, so a real reroll was never triggered); `ARENA_STOP_FIGHT_WHEN_RANK1`'s rank-1 stopping condition (default `False`, and this account's rank never got close enough to test the branch). All three are implemented per the reference's own logic, not guessed at, but none has been exercised against real game state yet. #### Phase 13 follow-up: consecutive-invocation navigation bug **Reported 2026-07-11**: a real log showed a first `arena` invocation completing normally (fight won, tickets 3→2, rewards checked), leaving the game sitting on the Tactical Challenge screen — then a second invocation immediately failed `_open_tactical_challenge` all 3 retries and aborted without pressing anything further. **Root cause**: `arena.py`'s `run()` never called `navigation.return_to_home` anywhere, unlike `lesson.py`/`shop_*.py`/`event_sweep.py`. `_open_tactical_challenge`'s `WORK_ICON`/`ARENA_WORK_HUB_CARD` clicks are home-screen-relative coordinates; starting from wherever the prior run left the game (the arena list itself) sent those clicks somewhere meaningless, and no amount of retrying from the wrong starting screen would recover. **Fix**: call the shared `navigation.return_to_home(driver)` (built during `event_sweep.py`'s own wrong-page recovery work, explicitly designed to be reusable per the user's request at the time) at the very start of `run()`, before `_open_tactical_challenge`. If it can't confirm reaching home within its bounded retry budget, log a warning and still attempt to open Tactical Challenge anyway (no worse than the prior behavior, and `_open_tactical_challenge` has its own independent verification/retry). **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." **Scope decision**: rather than porting the reference's config-string sweep-list parsing (arbitrary stage lists with per-stage float/fraction AP-fraction counts, e.g. `"9,10,11"` x `"0.5,3,1/3"`), this sweeps exactly one stage per run, chosen from a fixed 9-12 sub-range via the same date-ordinal-modulo rotation `story_sweep.py`'s `_rotation_target` already uses (a plain `date.today().toordinal() % span`, not day-of-year, so the cycle doesn't skip/repeat around a year boundary) — matching the user's own framing of the request. **Live calibration, against the currently-running "鉄道爆走事件" (12 stages, all already 3-starred on this account), with zero real AP spent** (every AP-usage confirm dialog reached during calibration was cancelled via Escape, verified by the top-bar AP counter being unchanged before and after): - **Entry point**: the home screen's top-right event countdown badge/thumbnail (`config.EVENT_BADGE_ICON`), not the bottom-left banner slot — that slot was found live to cycle between several unrelated banners (gacha pickups, other campaigns) across screenshots taken seconds apart, and clicking it landed on a *different, already-concluded* event once. The event list icon (イベント一覧) was also checked and ruled out: its "進行中" tab lists side campaigns (schedule-reward bonus, joint firepower drill, etc.), not the main story event. - **The stage-info modal is structurally identical to story_sweep's**: same MIN/-/+/MAX count stepper, the same AP-usage confirm dialog (reused directly — `config.SWEEP_CONFIRM_BUTTON`/`SWEEP_CONFIRM_CANCEL_BUTTON`/`SWEEP_CONFIRM_CYAN`/`SWEEP_CONFIRM_GOLD` are pixel-identical live), and the same `SWEEP_RESULT_BUTTON_REGION` SKIP/OK result screen — expected, since the reference's own `activity_utils.py::start_sweep` is a near-duplicate of `sweep_task.py::start_sweep`, reusing the same underlying image names. - **Simpler than story_sweep in three ways**, all confirmed live: no region concept (one flat stage list reached via the event's own Quest tab, not a Work-hub-card region browser); the stage list's bottom scroll extreme always reveals stages 9-12 regardless of starting scroll position, with **constant row height** regardless of 1-line vs 2-line title wrapping (no separate top/bottom row-position sets needed, unlike story_sweep's); and the modal has **one fixed layout** confirmed identical across two different stages tested (09 and 12) — no tabbed-vs-plain variant. - **One behavior difference from story_sweep found live**: this modal *does* close on Escape (confirmed: AP counter and screen state both correct after Escape-cancelling the confirm dialog and closing the stage modal), whereas story_sweep's needs its own X button. `event_sweep.py` tries Escape first and falls back to the X button. - **The "-" stepper's raised-count color signal is subtler here** than story_sweep's vivid-orange: a saturated coral `(251,173,152)` vs flat grey `(171,172,171)`, told apart by channel spread (`max-min > 40`) rather than story_sweep's simpler `r>200 and g<180` rule. **Not yet exercised**: an actual real sweep/AP spend (calibration deliberately avoided this, per `CLAUDE.md`'s "validate offline before spending the resource" guidance — the confirm dialog, cyan/gold color distinction, and result-screen mechanics were all visually confirmed but never clicked through to completion); the reference's SSS-availability gate for a stage that has never been cleared (every stage 9-12 on this account was already 3-starred, so `check_sweep_availability`'s "not yet sweepable, needs a manual fight first" branch was never seen — `event_sweep.py` handles this the same defensive way story_sweep handles an unavailable target: if the MAX-button count-raise can't be verified, it aborts that target without spending AP rather than guessing what an unsweepable stage's modal looks like); behavior once this event ends 2026-07-22 and a future event's badge/stage-count/layout replaces it (the 9-12 rotation range and row-position calibration are specific to this 12-stage event, not proven durable across arbitrary future events). #### Phase 14 follow-up: wrong/stale event page fix + shared `navigation.return_to_home` **Reported by the user after a real run**: `event_sweep` opened an old, already-finished event's page instead of the current "鉄道爆走事件". Root cause: `config.EVENT_BADGE_ICON` (the home screen's top-right event badge) is itself a rotating carousel, not the stable single-event slot calibration happened to suggest — it cycles between the current event's own countdown and OTHER notices, including a finished event's leftover reward-claim-period reminder. Calibration's several screenshots all happened to catch it showing the right event, which masked this. **Fix, in two parts:** 1. **New shared primitive**: `navigation.return_to_home(driver)` — a bounded "press Escape, re-check, fall back to clicking the shared back-arrow position, re-check" loop that returns once the home screen is confirmed reached (via `is_on_subscreen` reading `False`). Checks before every single press and never presses blind, matching the project's established "verify before acting" discipline — this specifically avoids the exit-game-confirmation hazard `CLAUDE.md` already documents (a stray Escape on the home screen itself raises Blue Archive's own "exit the game?" dialog). Generic across tasks: it only depends on `is_on_subscreen` and the shared back-arrow position (`navigation.BACK_BUTTON`, confirmed identical across `config.py`'s `LESSON_BACK_BUTTON`/`SHOP_BACK_BUTTON`/the now-removed `EVENT_BACK_BUTTON`), not on any event_sweep-specific state — built specifically so other tasks can reuse it later, per explicit user request ("make the back to home page a module so it can be used by other feature too"). 2. **Wrong-page detection + retry in `event_sweep.py`**: `_find_stage_row` now also reports whether it OCR'd *any* valid stage number at all across the 5 checked rows, not just whether the target matched. Zero valid reads across all 5 is a strong "not actually on a stage list, likely the wrong/finished event" signal (a finished event's Quest tab shows plain "period ended" text instead of stage-row cards) — reported as a distinct `"wrong_page"` outcome, separate from `"stage_not_found"` (right page, this specific stage just isn't visible, which retrying the same badge click won't fix). `run()` now retries the whole entry sequence up to `WRONG_PAGE_RETRIES` (3) times on a `wrong_page` outcome, calling `navigation.return_to_home` and waiting a few seconds before each re-click of the badge, hoping the carousel has moved on by the next attempt. `run()` also now unconditionally calls `navigation.return_to_home` at the very end as a final safety net, replacing the old single unconditional `EVENT_BACK_BUTTON` click. **Not yet live-tested**: this fix has only been syntax/compile-checked and deployed, not run against a real recurrence of the wrong-page bug — the retry timing (3 attempts, 3s apart) is a reasonable-but-unconfirmed guess at how fast the badge carousel actually cycles. #### Phase 14 follow-up #2: stage-row-enter click had no retry **Reported by the user after re-running live.** The follow-up #1 fix worked as intended -- the outer retry loop correctly detected the first attempt's failed event-screen open, called `navigation.return_to_home`, and the second attempt landed on the right event, correctly OCR'd row 1055 as stage "12" (the day's rotation target). But the very next step -- clicking that row's own 入場 (enter) button to open the stage-info modal -- was a **single click with no retry**, unlike every other click-then-confirm step in this module (`_open_event_screen`'s badge click, `_click_max_and_verify`, `_click_plus_and_verify` all retry). That click missed once and the whole run aborted with `unrecognized_state` instead of trying again. This is the same failure class `CLAUDE.md` already documents for the mailbox/cafe icons ("missed the first click and worked on retry"), just not yet covered here. The user's own hypothesis was that the click might actually be registering as a drag rather than a tap -- `driver.click`'s `xdotool mousemove` + separate `xdotool click 1` is a discrete warp-then-click, not a drag gesture, so that specific mechanism seems unlikely, but a plain missed click (or the modal needing a beat longer to render before the corner-darkness probe reads it) fits the existing pattern exactly. **Fix**: new `_open_stage_modal(driver, config, row_y)` wraps the row-enter click in the same click-then-verify-then-retry pattern (`STAGE_ENTER_RETRIES = 3`) already used everywhere else in this file. `_sweep_target` calls it instead of the old bare single click. **Not yet live-tested**: syntax/compile-checked and deployed only. #### Phase 14 follow-up #3: false-positive wrong-page detection + one more missing retry **Reported by the user after a third real run.** Two more instances of the exact same problem class surfaced in one log: 1. **`_find_stage_row`'s wrong-page detection false-positived twice in a row** on a run that turned out to be on the right page the whole time. The Quest tab's stage list evidently hadn't finished rendering on the first OCR pass right after a fresh navigation (all 5 rows OCR'd empty), and the existing 1-second settle wait wasn't always enough. Since "wrong page" triggers the *expensive* recovery path (`navigation.return_to_home` + a full re-navigate, ~3+ seconds each), this wasted 2 of the outer loop's 3 attempts on what was actually just a rendering race, before the 3rd attempt (now naturally a bit later, giving the UI more time to settle) read all 5 rows correctly. Fixed by adding a *cheap* in-place rescan inside `_find_stage_row` itself (`STAGE_ROW_SCAN_ATTEMPTS = 3`, re-reading the same rows with a short wait between, no re-scroll/re-navigate) before concluding "no valid rows at all" — reserving the expensive return-home recovery for a genuinely wrong page, not a timing race. 2. **Once past that, `_click_max_and_verify` correctly raised the sweep count to MAX (on its own internal retry) but the very next click — 掃討開始 (start sweep) — was still a bare single click with no retry**, the last unguarded click in the whole file. It missed once, no confirm dialog (neither the AP-usage-confirm nor the AP-purchase variant) was ever detected, and the run aborted right before it would have actually spent AP. Fixed via new `_click_sweep_start_and_verify`, the same click-then-verify-then-retry pattern (`SWEEP_START_RETRIES = 3`) as every other click in this file — checking for *either* dialog variant, since which one appears depends on current AP. **Not yet live-tested**: syntax/compile-checked and deployed only. Every click in `_sweep_target`'s critical path now follows the same retry pattern; if a future run still fails, the next place to look is `SWEEP_CONFIRM_BUTTON`'s own click (the actual AP-spend-committing click) or `_watch_sweep_result`'s polling loop, the two remaining steps that haven't yet been individually implicated by a real failure. #### Phase 14 follow-up #4: badge-carousel dots + cold-start timing — first confirmed real live sweep **Per explicit user direction (2026-07-10): stop deploy-and-report-back, iterate live over SSH autonomously (fix -> deploy -> run -> screenshot -> diagnose -> repeat) until either a real sweep completes end-to-end confirmed by screenshot, or genuinely blocked.** Full iteration log kept in `PROGRESS.md` during the loop; summarized here. **Root cause #1 — the badge carousel's auto-rotate timer is far slower than this task's retry window.** A self-driven live run hit `"wrong_page"` on all 3 outer attempts, back to back — not a false positive this time, confirmed by screenshot: the badge was genuinely showing a finished event's reward-claim reminder the entire run, and still was 20-30+ seconds later. Live investigation (pixel-scanning + a direct click test) found the badge carousel's own small pagination dots are directly clickable and immediately switch which item shows, bypassing the slow auto-rotate entirely. Fixed: `config.EVENT_BADGE_DOT_X`/`_DOT_Y`, and `_open_event_screen` now clicks a specific dot before each badge-open attempt, cycling dot index across `run()`'s outer retry loop instead of blindly re-clicking the same ambiguous badge. **Root cause #2 — a genuine cold-start settle delay, not a flaky render race.** Deployed the dot fix and re-ran immediately: STILL all-None on every attempt. Live diagnosis via standalone probe scripts (importing this project's real `driver`/`detector`/`config` modules directly through the venv, bypassing `ba_dailies.sh`) proved the navigation itself was landing correctly every time — manually replaying the exact click+scroll sequence and screenshotting at each step showed the stage list rendering perfectly. A dedicated timing probe (poll all 5 rows once/second for up to a minute after a fresh navigation) found the real cause: after a cold start, the Quest tab's stage list can take **up to ~20 seconds** to actually populate (most likely a one-time server round-trip the client only pays on the first open in a session) — far longer than the ~3.5s total budget `STAGE_ROW_SCAN_ATTEMPTS`/`_RETRY_WAIT` gave it. Once a read succeeded, it stayed stable for the rest of a 60+ second observation window — this was never intermittent flakiness, just an undersized budget for a specific one-time delay. Fixed: widened `STAGE_ROW_SCAN_ATTEMPTS` 3->9 and `_RETRY_WAIT` 1.5->2.5 (total ~3.5s -> ~20.5s). **First confirmed real live sweep.** Immediately re-ran `./ba_dailies.sh event_sweep` live (environment already "warmed up" from the preceding diagnostic probes). It worked all the way through: found stage 12's row, opened the modal, raised the count to MAX, clicked 掃討開始, confirmed the AP-usage dialog, and a real 10x sweep executed. **Confirmed by screenshot**: AP went from 206/240 to 6/240 (-200, exactly matching 10 sweeps at 20 AP each — the calibrated MAX count at that AP level), credit-point balance increased (166,215,679 -> 166,221,199), and the game returned cleanly to the home screen with no manual intervention. This is the feature's first genuinely successful end-to-end live run. **One remaining wrinkle, fixed but not yet re-verified live** (the day's AP was fully spent by the successful sweep above, leaving none for a further live test): `_watch_sweep_result`'s own polling budget (`POST_SWEEP_DISMISS_ROUNDS`, originally 6 x 1.5s = 9s) was too short for a 10x bulk sweep's longer reward-reveal sequence, so this run's outcome logged as `"unrecognized_state"` instead of `"swept"` — functionally harmless (`_close_stage_modal`'s fallback + `run()`'s final `navigation.return_to_home` still safely recovered to home), but incorrect bookkeeping. Widened `POST_SWEEP_DISMISS_ROUNDS` 6->14 to match the same cold-start-budget lesson. Also still unresolved, non-blocking: rows 08/09 (stage-list positions 366/538) consistently misread ("2"/`None`) in every test this session including the fully-stable 60-second observation window — doesn't affect today's target (stage 12) but would matter on a day the 9-12 rotation picks stage 8 or 9 specifically; worth a closer look if that ever surfaces as a real `"stage_not_found"` on those days. #### Phase 14 follow-up #5: the wrong-page loop was real after all — JP OCR text detection + carousel-timer-aware retry, two more confirmed real sweeps **Reported by the user a day later**: the exact same wrong-page-looping symptom recurred (full log: 9 scans x 2+ attempts, all rows None). The user asked for a direct check instead of the indirect "zero valid rows" heuristic: does the Quest tab contain the finished event's own "イベント期間が終了しました" text? This requires Japanese OCR, which this project never needed before (only digit/whitelisted-English reads) — no passwordless sudo on `nik-gpu`, so the exact `sudo apt-get install -y tesseract-ocr-jpn` command was handed to the user, who installed it directly and confirmed via `tesseract --list-langs`. **Calibrating the text region took real live investigation.** Repeated attempts to reproduce a wrong/finished event page (every known badge-carousel dot position, the bottom-left banner, the event-story replay archive) kept landing back on the CURRENT correct event instead — eventually reproduced by chance (clicking the badge while it happened to be showing "嵐過天晴"'s reward-claim notice again) and calibrated for real against a live capture: `config.EVENT_FINISHED_TEXT_RECT`'s OCR read the exact phrase `"イベント期間が終了しました。"` verbatim via `lang="jpn"`, psm=6, and a follow-up check on the correct event's own page read unrelated stage-list text with zero false-positive `"終了"` match — both directions live-confirmed, not guessed. Wired in as `_is_finished_event_page`, an early-exit, authoritative-when-positive check inside `_find_stage_row`'s scan loop. **The fix worked exactly as designed, and immediately exposed the real underlying mechanism.** Redeployed and re-ran: all 3 outer attempts now correctly and instantly identified "finished-event page text detected" instead of burning the full ~20s rescan budget each time — but all 3 landed on the *same* wrong page regardless of which badge-carousel dot was clicked first. Live investigation right after (repeated manual dot-clicks, direct observation) found dot-clicking is **not reliably controllable** — it sometimes visibly switched the badge and sometimes did nothing at all on the identical badge state — while simply *waiting* was independently observed to eventually cycle the badge back to the correct event on its own, with zero interaction. This points to a genuine time-based auto-rotate timer as the real mechanism; dot-clicking (follow-up #4's fix) is at best an unreliable nudge on top of it, not the deterministic override it was believed to be. The お仕事 (Work) hub — a stable, non-carousel entry point already used by `story_sweep`/`arena` — was checked as a possible alternative and does not have a dedicated card for this event, so the badge remains the only viable entry point. **Fix**: widened `WRONG_PAGE_RETRIES`/`_WRONG_PAGE_RETRY_WAIT` from 3 attempts x 3s to 6 attempts x 12s (~72s total budget), giving the natural rotation timer a real chance to land on the correct item within the retry window, instead of depending on the unreliable dot-click to force it. Dot-clicking was kept as a harmless best-effort nudge alongside the longer wait, not removed, since it did visibly work at least twice during investigation. **Second and third confirmed real live sweeps.** Re-ran immediately: found stage 12's row on the very first scan (badge already showing the correct event by then), opened the modal, raised the count to MAX (11 sweeps, matching 233 AP available), clicked 掃討開始, confirmed the AP dialog, and the sweep executed for real. **Confirmed by screenshot**: AP went 233/240 -> 14/240 (-219 ≈ 11 x 20 AP minus ~1 AP natural regen during the run), credits increased (167,003,618 -> 167,009,544), game returned cleanly to home. **The `_watch_sweep_result` "unrecognized_state" bug recurred even with `POST_SWEEP_DISMISS_ROUNDS` already at 14** — and this time the trace proved it was never a timing/budget issue at all. `_close_stage_modal` returned `True` on its very first check (no retries needed) immediately afterward, meaning the stage modal was *already closed* by the time `_watch_sweep_result` gave up. Root cause: the `"swept"` `ends` condition required the modal to still be *open* (matching `story_sweep.py`'s equivalent screen, which this was ported from and which always does leave its modal open) — but this event's flow can instead auto-return all the way to the underlying Quest list after the final reward screen, a terminal state the original condition never anticipated no matter how large the timeout budget. **Fix**: added a second `ends` condition — modal closed AND no result button AND at least one result button has already been clicked this cycle (a `clicked_any` gate, so an immediate "no button visible yet" read on the very first check, before the SKIP/OK sequence has even started, can't be mistaken for "swept"). Deployed and compile-checked; **not yet live-verified** — the successful sweep above spent the account down to 14/240 AP, not enough for a further live test today. **Status**: the wrong-page-looping bug the user reported twice is now considered definitively fixed, confirmed via two independent real live sweeps in this follow-up alone (three total across the whole Phase 14 investigation). The `_watch_sweep_result` fix is well-reasoned and low-risk (outcome-logging correctness only; the actual sweep behavior was already safe even without it) but awaits live reconfirmation once AP regenerates. #### Phase 14 follow-up #6: stage 08/09 OCR misread (the flagged non-blocking gap, now actually hit) + the real `_watch_sweep_result` root cause **Reported 2026-07-11**: the daily rotation picked stage 9 for the first time since this task existed — exactly the previously-flagged-but-never-hit "stage-list rows for stages 08/09 consistently misread by OCR" gap. Real log: rows 366/538 ("08"/"09") read `None` on the first (wrong-page) attempt, then `'2'` and `None` respectively on the second (correct-page) attempt, while rows 710/883/1055 ("10"/"11"/"12") read correctly — confirmed NOT a navigation/timing bug this time. **Root cause** (isolated with `scratchpad/probe_event_stage_ocr.py` and `probe_ocr_psm_sweep.py`, run directly against a live screenshot with no game interaction needed after capture): the crop and its thresholded/upscaled version both looked completely clean by eye, but tesseract's segmentation consistently misreads "08" as "2" and drops/mangles "09" across every psm mode tried. `scratchpad/probe_ocr_fix_08_09.py` isolated why: the crop is edge-to-edge with no whitespace margin around the glyph pair, and adding a plain white border around the upscaled image before OCR fixed both digits to their exact correct values at every psm mode, without affecting the already-working "10". **Fix**: new `detector.read_int_bordered` (same pipeline as `read_int`, plus `cv2.copyMakeBorder`), used for all `EVENT_STAGE_ROW_Y` reads. Kept as its own function rather than changed in the shared `read_int`/`_ocr_crop`, since no other current OCR read in this project has this specific tight-crop shape. **Confirmed live**: ran `event_sweep` for real. `row @ 366: read '8'`, `row @ 538: read '9'` — stage 9 found, modal opened, sweep confirmed and executed. AP 233/240 → 14/240, credits +5,892, screenshot-confirmed safe return home. The originally reported bug is fixed. **Bonus finding**: the same run again logged `unrecognized_state` instead of `swept`, despite the sweep genuinely succeeding — proving follow-up #5's `clicked_any`-gate fix, while a real improvement, was not the whole story. Diagnosed at zero AP cost via `scratchpad/probe_result_button_fp.py` (checks `_find_result_button` against the plain Quest list with no sweep in progress, no AP needed to reproduce): the shared `SWEEP_RESULT_BUTTON_REGION` (x: 700–1300, borrowed directly from `story_sweep.py`) reaches into this event's own Quest-list character-art panel on the left side of the screen, which false-positive-matches `SWEEP_CONFIRM_CYAN` even with no result dialog showing at all — confirmed on both a wrong/finished event page and, more importantly, the actual correct event's own plain list. This meant `_watch_sweep_result`'s reaction kept "finding" a result button and clicking it after the real one had already been dismissed, so its "modal closed, no result button" end condition could never match. **Fix**: new `config.EVENT_SWEEP_RESULT_BUTTON_REGION` (x narrowed to 1000–1300, excluding the character-art panel while still comfortably covering the real buttons' known x≈1150 position), used only by `event_sweep.py`'s `_find_result_button` — deliberately not changed in the shared region `story_sweep.py` also uses, matching this task's established pattern of giving event_sweep its own constant whenever the two tasks' actual on-screen content differs. **Confirmed live** (zero AP): re-ran the same false-positive probe against the deployed fix on the real correct-event Quest list — `_find_result_button` now correctly returns `None` where it previously returned a false match. Not yet re-confirmed against a fresh full real sweep end-to-end, since that day's AP was down to 14/240 (too low for another MAX sweep) — the fix is validated against the actual live false-positive scenario and the real code path, just not a brand-new full sweep cycle. **Status**: both bugs fixed. The originally reported bug (stage 9 unreadable) is fully confirmed via a real successful live sweep. The `_watch_sweep_result` mislabeling's real root cause is now understood and fixed (previous fix attempt addressed a real but secondary issue); confirmed against the actual live false-positive condition, awaiting one more full live sweep to confirm the outcome log itself reads `"swept"` next time AP allows. #### Phase 14 follow-up #7: `_find_result_button` re-clicking 掃討開始 itself (2026-07-13) **Reported**: a real MAX sweep of stage 12 completed successfully (all 5 stage rows OCR'd correctly, sweep confirmed) but still logged `unrecognized_state`. The user's own diagnosis: "the sweep went well, but I think it overclick and closed the result page." Follow-up #6's x-narrowing (1000-1300) turned out to not be the whole fix. Root cause, identified without needing a fresh live sweep: `EVENT_SWEEP_RESULT_BUTTON_REGION`'s y1=700 still overlapped `EVENT_SWEEP_START_BUTTON`'s (1400,668) own real cyan-pixel footprint (y 622-715, x 1152-1655) — this is the **exact same bug class** just fixed in `bounty.py` (see Phase 15's own writeup below), and since bounty's stage-info modal is confirmed pixel-identical to this one (sharing this exact button position), its already-measured footprint could be reused directly rather than re-deriving it. Once the real "掃討完了" result dialog is dismissed and the flow lands back on the bare stage-info modal, 掃討開始 becomes visible and cyan again — `_find_result_button` re-matched its corner and clicked it, re-opening a fresh AP-usage-confirm dialog the loop had no way to recognize as anything but "still a result button," exactly matching the user's own diagnosis. **Fixed by porting bounty.py's own resolution directly**: (1) `EVENT_SWEEP_RESULT_BUTTON_REGION`'s y1 shifted 700→730 (15px clear of the button's measured 715 edge), kept wide through y2=1050 to still cover a possible SKIP-then-OK sequence's ~120px spread since this event's own SKIP button was never individually measured; (2) `detector.find_color_centroid`'s `min_pixels` parameter applied here too via a new `EVENT_SWEEP_RESULT_BUTTON_MIN_PIXELS` (3000, carried over from `BOUNTY_RESULT_BUTTON_MIN_PIXELS` by analogy — same shared UI component, though this region is somewhat larger so it may need its own tuning); (3) `_watch_sweep_result` gained a third `ends` condition on `_is_ap_purchase_prompt` (mirroring bounty's `_is_ticket_purchase_prompt` fix) as an independent safety layer. **Confirmed live at zero AP cost**: opened the real stage-12 modal (view only, no count raised, no sweep confirmed) and directly compared `_find_result_button` against the OLD region (falsely matched `(1226, 707)`, sitting right on 掃討開始's own corner) vs. the NEW region+min_pixels (correctly returned `None` on the same resting screen). **Confirmed live with a real full sweep** (the user's next run, same day): a real MAX sweep of stage 12 completed and logged `result: swept` for the first time ever — not `unrecognized_state`. The fix is fully validated. #### Phase 14 follow-up #8: badge-carousel dots don't work — click immediately after reaching home instead of waiting (2026-07-13) **Reported** in the same real run that confirmed follow-up #7: the wrong-page retry loop hit `wrong_page` on attempts 1-3 (all 5 rows `None`, finished-event text confirmed each time) before finally succeeding on attempt 4. The user's own diagnosis, with the fix already specified: "When landed on wrong event page, it will click on the button(?) under the event. The button does nothing. Fastest way is to click event right away when get to home after returning, since ongoing event will always be on top by default, then scrolled away automatically after few ms." This overturns follow-up #5's own theory. That fix assumed the carousel's auto-rotate timer was SLOW, so waiting longer (`WRONG_PAGE_RETRIES`/`_WAIT` widened 3×3s → 6×12s) would eventually land on the correct event — and it also assumed the carousel's pagination dots (added in follow-up #4) reliably forced a specific page. Per the user's direct correction, neither is right: the dots don't reliably do anything, and the real mechanism is the *opposite* of "wait for the slow timer" — the current/ongoing event is the carousel's DEFAULT item the moment the home screen is reached, and it rotates away again quickly. The fix is to click the badge as fast as possible after confirming home, not to wait. **Fix**: `_select_badge_page`/dot-clicking removed entirely (`_open_event_screen` no longer takes a `dot_index`); `run()`'s retry loop no longer waits `WRONG_PAGE_RETRY_WAIT` (12s, now deleted) between `navigation.return_to_home` and the next `_open_event_screen` call — it retries immediately. `config.EVENT_BADGE_DOT_X`/`_Y` removed as dead code. **Confirmed live at zero AP cost**: 3 separate trials of `return_to_home` → `_open_event_screen` (no dot, no wait) → checking `_is_finished_event_page`/reading the stage rows, run immediately after deploying. All 3 landed on the correct current event on the very first attempt — no `wrong_page` outcome at all across any trial (previously, the same account's badge was landing wrong 3 times out of 4 attempts with the old design). The full sweep-and-result path through this navigation wasn't re-exercised in the same check (that needs spending real AP) — deferred to the user's next real run. ### Phase 15: Bounty Reference: `module/rewarded_task.py`. Local: `ba_auto/tasks/bounty.py`. Per explicit user direction (2026-07-11): "There are 3 areas in the Bounty (指名手配), choose random (mod%3), and run the latest stage available (currently J)." Simplified from the reference's own per-call loop across all 3 areas (config-string sweep counts via `get_task_count`/`rewarded_task_times`, which this project has no equivalent config for) plus its `get_los`/`one_detect` per-row SSS-color scan, the same way event_sweep.py already simplified `sweep_activity.py` — one area per run via date-ordinal-modulo rotation, always the area's bottom-most (= latest) stage. **Live-calibrated 2026-07-11, zero real tickets spent during calibration** (every confirm dialog cancelled via Escape, ticket count 6/6 confirmed unchanged before/after, across all 3 areas): entry is a Work-hub `指名手配` card (not a bottom-nav "bus" icon like the reference), landing directly on Location Select; all 3 areas (ハイウェイ/砂漠の線路/校舎 = OVERPASS/DESSERT RAILWAY/CLASSROOM) confirmed identical in layout — exactly 10 stages each (01-10, lettered A-J), all already SSS-cleared, row 10 the true list end (scrolling further is a no-op). This makes "scroll to the bottom extreme, click the last row" equivalent to "the latest stage available" by construction, unlike story_sweep/event_sweep which need an OCR search over a longer-than-visible list. The 任務情報 modal's MAX stepper and the ticket-usage confirm dialog are pixel-identical to event_sweep's own stage modal/shared `SWEEP_CONFIRM_*`, reused directly; the modal DOES close on Escape (like event_sweep's). The modal-open probe could NOT be reused from `EVENT_STAGE_MODAL_PROBE`, though — that corner point reads dark on this screen regardless of modal state (different background art); a fresh `BOUNTY_STAGE_MODAL_PROBE` was calibrated that discriminates correctly. **Live-tested for real, with a real bug found and fixed along the way.** The user approved a controlled 1-ticket live test to confirm the result-screen handling (deliberately not exercised during zero-spend calibration). The test spent **5 tickets instead of 1** (6/6 → 1/6): `_set_sweep_count`'s `count == 1` path assumed the modal always defaults to count=1 on open, and short-circuited without clicking anything — but the modal actually remembers the last-used count (carried over from an earlier calibration session's MAX click on a *different* stage/area), so the "no click needed" assumption silently confirmed whatever count was actually showing. A real sweep executed regardless (credits +180,000, confirmed screenshot, safe automatic return home), so nothing was lost, but the spend didn't match what was communicated to the user. **Fixed**: `_set_sweep_count` now always forces a known baseline via a new `_click_min_and_verify` (clicks `BOUNTY_SWEEP_MIN_BUTTON`, symmetric to the existing MAX click) before applying any `+` raises, for any count including 1 — never trusts an assumed default again. With that fixed, the user approved spending the account's one remaining ticket on a fully-diagnostic run (polling and screenshotting every step of the result window instead of trusting the production code blindly). This surfaced a second, more serious near-miss: after the ticket count hit exactly 0, `_find_result_button`'s color search (still using a first-guess region copied from `EVENT_SWEEP_RESULT_BUTTON_REGION`) re-matched part of `BOUNTY_SWEEP_START_BUTTON`'s own real cyan pixels once the result dialog closed and the plain stage-info modal reappeared — clicking it re-triggered a **real Pyroxene (gem) ticket-purchase prompt**, the same shared gold-button dialog component as the AP tasks' insufficient-AP variant. The diagnostic loop's blind repeated clicks near this dialog never actually landed on its real "OK" button (confirmed: gem balance 10,494 unchanged before/after), but this was closer to an unintended real-currency spend than any previous task in this project has come. **Fixed three ways**: (1) `BOUNTY_SWEEP_RESULT_BUTTON_REGION` corrected from the first-guess copy to the real, pixel-scanned OK-button bbox (confirmed via offline analysis of the saved diagnostic screenshots — x 783–1139, y 879–991 — with zero further game interaction needed), now clear of `BOUNTY_SWEEP_START_BUTTON`'s own y-range (622–715); (2) a second, smaller contamination source found in the same offline analysis — the modal's own reward-icon artwork has a handful of pixels that incidentally fall inside the cyan color range even with no dialog showing (~878 stray pixels vs. the real button's ~34,000) — fixed via a new `min_pixels` parameter on `detector.find_color_centroid` (default 1, preserving existing callers' behavior unchanged) and `config.BOUNTY_RESULT_BUTTON_MIN_PIXELS = 3000`, comfortably separating real matches from noise; (3) `_watch_sweep_result` now has an explicit `_is_ticket_purchase_prompt` check as one of its `ends` conditions, so if this state is ever reached again for any reason, it's recognized and cancelled outright rather than left to a blind color search near a real-currency dialog. All three fixes were verified offline against the actual saved screenshots from the diagnostic run (the plain-modal and purchase-prompt states now correctly return no match; the real result dialog still matches correctly) before redeploying — no further ticket was available to re-test live same-day. **Confirmed real**: 2 live sweeps total, credits gained both times (+180,000 then +36,000), clean automatic return home both times, zero manual intervention, zero real Pyroxene spent despite the near-miss. **Not yet re-confirmed live**: a fresh sweep with today's fix deployed (account is at 0/6 tickets as of session end) and any bulk/MAX-count sweep's result-screen flow specifically (only count=1 was ever tested, since ticket scarcity forced it — a bulk sweep may show a SKIP-then-OK sequence like story_sweep/event_sweep rather than the single-OK dialog confirmed here). ### Phase 16: Gem shop daily free package (2026-07-15) Reference: `module/collect_daily_free_power.py`. Local: `ba_auto/tasks/gem_shop.py`. Requested directly by the user with reference screenshots (`screenshots/gem_shop/1-4.png`): claim the 毎日無料パッケージ (daily free package, +10 AP / +10,000 credits, 0 yen, once per day) inside the 青輝石購入 (gem purchase) dialog. This is the exact piece Phase 8 deliberately deferred (see its follow-up above) — that phase stopped at "this menu has real-money purchase buttons visible immediately," but the specific free card is a fixed, non-selectable, no-decision claim, same category as Phase 8's own Mission-panel claim. Reference flow (`implement`): `to_main_page` → `to_purchase_pyroxenes_menu` (open the dialog via the home-screen icon) → `to_purchase_type("package")` → `detect_free_power_availability` → if purchasable, `collect_daily_free_power` (click the free card, confirm the 0-yen purchase, wait for the reward) else log already-collected → `return_to_main_page` (close the dialog). Every step is detected via `core.picture.co_detect` + fixed-region OpenCV template matching against per-locale PNG assets (`main_page_purchase-pyroxenes-{menu,package-selected-*,daily-free-purchasable,daily-free-non-purchasable,confirm-purchase-notice}.png`) — no OCR anywhere in this reference flow. Ported as plain color probes instead of new template assets, matching this project's own established equivalent for a simple, high-contrast state-A/state-B visual difference (`cafe.py`'s `CLAIM_DISABLED_RGB`, `stamina.py`'s `MISSION_CLAIM_PROBE`) rather than building a template-match asset pipeline for what reduces to flat colors. **Live-calibrated 2026-07-15** by manually driving the real dialog end-to-end on nik-gpu via raw `xdotool`/`scrot` (not estimated from the user's own non-native-resolution reference screenshots — confirmed those aren't 1:1 with real game coordinates, same finding as `screenshots/cafe/student/`'s calibration history) — this walkthrough **genuinely claimed the account's real free package for the day** (AP 64→74, credits 161,391,144→161,401,144, both confirmed via before/after counter reads), which calibrated both the "available" and "already claimed" states from real data in one pass: - The free card's own status bar reads flat dark navy `~(41,65,90)` when available ("一日に1回まで購入可能") vs flat dark red `~(144,38,47)` once claimed ("一日に0回まで購入可能") — a clean, high-contrast signal (`r` vs `b` alone separates them by ~50-100), no OCR of the count digit needed. - `navigation.is_on_subscreen`/`is_modal_open`'s default probes both proved unreliable on this dialog specifically: it overlays the home screen directly (keeping the home header visible) rather than being a full subscreen, so `is_on_subscreen`'s header probe stays dark whether the dialog is open or not; `is_modal_open`'s `(960,200)` probe lands on the dialog's own opaque white card interior rather than a dimmed backdrop, so it reads bright regardless of dialog state too — the same class of default-probe mismatch `story_sweep.py`/`bounty.py` already document for their own wide modals. Fixed with a dedicated 4-point "is this dialog (or a nested confirm/notice card) showing" check (`GEM_SHOP_DIALOG_PROBES`), reusing the same multi-point-beats-single-point reasoning `navigation.is_header_bar_visible` was built on the same day for an unrelated cafe bug (see Phase 6 follow-up #5) — independently applied here since this dialog's false-positive risk is a different shape (character-art home screen vs. the dialog's own near-white card gutters), not the header row. - The post-purchase "報酬獲得!" (reward acquired) banner did not accept input on its first 1-2 Enter presses while its own entry animation was still playing — `_claim_free_package` polls the free card's status after each press rather than assuming one press is enough, bounded by `GEM_SHOP_CLAIM_MAX_ATTEMPTS` (6). Added to `ba_daily.py`'s `TASKS` dict and, unlike every task added since Phase 8, also to `DEFAULT_ORDER` — it only ever reclaims a genuinely free, once-per-day resource with no choice to make (unlike story_sweep/shop/lesson/arena/bounty, which all spend something on an automated choice), the same "reclaim something free" category as mailbox/cafe/stamina. **Live-tested for real** via the actual `./ba_dailies.sh gem_shop` CLI path (not just the manual calibration walkthrough): correctly recovered from a flaky first icon click (retried and succeeded on attempt 2, the same "first click sometimes misses" pattern already documented for the mailbox/cafe icons), correctly read the real "already claimed today" state left over from the calibration walkthrough, logged it, and returned cleanly to the confirmed true home screen. **Not yet live-tested**: the "available → claim" code path itself, since the account's package was already claimed for the day by the manual calibration walkthrough before the module existed — it uses the same coordinates/logic already confirmed live via that manual walkthrough, but hasn't been exercised by the actual module end-to-end. Worth a follow-up check the next time the package resets and hasn't been claimed yet. **A `reference-parity-reviewer` pass the same day, before this was reported done, caught a real bug and a real future-breakage risk:** 1. **Dead close-verification (fixed).** The first version's final dialog-close step verified success via `navigation.is_on_subscreen` — but that probe is documented (in the same session's config.py writeup) to read identically whether this specific dialog is open or closed, since the dialog overlays the home screen directly rather than being a full subscreen. The check could therefore never fail, silently masking a stuck-open dialog that neither this module nor `navigation.return_to_home`'s shared fallback (same blind-probe class) could then detect — the exact "unverified action assumed to work" shape the cafe.py bugs earlier in this same session were also caught in. Fixed by adding `_close_gem_shop`, which verifies with the module's own correctly-calibrated `_dialog_open` check instead, bounded-retry, reused for both the normal end-of-run close and the "unknown status" abort path (which had the same unverified-Escape bug separately). **Re-confirmed live** after the fix: same real "already claimed" run via the actual CLI, no warnings, confirmed via screenshot back at the true home screen. 2. **`GEM_SHOP_PACKAGE_TAB` is a single coordinate calibrated only against today's temporary 3-tab layout (documented, not yet fixable).** The reference's own `to_purchase_type` branches on whether the 期間限定 (time-limited) tab is present, because removing it reflows the remaining tabs — and `screenshots/gem_shop/2.png` (captured live the same session) shows that tab's own countdown ("終了まであと6日", 26.06.24〜26.07.29), meaning this account's 3-tab layout is itself due to change around 2026-07-21. No real screenshot of the resulting 2-tab layout exists yet, so a second coordinate was deliberately NOT guessed in rather than grounded in a real capture — flagged with a dated comment in `config.py` instead. Contained, not a live hazard: if this goes stale, `_open_package_tab`'s existing status-probe check reads "unknown" and aborts cleanly via the now-fixed verified close, without ever clicking a purchase button on whatever tab it actually landed on. Needs a real re-calibration pass once the tab count actually changes. ### Phase 17: Circle (Group/Club) daily check-in (2026-07-15) Reference: `module/group.py`. Local: `ba_auto/tasks/circle.py`. Requested directly by the user: "enter circle (guilds)... grant you 10 AP (stamina) once, reset daily. Then you can claim it from mailbox. This implement only do circle enter and return to home screen. No need for mailbox claim." Reached via home → bottom-nav ソーシャル (Social) icon → サークル (Circle) card, per the user's own description. Reference flow (`implement`): `to_main_page` → `to_group`, which reacts to the `main_page` rgb state by clicking a fixed screen position, polling (`picture.co_detect`) until one of three image-template terminal states: `group_sign-up-reward` (first entry today, +10 AP granted), `group_menu` (already checked in, plain menu), or `group_join-club` (account isn't in a circle — reference just warns and stops). Every step detected via fixed-region template matching, no OCR. **Live-calibrated 2026-07-15** by manually driving the real flow end-to-end on nik-gpu — this **genuinely claimed the account's real circle check-in reward for the day** (the real "今日のサークルへの参加報酬" modal, AP x10, "報酬はメールボックスから受け取ることができます" — matching the reference's own sign-up-reward outcome and the user's own description exactly), then confirmed the "already checked in" state for real by re-entering immediately afterward (same click sequence lands straight on the chat/member screen, no modal). Both real states pixel-sampled and found to satisfy the existing shared `navigation.is_modal_open`/`is_on_subscreen` check with no new probes needed — first genuine case this session where the shared primitives worked as-is rather than needing a dedicated dialog probe (contrast gem_shop.py's `GEM_SHOP_DIALOG_PROBES`). One real navigational subtlety found and deliberately *not* over-engineered: the intermediate ソーシャル hub page (between clicking Social and clicking Circle) has no reliable single-point brightness signal of its own — pixel-sampled live, it reads dark at `SUBSCREEN_HEADER_PROBE` same as the true home screen, because it keeps the home screen's own background art dimly visible behind its card grid rather than using a proper opaque header bar like every other subscreen in this project. Rather than inventing an uncalibrated probe for that transient state, `_enter_circle` treats "click Social, click Circle" as one combined attempt and retries the *whole pair* (bounded, `ENTER_RETRIES=3`) if the final destination isn't confirmed — justified as safe because both click targets are harmless no-ops if partially already there on a retry. Also hit, live, mid-calibration: the recurring `XIGNCODE` anti-cheat overlay stole a `BACK_BUTTON`-coordinate click (same gotcha documented in the "Return-to-home audit follow-up #2" below) — recovered via the standard `windowactivate`+`windowraise` escalation, no code change needed since `driver.focus_game()`/`navigation.return_to_home` already handle this class of incident project-wide. Per explicit user direction mid-session ("Instead of click back you can use Esc button"), the task returns home via a direct `driver.keypress("Escape")` rather than clicking `navigation.BACK_BUTTON` — confirmed live that a single Escape from the サークル screen returns straight to the true home screen in one step (skipping back through the intermediate ソーシャル hub page entirely), matching `mailbox.py`'s own established end-of-task convention rather than inventing a new one. The reference's `group_join-club` ("not in a circle") outcome is deliberately not ported — this account is already a circle member (confirmed live), and per explicit user scope this task only performs the entry, nothing else. Added to `ba_daily.py`'s `TASKS` dict and `DEFAULT_ORDER` (alongside mailbox/cafe/stamina/gem_shop) — same "reclaims something free, no decision to make" category. **Live-tested for real** via the actual `./ba_dailies.sh circle` CLI path: correctly recovered from a flaky first click (same familiar "first click sometimes misses" pattern), correctly read the real "already checked in today" state left over from the manual calibration walkthrough, and returned cleanly to the confirmed true home screen. **Not yet live-tested**: the "first entry → claim reward" code path itself, since the account was already checked in for today by the manual calibration walkthrough before the module existed — same disclosed-gap shape as gem_shop's own "available → claim" path (Phase 16). Worth a follow-up check the next time the check-in resets and hasn't been claimed yet. **A `reference-parity-reviewer` pass the same day, before this was reported done, caught three real gaps, all fixed:** 1. **`_reached_circle`'s generic terminal check could false-positive from a stuck non-home starting state (fixed).** The first version relied solely on `ba_daily.py`'s centralized `_ensure_home()`, which CLAUDE.md documents as explicitly best-effort, not a hard gate — if a run began while some other subscreen/modal was already open (a leftover from a prior task), `_reached_circle` would read true on attempt 1 before either click did anything, and the code would silently report "already checked in today" without ever attempting the real check-in, no warning logged. This also undermined the module's own stated justification for skipping the reference's `group_join-club` ("not in a circle") case — a join-prompt rendered as an ordinary subscreen or modal would trigger the exact same false positive. Fixed by calling `navigation.return_to_home(driver)` at the top of `run()`, porting the same fix `arena.py`/`bounty.py` already made for themselves for this identical class of bug. 2. **The reward-dismiss Enter press and the closing Escape were never verified to have worked (fixed).** Same "unverified keypress assumed to work" shape as the cafe.py/gem_shop.py bugs fixed earlier the same session — a dropped Enter press would leave the reward modal open, then the very next line's unconditional Escape would risk cancelling it instead of claiming it, silently declining the day's free AP with no warning and no way to retry until the next daily reset (amplified by `circle` running unattended in `DEFAULT_ORDER` every day). Fixed with `_dismiss_reward`/`_leave_circle`, both bounded retry-until-verified loops directly porting `gem_shop.py`'s `_claim_free_package`/`_close_gem_shop` pattern. 3. **`_enter_circle`'s retry loop had no `driver.focus_game()` escalation for the `XIGNCODE` overlay (fixed).** That overlay had already struck live during this task's own calibration session (stole a `BACK_BUTTON` click, recovered manually via `windowactivate`+`windowraise`) — every other retry loop bitten by this class of incident in this project (`navigation.return_to_home`, `navigation.click_back`, arena's own navigation) escalates via `focus_game()` partway through its budget; this one now does too, right before the final attempt, matching `click_back`'s own convention. **Re-confirmed live** after all three fixes: same real `./ba_dailies.sh circle` run via the actual CLI, correctly read "already checked in today" with the new `return_to_home()` pre-check in place, no warnings from the new verified dismiss/close loops, confirmed via screenshot back at the true home screen. ### 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. #### Return-to-home audit follow-up #2: the `XIGNCODE` overlay gets an automatic recovery (2026-07-12) **Reported**: a real `bounty` run correctly detected `not_sweepable` (0/6 tickets — expected, already spent that day) and aborted cleanly without spending anything, but then both `bounty.py`'s own `navigation.return_to_home` call and `ba_daily.py`'s wrapper's post-task call failed to confirm reaching home — the user asked directly: "it couldn't press Esc key(?) to go to home." This is a recurrence of a gotcha first found during `event_sweep.py`'s original live debugging (documented only in the now-superseded `Handoff.md`, never in `plan.md`/`CLAUDE.md`): an anti-cheat `XIGNCODE` window can render visibly on top of the game and intercept clicks at a fixed screen position, even though `xdotool getactivewindow` still reports `BlueArchive` as active throughout — `windowactivate` changes input focus, not window stacking order, so it doesn't fix this by itself. That first occurrence was written up as "not yet turned into an automatic recovery... since this was only seen once, incidentally, not reproduced deliberately." It has now recurred, in a completely different task, confirming it's a real recurring environment condition rather than a one-off fluke, so it earned the automatic recovery this time. **Confirmed live via screenshot**: the game was sitting cleanly on the bounty Location Select screen (0/6 tickets, correctly not_sweepable, no stuck game modal at all) — but a small stray window rendered in the top-left corner, directly overlapping `navigation.BACK_BUTTON`'s `(85,55)` coordinate. `xdotool search --name XIGNCODE` confirmed the window existed; `getactivewindow` still reported `BlueArchive`, reproducing the exact misleading symptom from the original incident. Manually running `xdotool windowactivate` + `xdotool windowraise` on the BlueArchive window ID made the overlay disappear and `navigation.return_to_home` immediately succeeded afterward — confirming the diagnosis and the fix in one step, at zero further cost (no ticket, no click, purely a window-stacking operation). **Fix, in two layers**: (1) `driver.focus_game()` — already called at the start of every single task — now calls `xdotool windowraise` right after `windowactivate`, preemptively clearing the overlay before any task's own navigation begins. (2) `navigation.return_to_home` gained a second, later-in-the-budget escalation: if still not home halfway through `RETURN_HOME_MAX_ROUNDS`, it calls `driver.focus_game()` once (re-raising the window) before continuing the remaining rounds — covering the case (exactly what happened here) where the overlay appears or becomes input-stealing *during* a run, after the task's own start-of-run `focus_game()` call already completed. **Confirmed live end-to-end**: re-ran `./ba_dailies.sh bounty` for real (still 0/6 tickets, so free) after deploying the fix, with the overlay already cleared from the manual diagnosis step — no "could not confirm return to home screen" warning on either the internal or wrapper-level call this time, and a direct check confirmed the game genuinely on the home screen afterward. The fix hasn't yet been proven to self-recover *while the overlay is actively present* (the manual clearing during diagnosis happened to also fix it before the automated code path was exercised) — the next time this overlay appears during a live run, watch for `"[navigation] still not home halfway through recovery -- re-raising the game window..."` in the log to confirm the escalation path itself fires and works, not just that `focus_game()`'s own preemptive raise is enough. **Follow-up, same session — "could the same error happen in other scripts?"** The two-layer fix above only covers `navigation.return_to_home`/`driver.focus_game()`. Auditing every direct use of the shared `(85,55)` coordinate found 5 places that clicked it *without* going through either: `shop_common.py`/`shop_tactical.py`'s end-of-run cleanup clicks, and — the real risk — `lesson.py`'s `_ensure_location_select_list` (had its own retry loop, but no overlay escalation) and `_close_region_grid` (called up to 12x per run, zero retry or verification at all; a silently-missed click here leaves the next region's `_open_region_grid` starting from the wrong screen and silently skipping that region). Fixed with a new shared `navigation.click_back(driver, verify_fn, max_attempts=3)` — the same retry + windowraise-escalation pattern as `return_to_home`, but parameterized by a caller-supplied verification check instead of hardcoding "reached home," for callers (like `lesson.py`'s per-region-map → list transition) that only want to go back ONE level. Applied to both `lesson.py` call sites; the three lower-risk end-of-run cleanup clicks (`lesson.py`, `shop_common.py`, `shop_tactical.py`) were switched to call `navigation.return_to_home` directly instead, since "go all the way home" is exactly their intent anyway. `config.SHOP_BACK_BUTTON`/`LESSON_BACK_BUTTON` were then removed as dead code (redundant with `navigation.BACK_BUTTON`, matching `EVENT_BACK_BUTTON`'s earlier removal for the identical reason). **Confirmed live**: `lesson` (0 tickets, so the no-op path — confirmed clean return home, no warnings), `shop_common` (a real purchase, 8 items/1,211,500 credits — confirmed clean return home), and `shop_tactical` (a real purchase, 2 items/45 tactical coin — confirmed clean return home) all ran successfully through the real CLI with the new navigation. `lesson.py`'s two hardened mid-run call sites (`_close_region_grid`/`_ensure_location_select_list`) weren't actually exercised by this test, though, since 0 tickets meant the scan/execute phase never ran — they'll get real coverage the next time `lesson` runs with tickets available. ### Phase 18: Login (title screen -> home) (2026-07-16) Reference: `core/Baas_thread.py`'s `to_main_page` (the generic post-launch arrival routine every other reference feature's own navigation reuses -- there's no separate "login" module in the reference) plus `module/restart.py`'s `implement`/`start` (check the app is running, launch it if not). Local: `ba_auto/tasks/login.py`. Requested directly by the user, with the game genuinely sitting on the real login screen at request time: "go through login page to go to home." Reference flow: `to_main_page` is a `picture.co_detect` call wired with ~20 named `img_reactions`/`rgb_possibles` (download notices, the daily attendance card, a login-feature/login-store banner, news, rank-up cutscenes, etc.) -- whatever recognized state appears gets clicked through, repeatedly, until the `main_page` rgb state is reached. This project has no image-template assets for that whole catalog, so rather than hand-calibrating every possible reference dialog, the port scopes down to what was actually confirmed live plus a bounded generic Enter-press fallback for anything else recognized, mirroring co_detect's own "blind action once nothing matches" fallback shape using this project's own established Enter-dismiss idiom. **Live-calibrated 2026-07-16** on nik-gpu against the account's real overnight login-screen state, native 1920x1200 captures throughout (not the non-native `screenshots/daily_login/*.png` the user originally supplied -- same not-1:1 gap already documented for `screenshots/gem_shop/`/`screenshots/cafe/student/`). Confirmed states, in the order actually hit: 1. Title screen ("TOUCH TO START") -- the ブルーアーカイブ logo (top-left) is fixed brand chrome independent of the rotating seasonal background art, confirmed identical across two completely different background pieces (a beach BBQ scene, a train interior). Click `LOGIN_TOUCH_TO_START`. 2. A real "ネットワークへの接続に失敗しました" (network connection failed) notice, hit unprompted during calibration -- same logo, uniformly dimmed by the notice's own overlay. Enter dismisses it, dropping back to state 1. 3. A loading transition, two visually distinct variants: a brief chrome-visible one, and a full-bleed one (rotating splash art, center spinner, no chrome at all) that **genuinely got stuck for over 6 minutes with zero progress** during the first live attempt -- confirmed not a network/process-health issue (ping and the game process were both healthy throughout). The user's own live guidance mid-session ("Usually I would kill the game and rerun it to fix it", identifying `/usr/local/bin/launch-blue-archive.sh` and the game's working directory) matched the reference's own `restart.py` kill+relaunch pattern exactly. A manual kill+relaunch recovered immediately; a second attempt completed the entire remaining flow in well under 15 seconds, confirming the stall was a genuine stuck state, not normal variance. Ported as `login.py`'s own `_recover`, using new `driver.kill_game`/`launch_game`/`is_game_running`/`window_exists` primitives. 4. アロナの毎日出席簿 (the daily attendance card) -- only appears once per day (confirmed absent on an immediate same-day re-run after the first run's Enter already claimed it). Falls through to the generic Enter fallback like every other unrecognized pre-home state, confirmed live to correctly claim/dismiss it in one press. 5. True home, usually with the S.C.H.A.L.E NEWS popup open on top -- `navigation.is_on_subscreen`/`is_modal_open` both proved unreliable for this dialog specifically (same class of mismatch as `gem_shop.py`'s own dialog: it overlays home directly, and `is_modal_open`'s probe point lands on the dialog's own bright header/body rather than a dimmed backdrop). Fixed with a dedicated `_news_dialog_open` multi-point header-color check; closed via its own X button, not Enter (not confirmed to be Enter-bound, unlike everything else in this flow). **A known pre-existing client bug was observed live but deliberately not worked around**: per the user, a rendering bug (unrelated to this project) where the news popup's own promotional image can get stuck on-screen as a blank white rectangle after the dialog is otherwise closed. Confirmed live twice in the same session -- present once, absent on an immediately-following clean run with identical navigation, so not deterministic. Confirmed harmless to this task specifically: the stuck rectangle sits well clear of every probe point this module and `navigation.py` use, and the true-home checks still read correctly through it. The user's own fix (reload the app) is already this module's existing stuck-recovery path, so no separate handling was added. **A real correctness bug was found and fixed via the actual module's own first live run** (not manual clicking): the original `_true_home` (not-subscreen AND not-modal AND no-news-dialog) is a set of negative conditions never actually calibrated against the pre-login states this module itself introduces -- both the plain title screen AND the unclaimed daily attendance card independently satisfy all three (neither looks like a subscreen or an open modal to checks that were built assuming the game world is always already reached). The first real `login` run false-positived "reached home" while still sitting on the title screen, before clicking anything. Fixed by adding `_home_nav_bar_visible`, a positive multi-point check for the bottom nav bar's own flat, near-white background band -- confirmed live to hold uniquely on true home and fail on every other captured state, including home with the news dialog still open (which dims that same band). Re-run afterward: a real cold start (game process not running at all -- confirmed via `pkill`) correctly launched the game via `driver.launch_game()`, clicked through the title screen, and reached the genuinely-confirmed home screen. **A second real bug was found via the same cold-start test, one layer up**: `ba_daily.py`'s centralized pre-task `_ensure_home()` calls `navigation.return_to_home`, which calls `driver.focus_game()` partway through its escalation budget -- and `focus_game()` hard-raises `RuntimeError` if the game window doesn't exist at all, crashing the whole process *before* `login.run()` (the one task able to launch the game itself) ever got a chance to run, making its own launch-if-missing logic unreachable dead code. Fixed by having `navigation.return_to_home` bail out immediately (`False`) if `driver.window_exists()` is false, rather than pressing/escalating into a guaranteed crash, plus the same guard on `navigation.click_back`'s own escalation. Every other task is unaffected -- they still crash exactly as before once dispatched (correct for them, since none of them can launch the game themselves). Added as the very first step in `ba_daily.py`'s `DEFAULT_ORDER`, ahead of mailbox: every other task's own navigation assumes the home screen is already reachable via `navigation.return_to_home`'s ordinary Escape/BACK_BUTTON press loop, which has no way to get there from the title/loading/attendance-card states this module handles. `_wait_for_home` checks true-home first on every poll, so a session that's already logged in and sitting at home is a fast no-op, not a risky blind click every day. **Not yet live-confirmed**: the infrequent 業務復帰ログインボーナス welcome-back login bonus card from the user's own reference screenshots (account wasn't in that state during calibration) -- expected to fall through to the same generic-Enter path already confirmed for the attendance card and network notice, but not yet exercised for real; and the automatic kill+relaunch recovery path (`_recover`) has only been exercised once, manually, not yet triggered by `login.py`'s own `LOGIN_TIMEOUT_SECONDS` budget hitting a real stuck state on its own. ## Prerequisites ### OCR **Set up (Phase 10): `pytesseract` + the `tesseract-ocr` apt package.** `ba_auto/detector.py`'s `read_text()`/`read_int()` wrap it for occasional single-crop reads (a region number, a stage label) — no need for the reference's own socket/shared-memory PaddleOCR server, which exists there to make OCR fast across thousands of automation steps; this project's usage volume doesn't need that. Needed for: - currency readouts - ticket counts - region/tab name matching - some shop logic - some lesson/schedule logic - arena ticket/rank/level checks - bounty coin balance if auto-refresh is implemented Candidates: - Tesseract - PaddleOCR ### Auto-fight primitive Needed for: - Arena - future main story push - some battle automation Reference: ``` ~/repo/baas-reference/module/main_story.py ``` Look for: ``` auto_fight enter_battle ``` Target local module may be: ``` ba_auto/tasks/battle.py ``` or: ``` ba_auto/battle.py ``` This should become a reusable primitive, not arena-specific code. ## High priority backlog ### 1. Migration to Python-first **Status: Done.** See Phases 1–7 above for the detailed history, including the mailbox and cafe exit-game-dialog bug and its fix. Goal (all done): - Bash launcher only - Python CLI - Python driver - Python detector - mailbox migrated - cafe migrated - reference mapping started ### 2. Stamina/AP sweep **Status: Partially done — see Phase 8.** Claim: - ~~daily free AP purchase~~ — deferred; entry point is a real-money purchase menu, needs explicit confirmation before automating - daily task-menu AP/pyroxene rewards — done, via the Mission panel's bulk claim button Reference: ``` ~/repo/baas-reference/module/collect_daily_free_power.py ~/repo/baas-reference/module/collect_daily_task_power.py ``` Local target: `ba_auto/tasks/stamina.py` OCR: Not needed — turned out to be a single bulk-claim button + Enter, no per-item detection required. ### 3. Club/Group AP claim Claim AP from club/group. Reference: ``` ~/repo/baas-reference/module/group.py ``` Local target: `ba_auto/tasks/group.py` OCR: Not expected. ### 4. Normal/Hard story AP sweep **Status: Done — see Phase 10 (supersedes Phase 9's random-pick design).** Sweep already-cleared main story stages to burn AP. Reference: ``` ~/repo/baas-reference/module/explore_tasks/sweep_task.py ~/repo/baas-reference/module/explore_tasks/task_utils.py ``` Local target: `ba_auto/tasks/story_sweep.py` OCR: Used, for real, as of Phase 10 — region-number readout OCR + delta-click (porting `to_region`), and stage-row label OCR matching (a scoped-down `swipe_search_target_str`), replacing Phase 9's "next region arrow stops advancing, then a random stage" heuristic. See Phase 10's write-up for what was actually found live (a second, taller modal layout for regular numbered stages; an AP-usage-confirmation dialog the design hadn't accounted for; a genuine insufficient-AP dialog told apart from it only by button color). Implemented version: - exact configured `(region, stage, count)` targets (`config.STORY_SWEEP_TARGETS`), not a fixed single stage nor a random pick - `count` is `"max"` or a specific int (the latter calibrated but not yet live-clicked — see Phase 10) - opt-in only, not in the default daily flow ### 5. Bounty Three sub-areas and sweep availability. Reference: ``` ~/repo/baas-reference/module/rewarded_task.py ``` Local target: `ba_auto/tasks/bounty.py` OCR: Optional for coin balance/refresh logic. Can skip refresh for first version. ### 6. Commissions Two sub-dungeons: - Base Defense - Item Retrieval Reference: ``` ~/repo/baas-reference/module/clear_special_task_power.py ``` Local target: `ba_auto/tasks/commission.py` OCR: Port the reference's approach if it uses OCR here — do not default to a fixed-target workaround just to avoid OCR (see "OCR policy" in `CLAUDE.md` and the Phase 9 retrospective above). ### 7. Arena **Status: Done — see Phase 13.** Fights exactly one battle per invocation, not "until out of tickets" (deliberate — see Phase 13). Reference: ``` ~/repo/baas-reference/module/arena.py ``` Local target: `ba_auto/tasks/arena.py` ### 8. Common Shop + Tactical Shop **Status: Done — see Phase 11.** Auto-buy configured items. Reference: ``` ~/repo/baas-reference/module/shop/common_shop.py ~/repo/baas-reference/module/shop/tactical_challenge_shop.py ~/repo/baas-reference/module/shop/shop_utils.py ``` Local targets: ``` ba_auto/tasks/shop_common.py ba_auto/tasks/shop_tactical.py ba_auto/tasks/shop_utils.py ``` Implemented version: - OCR for currency balances (top-bar credits, in-panel tactical coin) — done - shop tab detection — done via a fixed click (tactical tab list fits on screen with no scroll needed on this account, confirmed live); no scroll/pagination logic yet since both current buy lists are fully visible without scrolling - configured buy list — done, `config.COMMON_SHOP_TARGETS`/`config.TACTICAL_SHOP_TARGETS`, fixed `(row, col, name, expected_price)` per item (see Phase 11 for why identification is by grid position + price-OCR-verify rather than per-item OCR — the reference doesn't OCR item names here either) - safe purchase confirmation logic — done, a single overlay-darkness probe covers both the confirm dialog and the reward-acquired banner - no-refresh (the paid manual `更新` refresh button is deliberately not automated, same reasoning as Daily Free Power) ### 9. Lesson / Schedule — Done (Phase 12) Affection farming via classes. Reference: ``` ~/repo/baas-reference/module/lesson.py ``` Local target: `ba_auto/tasks/lesson.py` — implemented, live-tested with real tickets. See Phase 12 above for the full writeup. - region/area identification — done without OCR: this client's region list only settles at two fixed scroll positions, so navigation is deterministic index-based clicking, not the reference's OCR-a-name-then-page approach - multi-page swipe search — not needed for the same reason - student detection/portrait matching for specific students — deferred, per the original suggested scope below - isometric grid location logic — done without porting the reference's geometry: per-cell status/affection reads via `detector.read_int_on_heart_badge` OCR + a checkmark color probe instead Suggested first version (as originally scoped, and what shipped): - pick a fixed region — expanded to all 12, swept in order - select available/highest visible lesson — affection-first, per explicit user direction - avoid favorite-student targeting at first — still deferred ## Low priority backlog ### Scrimmage Reference: ``` ~/repo/baas-reference/module/scrimmage.py ``` Local target: `ba_auto/tasks/scrimmage.py` Similar shape to Bounty/Commissions. ### Crafting Reference: ``` ~/repo/baas-reference/module/create.py ``` Local target: `ba_auto/tasks/crafting.py` Very complex. Contains: - material selection - priority lists - rarity tiers - stepper/quantity UI - filtering/sorting - OCR-like decision points Do not start until the framework and OCR are mature. ### Battle Pass claim Reference: ``` ~/repo/baas-reference/module/collect_pass_reward.py ``` Local target: `ba_auto/tasks/battle_pass.py` Should be simpler than crafting. OCR only needed for optional stats. ### Momo Talk Reference: ``` ~/repo/baas-reference/module/momo_talk.py ``` Local target: `ba_auto/tasks/momo_talk.py` Potentially useful because it runs on a different cadence from daily reset. Likely no OCR. Mostly state scanning and click flow. ### Main story push This means clearing new uncleared stages, not sweeping already-cleared stages. Reference: ``` ~/repo/baas-reference/module/main_story.py ~/repo/baas-reference/module/explore_tasks/explore_task.py ``` Low priority because full grid-mode support requires lots of per-stage scripting. A simple auto-fight-only mode can be added later. ### Group Story / Mini Story Reference: ``` ~/repo/baas-reference/module/group_story.py ~/repo/baas-reference/module/mini_story.py ``` Convenience only. ### Event content **Status: Event AP sweep done — see Phase 14.** The user asked for exactly the generic-sweep case anticipated below (reusing story_sweep's AP-confirm/result-screen logic, not a prebuilt event-specific script), scoped to the currently-running "鉄道爆走事件" event's 9-12 stage range via a rotation target. Reference: ``` ~/repo/baas-reference/module/activities/activity_utils.py ~/repo/baas-reference/module/sweep_activity.py ``` Still not built: anything beyond the sweep panel (`explore_activity_story`/`explore_activity_mission`/`explore_activity_challenge` — walking the event's map/fighting story stages manually, needed only for a stage that hasn't been SSS-cleared yet) and the reward-exchange shop (`exchange_reward`). Event-specific content expires; the 9-12 rotation range and row-position calibration are specific to this 12-stage event, not proven durable across future events with a different stage count or layout. ## Skip list | Feature | Why skip | |---|---| | Total Assault / Raid | Low value and risky to automate. Reference support may be limited/stubbed. | | Joint Firing Drill | Not worth prioritizing for JP if reference has server-specific limitations. | | De-clothes localization toggle | CN-only / irrelevant. | | Restart / refresh-uiautomator2 | Android/ADB backend maintenance, not applicable to PC/Steam/Proton. | | Auto-unfriend | Risky, low value, destructive. | | Daily minigame dispatcher | Event-specific and unstable. Handle ad hoc only. | ## Automation cadence notes Some tasks decay on different schedules. | Feature | Suggested cadence | |---|---| | Cafe income / affection | Every few hours | | Momo Talk | Every few hours | | Arena | Around reset windows / twice daily if implemented | | Mailbox | Daily or with default run | | AP/stamina/task rewards | Daily | | Group AP | Daily | | Bounty/Commissions/Scrimmage | Daily | | Shops | Daily, after reset | | Lesson/Schedule | Daily | Scheduling should be handled outside the feature logic. Feature code should perform one safe run and exit. ## Safety and robustness rules Every task should have: - maximum retry count - timeout where appropriate - safe failure mode - clear stdout logging - no infinite click loops - no unbounded spending - config guard for purchases - dry-run or debug mode when useful For purchases: - default to conservative behavior - avoid refresh loops until OCR/currency detection is reliable - require explicit configured item list - avoid buying unknown items For battle features: - require clear stop conditions - avoid continuing blindly after unexpected state - prefer returning failure over clicking randomly ## Configuration direction Future config may live in `ba_auto/config.py` or `config.yaml`. Possible config values: ``` server = JP game_window_name = BlueArchive display = :0 asset_dir = ~/ba_assets screenshot_dir = scratchpad/ cafe_max_clicks_per_room story_sweep_target shop_buy_list arena_stop_condition ocr_enabled debug_enabled ``` Keep config explicit. Do not bury user-specific settings deep inside task logic. ## Debugging conventions Use `scratchpad/` for: - temporary screenshots - cropped templates - annotated match images - OCR debug output - one-off notes Do not use `/tmp` or `/private/tmp` unless unavoidable. When detector behavior changes, save debug outputs with clear names, for example: ``` scratchpad/cafe_match_2026-07-05_001.png scratchpad/shop_ocr_debug_001.png ``` ## Local validation commands On `nik-macbookair`: ``` bash -n ba_dailies.sh python3 -m py_compile ba_daily.py python3 -m py_compile ba_auto/*.py python3 -m py_compile ba_auto/tasks/*.py ``` On `nik-gpu`: ``` ~/ba_dailies.sh mailbox ~/ba_dailies.sh cafe ``` After migration: ``` ~/ba_dailies.sh ``` should run the default daily sequence. ## Near-term recommended task order 1. Rewrite `ba_dailies.sh` as a thin launcher. — Done 2. Add `ba_daily.py`. — Done 3. Add `ba_auto/driver.py`. — Done 4. Add `ba_auto/detector.py`. — Done 5. Add `ba_auto/navigation.py`. — Done 6. Move mailbox logic to `ba_auto/tasks/mailbox.py`. — Done 7. Move cafe logic to `ba_auto/tasks/cafe.py`. — Done 8. Update `setup.sh`. — Done 9. Add `ba_auto/reference_notes/mapping.md`. — Done 10. Verify existing mailbox and cafe still work. — Done 11. Implement stamina/AP. — Done (Phase 8) 12. Implement Normal/Hard story AP sweep. — Done (Phase 9 built a random-pick heuristic; Phase 10 replaced it with the reference's actual OCR-based deterministic stage targeting, per Phase 9's own retrospective) 13. Implement group/club AP. — Not started 14. Set up OCR and port it for whichever remaining feature's reference implementation depends on it — not a blanket "only when needed" deferral; see `CLAUDE.md` → "OCR policy" 15. Implement Common Shop + Tactical Shop. — Done (Phase 11) 16. Implement Lesson/Schedule. — Done (Phase 12) 17. Implement Arena. — Done (Phase 13) ## Claude Code guidance summary When Claude Code works on this repo, it should follow this rule: > Reference first. > Python first. > Driver primitives before feature hacks. > Bash launcher only. > Port OCR when the reference uses it — don't invent non-OCR substitutes to avoid the setup cost. Do not turn this project into a Bash recreation of Blue Archive Auto Script.