186 KiB
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:
- Read the matching
~/repo/baas-reference/module/...file. - Summarize the reference flow.
- Identify reusable logic:
- state checks
- retry loops
- navigation sequence
- battle/sweep/shop rules
- detection method
- failure handling
- Identify backend-specific calls that cannot be reused directly.
- Implement missing generic primitives in
ba_auto/driver.pyorba_auto/detector.py. - Implement the feature in
ba_auto/tasks/<feature>.py. - Add CLI command dispatch in
ba_daily.py. - Keep
ba_dailies.shunchanged unless launcher behavior changes. - Test syntax locally.
- Deploy to
nik-gpu. - Run against the live game.
- 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:
#!/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 (mirrorscollect()'srgb_in_rangegate 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_outstep (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_roomnow polls for the fullCAFE_MAX_CLICKS_PER_ROOMbudget 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_mouseadded and called after each pat to park the cursor away from the sparkle area, perscreenshots/cafe/sparkle/02_*_cursor_on_head.pngshowing 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 6 follow-up #6: rank-up cutscene stuck the game for real, via cron (2026-07-16)
Follow-up #5's fix got its "next time either occurs naturally" moment: the account's real q4h cron run (17:00 slot) hit a genuine rank-up mid-pat in room 2, and the user reported it live with a screenshot — the game left sitting on the rank-up cutscene (a light-blue triangular background, "絆ランクアップ!" banner, stat-increase card) well after the whole script had already exited.
The log showed the pat itself printed successfully ([cafe] patted sparkle at (309, 634), score=0.995), meaning _dismiss_rank_up_if_shown's post-pat check had passed -- but the very next line, [cafe] panned to leftmost extreme, was confirmed by the user to have produced no visible movement, and the room's farming silently found nothing for the rest of the run. Root cause, diagnosed by reproducing the exact stuck state and pixel-checking it live: this is a timing gap, not a threshold problem. _dismiss_rank_up_if_shown only ever ran once, immediately after a pat, with no wait beforehand -- but the actual cutscene renders with its own client-side animation delay, so that single check could catch the tail end of the still-normal room view (header genuinely still visible in that instant) and conclude "clear" a beat before the real cutscene appeared. Once that happened, nothing checked again: find_cafe_sparkle() can't distinguish "stuck on an undetected cutscene" from the ordinary "nothing to pat right now" case, so _pat_current_view just polled dead air for the rest of its budget, and the following pan drag visibly did nothing (confirmed by the user) since the whole screen was a static cutscene image, not the room.
This also explained why the game was left genuinely stuck rather than self-healing via navigation.return_to_home's generic end-of-task cleanup: a live pixel-check of the captured stuck screenshot found SUBSCREEN_HEADER_PROBE reading (180,227,244) -- r=180, just under the 200 threshold both is_on_subscreen and (by extension) return_to_home's _not_home check need to detect "not home." This specific cutscene's background happens to read as "not a subscreen, no modal open" -- i.e. it falsely satisfies the exact same conditions as the true home screen -- so every "are we home" check downstream of the pat loop (the room-2 income claim, run()'s own final cleanup, and return_to_home itself) all falsely agreed nothing was wrong, and the whole q4h preset finished logging OK with the game actually stuck.
Fixed by moving the _dismiss_rank_up_if_shown check to the top of _pat_current_view's loop, run on every iteration rather than only right after a pat -- a delayed cutscene appearance now gets caught (and dismissed via the function's own existing Enter-press retry loop) on the very next iteration, roughly a second later, instead of never. Confirmed live: reproduced the exact real stuck state left by the incident (the game was still sitting on it) and confirmed a single Enter -- the same action _dismiss_rank_up_if_shown already takes -- cleared it back to the real cafe room view, then navigation.return_to_home confirmed true home afterward. The per-iteration check itself has not yet been re-exercised against a fresh live rank-up trigger end-to-end (same "could not force one on demand" caveat follow-up #5 already carried).
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 combinedxdotool mousemove X Y click 1invocation 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 separatexdotoolcalls 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 todriver.click()itself (project-wide, since all tasks share it) rather than special-cased instamina.py; regression-tested live againstmailboxandcafeafter 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 8 follow-up #2: daily gem reward wasn't being claimed (2026-07-16)
Reported live by the user, with a saved reference screenshot (screenshots/stamina/daily gem available.png): stamina "should collect all reward in the mission, but it didn't collect the daily gem reward."
Re-reading module/collect_daily_task_power.py::implement (already the reference for this task, but not fully ported the first time) confirmed the gap: it checks TWO separate button regions, not one. The main 一括受取 area is drained in a loop first (region A, the only one originally ported into stamina.py); then, independently, a second, separate button (region B, the reference's own "claim daily pyroxenes" check, positioned to the left of region A in both the reference's raw coordinates and this client's own layout) is checked and clicked -- 一括受取 does not also claim it. Live on nik-gpu, this second button corresponds to the "デイリーミッションを8回クリア" (clear 8 daily missions) summary bar's own 受取 button, a gem x20 reward, which was confirmed still showing claimable after 一括受取 had already gone flat grey.
Ported region B as MISSION_DAILY_GEM_CLAIM_BUTTON (config.py), pixel-scanned live at native 1920x1200 (button spans roughly x=1385-1535, y=1080-1165; click point (1400, 1095) confirmed clean and off the "受取" text glyphs, which read near-black). Reuses the exact same bright-yellow-vs-grey color signature already established for MISSION_CLAIM_PROBE (CLAIM_ENABLED_MIN_R/CLAIM_ENABLED_MIN_RB_GAP, refactored into a shared _button_claimable helper rather than duplicated). Unlike 一括受取, this button shows no Enter keybind on screen, so it's claimed via an actual driver.click (matching the reference's own raw self.click(976, 670, ...) for this exact region) followed by an Enter press to dismiss the reward-reveal card, wrapped in a click-then-verify bounded retry (_claim_daily_gem, DAILY_GEM_CLAIM_MAX_ATTEMPTS = 3) matching this project's established discipline rather than trusting an unverified click.
Confirmed live via the real ./ba_dailies.sh stamina CLI path against a genuinely claimable reward (the account's real daily-mission-count tracker was at 8/8, gem reward pending): gem balance went from 12,384 → 12,404 -- exactly the reward's own stated +20 -- with the existing main claim-all also firing normally in the same run, and a clean, warning-free return to the true home screen afterward.
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-specificconfig.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 probingconfig.SWEEP_MINUS_BUTTON_PROBEafter 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_resultnow checks_is_stage_modal_openbefore 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'sread_text()/read_int()crop a screenshot to a pixel rect, threshold it to pure black/white (this measurably fixed real digit misreads that survived everypsmmode when left anti-aliased — see below), upscale 3x, and runpytesseract.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'sunfinished_normal_tasksshape. 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, portingtask_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 adetector.region_contains_colorbox 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-downswipe_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_regionalready confirmed it independently. - Scoped
co_detectport:navigation.wait_for_state(driver, config, reactions, ends, max_iterations)— checks namedendsfirst each iteration (stop, return the name), then namedreactions(run an action, keep polling), else waits and retries up to a bound. Generic and reusable beyond this task. - Named outcomes:
_sweep_targetreturns"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_BUTTONall 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_modalcorrectly 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_confirmtell 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_TACTICALis a fixed click rather than a port of the reference'sgoto_shop_by_nameOCR 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 (seeCLAUDE.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_purchasepresses 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_commonCLI 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_utilshas no scroll/pagination logic yet — would need portingbuy()'slast_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'slesson_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 dedicateddetector.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_PLAUSIBLEdiscards 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_schedulepresses 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:
- Any cell (location card) with all 3 student slots schedulable, in any region — one ticket raises 3 students at once.
- Once no such cell remains anywhere, any cell with 2 schedulable slots.
- 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'sto_tactical_challengeassumes — 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.pyhas its own_is_modal_openusingconfig.ARENA_MODAL_PROBEwith 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 ownself_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):
-
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
Noneeven 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. -
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 plainTHRESH_BINARY) returnedNoneagainst the profile card's white "Lv.90" text on its dark navy background. Addeddetector.read_int_white_on_dark(the same crop pipeline withTHRESH_BINARY_INV) rather than reworking the shared path, since every other OCR read in this project genuinely is dark-on-light. -
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 onarena_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.mdalready documents from story_sweep ("a mistimed keypress could have started a real battle"). Fixed by abandoning per-button color detection entirely:_wait_for_resultnow presses Enter in a bounded blind-retry loop — the same patternlesson.py's_run_one_schedulealready 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. - 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
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_GOLDare pixel-identical live), and the sameSWEEP_RESULT_BUTTON_REGIONSKIP/OK result screen — expected, since the reference's ownactivity_utils.py::start_sweepis a near-duplicate ofsweep_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.pytries 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 simplerr>200 and g<180rule.
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:
- 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 (viais_on_subscreenreadingFalse). 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 hazardCLAUDE.mdalready documents (a stray Escape on the home screen itself raises Blue Archive's own "exit the game?" dialog). Generic across tasks: it only depends onis_on_subscreenand the shared back-arrow position (navigation.BACK_BUTTON, confirmed identical acrossconfig.py'sLESSON_BACK_BUTTON/SHOP_BACK_BUTTON/the now-removedEVENT_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"). - Wrong-page detection + retry in
event_sweep.py:_find_stage_rownow 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 toWRONG_PAGE_RETRIES(3) times on awrong_pageoutcome, callingnavigation.return_to_homeand 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 callsnavigation.return_to_homeat the very end as a final safety net, replacing the old single unconditionalEVENT_BACK_BUTTONclick.
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:
_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_rowitself (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.- Once past that,
_click_max_and_verifycorrectly 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 (rvsbalone 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, sois_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 mismatchstory_sweep.py/bounty.pyalready 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 reasoningnavigation.is_header_bar_visiblewas 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_packagepolls the free card's status after each press rather than assuming one press is enough, bounded byGEM_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:
- 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 nornavigation.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_opencheck 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. GEM_SHOP_PACKAGE_TABis a single coordinate calibrated only against today's temporary 3-tab layout (documented, not yet fixable). The reference's ownto_purchase_typebranches on whether the 期間限定 (time-limited) tab is present, because removing it reflows the remaining tabs — andscreenshots/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 inconfig.pyinstead. 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:
_reached_circle's generic terminal check could false-positive from a stuck non-home starting state (fixed). The first version relied solely onba_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_circlewould 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'sgroup_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 callingnavigation.return_to_home(driver)at the top ofrun(), porting the same fixarena.py/bounty.pyalready made for themselves for this identical class of bug.- 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
circlerunning unattended inDEFAULT_ORDERevery day). Fixed with_dismiss_reward/_leave_circle, both bounded retry-until-verified loops directly portinggem_shop.py's_claim_free_package/_close_gem_shoppattern. _enter_circle's retry loop had nodriver.focus_game()escalation for theXIGNCODEoverlay (fixed). That overlay had already struck live during this task's own calibration session (stole aBACK_BUTTONclick, recovered manually viawindowactivate+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 viafocus_game()partway through its budget; this one now does too, right before the final attempt, matchingclick_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 returns 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:
- 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. - 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.
- 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.shand the game's working directory) matched the reference's ownrestart.pykill+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 aslogin.py's own_recover, using newdriver.kill_game/launch_game/is_game_running/window_existsprimitives. - アロナの毎日出席簿 (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.
- True home, usually with the S.C.H.A.L.E NEWS popup open on top --
navigation.is_on_subscreen/is_modal_openboth proved unreliable for this dialog specifically (same class of mismatch asgem_shop.py's own dialog: it overlays home directly, andis_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_openmulti-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.
Phase 18 follow-up: named task presets (2026-07-16)
Requested directly by the user, who already had a personal ~/.bashrc ba_daily() wrapper function looping ./ba_dailies.sh "$task" || return once per task (a fresh Python process per task) over a fixed list. Wanted the same "run one named sequence" convenience built into the project itself (./ba_dailies.sh daily, with morning/evening/weekly/raid as future examples of the same mechanism), rather than kept as a personal shell alias outside the repo.
Per CLAUDE.md's Bash policy (ba_dailies.sh is a thin launcher only, no loops/sequences in Bash), this was not built as the case-statement-in-ba_dailies.sh the user's own sketch initially proposed -- it went into ba_daily.py instead, alongside the existing DEFAULT_ORDER. Added a PRESETS dict ({"daily": [...]}, exactly the user's own bashrc task list, ported verbatim -- notably it does NOT include gem_shop, which was preserved as-is rather than "corrected," since guessing at the user's intent for a resource-adjacent list wasn't this change's place) and a small _run_sequence helper shared between DEFAULT_ORDER's no-args flow and any PRESETS entry, dispatched via ./ba_dailies.sh <preset-name> in main(). Python owns the loop directly now instead of the bash wrapper's repeated ./ba_dailies.sh "$task" re-invocation (avoiding a fresh process/game-refocus per task) -- _run_task's existing bare try/finally already means an uncaught exception from one task halts the whole sequence, matching the bash version's || return semantics with no extra code needed.
--list-commands (drives completions/ba_dailies.bash's tab-completion) now lists preset names alongside task names, and the "Unknown phase" error path lists both separately. Per explicit user direction, only the daily preset was defined for now -- morning/evening/weekly/raid are left undefined until the user specifies their task lists, rather than guessed at (several candidate tasks spend real resources: arena tickets, bounty tickets, lesson tickets, event_sweep/AP).
Verified on nik-gpu without spending anything: --list-commands includes daily; requesting a bogus phase name correctly lists both valid phases and valid presets and exits 1. The daily preset's actual task sequence (which includes event_sweep/arena/lesson/bounty/shop_common/shop_tactical, all real-resource-spending) has not been run for real yet -- needs explicit user go-ahead before that first live run, per this project's own established live-testing discipline for resource-spending tasks.
The user then added a second preset directly ("q4h": ["login", "cafe", "mailbox", "event_sweep"]) and asked for actual cron scheduling: daily at 3:30 AM and 4:30 AM every day, q4h every 4 hours starting at 5:00 AM.
Phase 18 follow-up #2: cron scheduling (2026-07-16)
Deployed a fresh copy first: the repo checkout (~/repo/ba-auto-daily/, where this session's rsyncs land) and the "fixed runtime paths" setup.sh copies to (~/ba_dailies.sh/~/ba_daily.py/~/ba_auto/, what CLAUDE.md documents as the actual invocation target) had drifted -- the runtime copy predated the user's own q4h edit. Re-ran setup.sh on nik-gpu to resync them (idempotent, confirmed via diff -rq) before pointing cron at ~/ba_dailies.sh, rather than cron silently running a stale copy.
Added ba_cron_run.sh (repo root, deployed to ~/ba_cron_run.sh) as the actual cron entry point instead of calling ~/ba_dailies.sh <preset> directly. It is pure process-locking/logging plumbing -- no game-automation logic, same "thin wrapper" spirit CLAUDE.md's Bash policy already holds ba_dailies.sh to. Two things it adds that a bare crontab line can't:
- A shared
flock -nlock (~/ba_logs/ba_dailies.lock) across every preset invocation.dailyfires twice an hour apart, andq4hfires independently every 4 hours -- without a lock, an overlapping fire time would launch a second concurrentxdotool/scrotsession against the same game window while the first was still mid-navigation, an unsafe race this project has never needed to guard against before now (every previous invocation was a single manual/CLI run).-n(non-blocking): if a previous run is still going, the new one is skipped entirely rather than queued -- deliberately, since a queued-and-delayed run could land at an arbitrary later time with no relation to its intended fire time. - Timestamped logging to
~/ba_logs/<preset>.log(start/finish lines wrapping the actualba_dailies.shoutput and exit code), since a cron job's own stdout/stderr otherwise either gets mailed (if a local MTA is configured, not assumed here) or silently dropped.
Crontab installed (crontab -l on nik-gpu):
30 3 * * * /home/nik/ba_cron_run.sh daily
30 4 * * * /home/nik/ba_cron_run.sh daily
0 1,5,9,13,17,21 * * * /home/nik/ba_cron_run.sh q4h
Times are nik-gpu's local system time, confirmed Asia/Tokyo (JST) via timedatectl -- matters since the user's chosen 3:30 fire time isn't arbitrary, it lines up with the reference project's own JP-server restart-check window (module/restart.py's check_need_restart checks around hour=3), likely intentional on the user's part to mop up anything left just before the daily reset, with the 4:30 fire catching the freshly-reset dailies an hour later.
Verified on nik-gpu without touching the game: the cron daemon is active (systemctl is-active cron -> active, cron process confirmed running); ba_cron_run.sh invoked directly with a bogus preset name correctly logged timestamped start/finish lines wrapping ba_daily.py's own "Unknown phase" error and exit code; a deliberately-forced lock-contention test (holding the lock via a background flock ... sleep 5, then invoking the wrapper again) confirmed the second invocation logged start/finish with no output in between -- i.e. it correctly skipped running ba_dailies.sh at all while the lock was held, rather than racing it.
Not yet verified: an actual scheduled fire (3:30/4:30/the next q4h slot) has not yet happened live, so the real end-to-end cron path (crond -> ba_cron_run.sh -> ba_dailies.sh daily/q4h -> real task execution) is confirmed piece-by-piece but not yet as one unattended whole. Also flagged, not fixed (outside this change's scope): whether nik-gpu's GDM desktop session (DISPLAY=:0) is guaranteed active at 3:30 AM unattended -- if the machine sleeps, locks in a way that tears down the X session, or otherwise isn't logged in at fire time, xdotool/scrot would fail and the run would just log an error with nothing to click. Worth checking nik-gpu's power/session settings, or watching the first few real unattended fires' logs, rather than assuming.
Follow-up, same day: per explicit user request ("I also want to have logs for the cron. So I can detect if anythings go wrong. daily and q4h should have separate .log file") -- the per-preset log file separation was already in place (~/ba_logs/<preset>.log, one file per $PRESET), but a real gap was found in the failure-detection half of the ask: a genuinely crashed task and a lock-skip (an overlapping fire time, expected/harmless) both exit 1, and the original wrapper logged only a bare (exit N), making them indistinguishable at a glance. Fixed by switching from the flock -n LOCKFILE COMMAND form to an fd-based flock (exec 9>"$LOCK_FILE"; flock -n 9), checking lock acquisition as its own explicit step separate from the wrapped command's own exit code, so every log line is now tagged with one of three explicit outcomes: starting/finished ... OK (exit 0)/finished ... FAILED (exit N)/SKIPPED ... -- previous run still in progress -- a real problem can now be found with grep -E 'FAILED|SKIPPED' ~/ba_logs/*.log alone, without having to reason about ambiguous exit codes. Verified on nik-gpu without touching the game: a bogus-preset run correctly logged FAILED (exit 1); a deliberately-forced lock-contention run (same technique as the original test) correctly logged SKIPPED ... -- previous run still in progress instead of a second ambiguous (exit 1); the grep command above correctly surfaced both lines from ~/ba_logs/smoke_test_bad.log. Test log removed afterward -- ~/ba_logs/ currently contains no daily.log/q4h.log yet, since no real scheduled fire has happened.
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 countis"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_badgeOCR + 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
- Rewrite
ba_dailies.shas a thin launcher. — Done - Add
ba_daily.py. — Done - Add
ba_auto/driver.py. — Done - Add
ba_auto/detector.py. — Done - Add
ba_auto/navigation.py. — Done - Move mailbox logic to
ba_auto/tasks/mailbox.py. — Done - Move cafe logic to
ba_auto/tasks/cafe.py. — Done - Update
setup.sh. — Done - Add
ba_auto/reference_notes/mapping.md. — Done - Verify existing mailbox and cafe still work. — Done
- Implement stamina/AP. — Done (Phase 8)
- 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)
- Implement group/club AP. — Not started
- 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" - Implement Common Shop + Tactical Shop. — Done (Phase 11)
- Implement Lesson/Schedule. — Done (Phase 12)
- 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.