# Reference mapping Maps each local feature to the corresponding `~/repo/baas-reference/module/...` implementation, driver/backend replacements, and current status. Update this before/while implementing or migrating a feature — see `CLAUDE.md` → "Reference mapping notes" and `plan.md` → "Reference mapping table". | Local feature | Reference file | Reference functions/classes | Local file | Backend replacements | Status | |---|---|---|---|---|---| | Mailbox | `module/mail.py` | `to_mail`, `implement` | `ba_auto/tasks/mailbox.py` | tap/click via xdotool, screenshot via scrot, `color.rgb_in_range` → `driver.color_at` pixel-probe check | Migrated: real Python, state-verified via color probe (no legacy bridge) | | Cafe | `module/cafe_reward.py` | `to_cafe` (its `relationship_rank_up` popup-handling now also ported, see below), `interaction_for_cafe_solve_method3`, `collect`, `invite_girl`/`invite_by_affection`/`checkConfirmInvite` (student invitation, added 2026-07-14) | `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` (`find_cafe_sparkle`, now multi-scale) | Migrated: real Python, state-verified via color probes (no legacy bridge). Pat loop now polls for the full attempt budget instead of stopping on the first miss (see `plan.md` Phase 6 follow-up) — not yet confirmed against a live sparkle since none was available during testing. `_dismiss_rank_up_if_shown` reuses `navigation.is_on_subscreen` to detect and clear the full-screen bond-rank-up cutscene after a pat (see `plan.md` Phase 6 follow-up: rank-up popups) — not yet live-confirmed against a real trigger. **Student invitation (2026-07-14)**: per explicit user direction, invite a student into each room before farming it, preferring highest affection, always skipping (never confirming) a candidate that would swap an already-seated student's costume or move one in from the other room — directly ports `invite_by_affection`/`checkConfirmInvite`'s own logic with the reference's default `cafe_reward_allow_exchange_student`/`cafe_reward_allow_duplicate_invite` both `False` (no local config exists to make either configurable). Live-calibrated against nik-gpu with **zero real tickets spent** — all 3 real dialog variants (normal confirm, same-room costume-swap warning, neighboring-room move warning) found and confirmed live, every one cancelled rather than confirmed during calibration. The heart-shaped affection badge OCR reuses `detector.read_int_on_heart_badge` directly (pixel-confirmed the same widget lesson.py's own badges use — digit pixels sampled RG, matching that function's exact masking assumption) rather than building a second OCR path. Dialog-type detection OCRs the title bar and checks for either warning's own distinctive substring (`衣装`/`隣`) rather than an exact match, mirroring `event_sweep.py`'s own `"終了"` substring-match reasoning. All 5 heart-badge reads and all 3 dialog classifications verified offline against the real saved calibration screenshots before deploying (exact match, zero mismatches). **Confirmed live with real tickets spent, both rooms**: room 1 correctly skipped one 衣装替え (costume-swap) candidate then invited row 1 cleanly; room 2 correctly skipped three consecutive 隣のカフェの生徒を招待 (neighboring-room-move) candidates (expected — room 1's own 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 (the "newly invited student can be farmed" requirement), income was claimed, and the task returned cleanly to the true home screen with no warnings anywhere in the log. **Horizontal camera panning (2026-07-14)**: per explicit user direction ("due to my screen size... move screen most right and most left then farm"), `_pat_room` now pans the room camera to its rightmost extreme via a new `driver.drag()` primitive (this project's first — distinct from `driver.scroll()`'s wheel-based gesture, which is for list widgets, not a room camera), farms there, pans to the leftmost extreme, farms there too — no vertical panning, per the user's own instruction. Ports the reference's `zoom_out`'s underlying intent (see the whole room regardless of width) via the user's own specified mechanism (panning) rather than the reference's (zoom). Live-confirmed drag-direction-to-reveal-side mapping, that HUD elements (status bar, ticket buttons) stay fixed regardless of pan (no camera reset needed afterward), and that one drag already reaches the true extreme (extra repeats are a confirmed no-op, kept as a safety margin). **Confirmed live with a real game-state change**: a full run patted a real sparkle in room 2 (score 0.997) specifically after panning to an extreme, while room 1 found nothing that run (expected — per-student cooldown). Clean end-to-end completion, no warnings. **Invite ticket cooldown bugfix (2026-07-14)**: real-usage report (with screenshot) that the invite step kept failing once the account's ticket went on cooldown — `_open_invite_list`'s original check (`not is_on_subscreen`) couldn't distinguish the real MomoTalk list opening from a "通知" cooldown notice ("待機時間が経過した後に、再度招待することができます。") opening directly instead, since both dim the header the same way; misread as "list opened", it sent `_ensure_invite_sort`/`_try_invite_row`'s fixed coordinates into a dialog that has none of them. Fixed by checking `navigation.is_modal_open` (the darker real-dialog reading) first — a dialog appearing before any row is clicked can only mean the ticket click raised one directly — and dismissing it via the shared `SWEEP_CONFIRM_BUTTON`, returning `False` so the existing "skip this room's invite" fallback handles it. **Confirmed live**: both rooms correctly detected and dismissed the cooldown notice with no cascading errors, task completed cleanly (exit 0, true home screen confirmed via screenshot). See `plan.md`'s Phase 6 follow-up #4. **Two further real-usage fixes (2026-07-15, `next_fix.md`)**: (1) the cooldown-notice dismiss above now presses Escape and verifies via `navigation.is_modal_open` with bounded retry, instead of a fixed-coordinate click that could miss and leave the notice open under the following pan drags; (2) `_dismiss_rank_up_if_shown` switched from `navigation.is_on_subscreen`'s single-pixel header probe (found live to misread some characters' rank-up cutscene art as bright) to a new `navigation.is_header_bar_visible`, requiring 8 spread-out header-row points to all read bright via one atomic multi-point capture (`driver.colors_at`, added alongside it). Both best-effort — not yet re-confirmed live post-fix. **A real rank-up happened naturally via cron (2026-07-16)**, reported live with a screenshot of the game left stuck on the cutscene — exposed a third bug, a timing gap rather than a threshold problem: `_dismiss_rank_up_if_shown` was only ever called once, immediately after a pat, with no wait beforehand, but the cutscene renders with its own client-side animation delay, so that single check could catch the tail end of the still-normal room view and conclude "clear" a beat before the actual cutscene appeared. Confirmed via a live pixel-check that the resulting stuck cutscene reads under `is_on_subscreen`/`is_modal_open`'s thresholds too (`SUBSCREEN_HEADER_PROBE` read `(180,227,244)`, r=180 < the 200 both checks need), so `navigation.return_to_home`'s generic cleanup also couldn't recover it — every "are we home" check downstream falsely agreed nothing was wrong, and the whole `q4h` cron run finished "successfully" with the game actually left stuck. Fixed by checking on every `_pat_current_view` poll iteration instead of only right after a pat, so a delayed cutscene appearance is caught (and dismissed) roughly a second later instead of never. **Confirmed live**: manually reproduced the exact stuck state left by the incident and confirmed a single Enter (the same action `_dismiss_rank_up_if_shown` already takes) cleared it back to the real cafe room view; 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 as the original fix). **`SPARKLE_CLICK_OFFSET` recalibrated (2026-07-17)**: reported live with a screenshot (`screenshots/cafe/bug_student_close_together.png`) — when two students stand close together, the original `(75, 47)` offset (ported byte-for-byte from `scripts/detect_and_click.py`, never independently verified) overshoots past the intended target's head and lands on a different, closer student instead; the click doesn't register, so `find_cafe_sparkle()` matches the same still-showing sparkle again next iteration, repeatedly clicking nearly the same point for the rest of the room's budget (confirmed live: 13 of 16 "pats" in one run clustered within a ~10px box). First recalibration attempt, `(0, 0)` (raw template-match center), was also wrong — reported live ("clicking the actual sparkle instead of the student head"): a coincidental ambient thought-bubble animation at the test position had been misread as a hover confirmation. Properly recalibrated via numbered candidate-point overlays (screenshots with several labeled offset options drawn on a real live sparkle; the user picked the correct one directly on-screen) against two independent real students in different poses (sitting at an arcade cabinet, lying on a couch) — both converged on the same small offset, `(51, 15)`. **Confirmed live** via the real `./ba_dailies.sh cafe` CLI path: 6 sparkles patted across both rooms at genuinely distinct coordinates with zero repeated-click clustering, clean `[cafe] Done.` and true-home return. | | Stamina/AP | `module/collect_daily_task_power.py` | `to_tasks`/`implement` | `ba_auto/tasks/stamina.py` | `color.rgb_in_range` → `driver.color_at`; reference's per-tab claim loop → live UI's single "一括受取" bulk-claim button + Enter | Migrated: Mission-panel claim done (see `plan.md` Phase 8). The gem shop's own Daily Free Power flow (`module/collect_daily_free_power.py`) is a separate task now — see the "Gem shop daily free package" row below. **Daily gem reward bugfix (2026-07-16)**: reported live by the user against a real account state — the reference's own `implement` checks TWO separate button regions (the main 一括受取 area, then a second, separate "claim daily pyroxenes" button checked independently after), but only the first was ported originally. Fixed by porting the second region too: a new `MISSION_DAILY_GEM_CLAIM_BUTTON` (config.py), reusing the same bright-yellow-vs-grey color-probe style as the existing `MISSION_CLAIM_PROBE`, clicked via `driver.click` (not Enter — unlike 一括受取, this button has no keybind shown on screen) with a click-then-verify retry loop matching this project's established discipline. **Confirmed live**: gem balance went from 12,384 → 12,404 (exactly the reward's stated +20), clean return to the true home screen, no warnings. | | Gem shop daily free package | `module/collect_daily_free_power.py` | `implement`, `to_purchase_pyroxenes_menu`, `to_purchase_type`, `detect_free_power_availability`, `collect_daily_free_power`, `return_to_main_page` | `ba_auto/tasks/gem_shop.py` | Reference detects every step via `core.picture.co_detect` + fixed-region OpenCV template matching (no OCR anywhere in this reference flow) — ported here as plain color probes instead, matching this project's own established equivalent for a simple flat-color state-A/state-B difference (see `cafe.py`'s `CLAIM_DISABLED_RGB`, `stamina.py`'s `MISSION_CLAIM_PROBE`) rather than building new template assets. **Recalibrated 2026-07-29** after a real client UI update replaced the old 3-top-tab overlay dialog with a genuine full subscreen (left sidebar + デイリー/ウィークリー/マンスリー sub-tabs under 一般商品). The free card's own status bar now reads flat light blue (~(226,236,246)) when available vs flat dark red (~(190,56,66)) once claimed today — still no OCR needed, same high-contrast-flat-color reasoning as before, just re-read off the new layout. The old dedicated `GEM_SHOP_DIALOG_PROBES` 4-point "is this dialog showing" check is gone entirely: the redesigned screen is a real subscreen (own header, back arrow at the shared `navigation.BACK_BUTTON` position), confirmed live that `navigation.is_on_subscreen`/`return_to_home` now work correctly here, so `gem_shop.py` was simplified to share that machinery with mailbox/cafe/shop/lesson instead of the old bespoke Escape-only close. | Done, recalibrated. Original: live-calibrated against nik-gpu 2026-07-15 by manually driving the real dialog end-to-end via raw xdotool/scrot — genuinely claimed the account's real free package for the day (0 yen, confirmed +10 AP / +10,000 credits). Recalibration (2026-07-29): the new "already claimed" status color and every new coordinate (icon, sidebar tab, daily sub-tab, buy button, status probe, screen-open/close detection) were re-verified live on nik-gpu against the real redesigned screen (see `scratchpad/gem_shop_after_click.png`/`gem_shop_weekly_tab.png`/`gem_shop_after_close.png`) — confirmed the icon click still opens it, confirmed `is_on_subscreen`/`return_to_home` now correctly detect/close it, and confirmed the "already claimed" red status color directly off the real card (still claimed from the 2026-07-15 session's calibration run, today's own reset hadn't been claimed yet at investigation time but the account's daily reset had already occurred, so it was showing the post-claim state). The new "available" light-blue color was inferred from three other real, currently-purchasable cards under the ウィークリー tab (same shared status-bar UI component, all three reading identically) rather than from the daily free card itself, since that card wasn't in the available state at recalibration time — **not yet re-confirmed against the daily free card's own available state, or against the full claim-confirmation/reward-banner flow post-redesign**; worth a deliberate check the next time the package resets and hasn't been claimed yet. Added to `DEFAULT_ORDER` (unlike most recent additions): it only ever reclaims a genuinely free, once-per-day resource with no choice to make, matching mailbox/cafe/stamina's own "reclaim something free" category rather than the "spends a resource, needs opt-in" category. | | Normal/Hard story AP sweep | `module/explore_tasks/sweep_task.py`, `module/explore_tasks/task_utils.py` | `to_region` (ported: OCR region-number readout + delta-click), a scoped-down `swipe_search_target_str` (ported: OCR stage-row label matching), `start_sweep`'s named-outcome contract (ported via `navigation.wait_for_state`, this project's scoped `co_detect` port) | `ba_auto/tasks/story_sweep.py` | OCR region/stage-name matching, ported for real (Phase 10) — replaces Phase 9's "next-region arrow stops advancing, then random stage" heuristic; modal closed via its own X button (Escape doesn't close it; X-button position re-calibrated per stage-layout variant, see Phase 10) | Done (see `plan.md` Phase 10, supersedes Phase 9; rewritten Phase 22 to match story_sweep_hard.py's Phase 21 fixes and add its own campaign guard). Config-driven exact `(region, stage, count)` targets (`config.STORY_SWEEP_TARGETS`), not latest-region/random-stage. Opt-in only, not in default flow. **2026-07-21 rewrite (Phase 22)**: per explicit user direction ("match the hard implementation on best practices"), ported story_sweep_hard.py's fixes back into this module — an explicit Normal-tab guard (`config.NORMAL_TAB`/`_open_normal_tab`, needed now that story_sweep_hard can run earlier in the same `q4h` preset and leave Hard selected), a loop-level self-heal (`_ensure_task_screen`) replacing the old per-path "Escape if on subscreen" cleanup that would have backed out of the region browser after every target (never triggered in production since this module only ever swept one rotation target per run, but a real latent bug), MAX/+ clicks no longer requiring a visible stepper raise as proof of success (same low-AP false-negative class Hard hit), `_click_plus` now resets to a known baseline via a new `SWEEP_MIN_BUTTON` before raising (closing a latent version of bounty.py's real overspend bug that this module's original `_click_plus_and_verify` never guarded against), a newly-added `_confirm_dialog_is_sweep` OCR gate against the two-stacked-button hazard (confirmed live 2026-07-21 that Normal's own tabbed modal has the identical gold 任務開始 button beneath 掃討開始 that config.py's own `SWEEP_MAX_BUTTON` comment already documented but this module never actually guarded against), the same `clicked_any`-gated `_watch_sweep_result` fix, and a `REGION_READ_RETRIES` OCR-read retry. Also added a genuinely new campaign-active guard (`config.TASK_CAMPAIGN_BADGE_RECT`, renamed from `HARD_CAMPAIGN_BADGE_RECT` once shared) — confirmed live to be pixel-identical rect/color to Hard's own banner, just reading "任務Normalで獲得できる報酬量が2倍(+100%)になっています" instead of "Hard". Confirmed live that Normal mode has no per-stage daily clear cap (no "残り回数" indicator anywhere), so Hard's gem-refill hazard does not apply here. `story_sweep_force` added as the override command. **Live-tested for real the same day**: a real MAX sweep of the day's rotation target (30-2) succeeded cleanly end-to-end on the first attempt — campaign guard passed, OCR stage-row match succeeded despite the known leading-digit misread ("20-2" read but the "2" suffix still matched), sweep confirmed and correctly detected as `"swept"` (not the old cosmetic misreport), clean return home. AP dropped from ~139 to 1 and credits rose by 13,966, confirming a real substantial MAX sweep. | | Event sweep | `module/sweep_activity.py`, `module/activities/activity_utils.py` | `activity_sweep` (main flow), `to_activity` (nav to the event's Story/Mission/Challenge tabs), `check_sweep_availability`/`color.check_sweep_availability` (SSS gate), `start_sweep`'s named-outcome contract (shared with story_sweep's, ported the same way via `navigation.wait_for_state`) | `ba_auto/tasks/event_sweep.py`, `ba_auto/navigation.py` (`return_to_home`) | `to_activity`'s bottom-nav-icon entry -> this client's home-screen event badge (`config.EVENT_BADGE_ICON`, confirmed live to be a rotating carousel -- see Status); the reference's config-string sweep-list parsing (arbitrary stage lists, per-stage float/fraction counts via `preprocess_activity_region`/`preprocess_activity_sweep_times`) -> a single date-ordinal-modulo rotation target over a fixed 9-12 sub-range, mirroring `story_sweep.py`'s own rotation, per explicit user direction; stage-number OCR (`detector.read_int`) replaces the reference's `swipe_search_target_str` template-button search, since this client's stage list only ever needs its bottom scroll extreme for the 9-12 target range | Done, with a live-reported bug fixed (see `plan.md`'s Phase 14 follow-up). Live-calibrated against nik-gpu 2026-07-10 against the currently-running "鉄道爆走事件" event (12 stages) -- zero real AP spent during calibration (every confirm dialog reached was cancelled via Escape, verified by the AP counter). The stage-info modal is structurally identical to story_sweep's (MIN/-/+/MAX stepper, same AP-confirm dialog reusing `SWEEP_CONFIRM_*`/`SWEEP_RESULT_BUTTON_REGION`), but simpler: no region navigation, one fixed modal layout confirmed across two different stages (09 and 12), and the modal closes on Escape (story_sweep's doesn't). The reference's SSS-availability gate for a never-cleared stage was never exercised (every stage 9-12 on this account was already 3-starred) -- `event_sweep.py` handles that gate the same defensive way story_sweep handles an unavailable target: if the MAX-button count-raise can't be verified, it aborts without spending AP rather than guessing. **Bug found on a real run**: the home-screen event badge turned out to be a rotating carousel (cycles between the current event's countdown and other notices, e.g. a finished event's leftover reward-claim reminder), so a click could land on a stale event's page instead. Fixed via a new shared `navigation.return_to_home` primitive (bounded press-back-until-home loop, built generically so other tasks can reuse it, per explicit user request) plus wrong-page detection in `_find_stage_row` (zero stage-row numbers OCR'd at all -> retry via `return_to_home`, up to 3 attempts). **Second bug found on the next real run** (after the above fix correctly recovered and correctly OCR'd the target row): the row's own 入場 (enter) button click had no retry, unlike every other click-then-confirm step in this module -- missed once, aborted the whole run. Fixed via `_open_stage_modal`, the same click-then-verify-then-retry pattern already used everywhere else in the file. **Third bug found on a third real run**: `_find_stage_row`'s wrong-page detection false-positived twice (a fresh navigation's list hadn't finished rendering on the first OCR pass) before self-correcting on the 3rd attempt, wasting expensive return-home retries on what was really just a timing race -- fixed with a cheap in-place rescan before concluding "wrong page." Also, once past that, the 掃討開始 (start sweep) click turned out to be the last bare, unretried click in the file -- fixed via `_click_sweep_start_and_verify`. **Fourth round, self-driven live iteration per explicit user direction (fix/deploy/run/screenshot/diagnose in a loop, no stopping to report)**: found two more root causes and reached the first confirmed real live sweep. (1) The badge carousel's auto-rotate timer is far slower than the retry window -- a run hit "wrong page" on all 3 attempts genuinely, confirmed by screenshot; fixed by discovering and clicking the carousel's own pagination dots directly (`config.EVENT_BADGE_DOT_X`) instead of hoping the ambiguous badge shows the right item. (2) A genuine cold-start settle delay (up to ~20s for the Quest tab's stage list to populate after a fresh navigation, most likely a one-time server round-trip), not a flaky race -- confirmed via standalone probe scripts polling the real OCR pipeline once/second for a minute; fixed by widening `STAGE_ROW_SCAN_ATTEMPTS`/`_RETRY_WAIT` to match. With both fixes, a real run completed end-to-end: 200 AP spent (10x MAX sweep), credits gained, clean return home, confirmed by screenshot -- see `plan.md`'s Phase 14 follow-up #4 for the full writeup. **Fifth round, reported by the user a day later**: the exact same wrong-page-looping symptom recurred. Per the user's explicit request, added a DIRECT check for the finished event's own "イベント期間が終了しました" text (Japanese OCR, newly installed on nik-gpu since this project previously only needed digit/English reads) instead of the indirect "zero valid rows" heuristic -- `config.EVENT_FINISHED_TEXT_RECT`/`_is_finished_event_page`, live-calibrated and confirmed both ways (exact phrase match on a real finished page, no false positive on the correct page). This immediately proved the badge carousel's dot-clicking (follow-up #4's fix) is NOT reliably controllable after all -- sometimes worked, sometimes didn't, on the identical badge state -- while simply waiting longer let the carousel's own auto-rotate timer land on the correct item independently. Fixed by widening `WRONG_PAGE_RETRIES`/`_WAIT` (3x3s -> 6x12s) to give the timer real room to cycle, keeping dot-clicking as a harmless supplementary nudge. Two more real sweeps confirmed this fully working (11x MAX sweep, 219 AP spent, credits gained, clean home return, confirmed by screenshot -- three real confirmed sweeps total across this whole investigation). Also found and fixed the real root cause of the lingering `"unrecognized_state"` cosmetic bug (not a budget issue after all): `_watch_sweep_result`'s "swept" condition required the stage modal to still be open, but this event's flow can auto-return all the way to the Quest list instead, a terminal state it never anticipated -- fixed with a second `ends` condition gated on having clicked at least one result button first. Not yet re-verified live (AP exhausted by the successful sweep). See `plan.md`'s Phase 14 follow-up #5 for the full writeup. **Sixth round (2026-07-11)**: the daily rotation picked stage 9 for the first time, hitting a previously-flagged-but-never-exercised gap -- rows 366/538 ("08"/"09") consistently misread by OCR ("2" and empty/None) on every psm mode, even though the crop looked completely clean by eye; confirmed NOT a navigation/timing bug since rows 710/883/1055 ("10"/"11"/"12") read fine in the same run. Root cause (`scratchpad/probe_ocr_fix_08_09.py`): tesseract's segmentation fails on this tight edge-to-edge crop (no whitespace margin) for a leading-zero digit pair specifically -- adding a plain white border around the upscaled crop before OCR fixed both digits exactly, at every psm mode, without affecting "10". Fixed via new `detector.read_int_bordered`, now used for all `EVENT_STAGE_ROW_Y` reads. **Confirmed live**: stage 9 found, sweep executed for real (AP 233->14, credits +5,892, screenshot-confirmed safe return home) -- the originally reported bug is fixed. The same run again logged `unrecognized_state`, proving follow-up #5's `clicked_any`-gate fix addressed a real but secondary issue, not the full story. Diagnosed at zero AP cost (`scratchpad/probe_result_button_fp.py`, checks `_find_result_button` against the plain Quest list with no sweep running): the shared `SWEEP_RESULT_BUTTON_REGION` (borrowed from `story_sweep.py`) reaches into this event's own character-art panel and false-positive-matches `SWEEP_CONFIRM_CYAN` there with no dialog showing at all, confirmed on both a wrong event page and the correct one's own plain list -- so `_watch_sweep_result` kept "finding" a result button after the real one was already dismissed, and its "modal closed, no result button" end condition could never match. Fixed with a new event_sweep-only `config.EVENT_SWEEP_RESULT_BUTTON_REGION` (x narrowed to exclude the character-art panel, still comfortably covering the real buttons), confirmed live against the actual false-positive condition (now returns `None` where it previously didn't) -- not yet re-confirmed via a fresh full sweep since AP was too low that day. See `plan.md`'s Phase 14 follow-up #6. | | Circle (Group/Club) daily check-in | `module/group.py` | `implement`, `to_group` | `ba_auto/tasks/circle.py` | Reference's `picture.co_detect` polling against fixed screen positions + `rgb_possible`/`img_possible` template states → this client's own 2-click nav (home → bottom-nav ソーシャル icon → サークル card) verified with the existing shared `navigation.is_modal_open`/`is_on_subscreen` (no new probe needed — pixel-confirmed both real "reached the circle screen" states, with or without the reward modal, satisfy that combined check) | Done. Live-calibrated 2026-07-15 on nik-gpu by driving the real flow end-to-end via raw xdotool/scrot — this **genuinely claimed the account's real circle check-in reward for the day** (+10 AP, confirmed via the real reward dialog "今日のサークルへの参加報酬... AP x10... 報酬はメールボックスから受け取ることができます"), which calibrated both real states from real data: the first-entry reward modal, and (by re-entering immediately after) the already-checked-in straight-to-chat state with no modal. Also live-confirmed a real `XIGNCODE` anti-cheat overlay stole a `BACK_BUTTON`-coordinate click mid-calibration (same recurring gotcha documented elsewhere in this project) — recovered via the standard `windowactivate`+`windowraise` escalation. Per explicit user direction, the task presses Escape directly to return home (confirmed live: a single Escape from the サークル screen returns straight to true home, skipping back through the intermediate ソーシャル hub page) rather than clicking `navigation.BACK_BUTTON`. The reference's `group_join-club` ("not in a circle") outcome is deliberately not ported — this account is already a member, and the task's scope (per explicit user direction) is entry only, no mailbox claim. The actual `circle.py` module was then live-tested for real via `./ba_dailies.sh circle`, correctly reading the "already checked in today" state left over from calibration and returning cleanly home. **Not yet live-tested**: the "first entry → claim reward" code path itself, since the account was already checked in for today by the time the module existed — same disclosed gap shape as gem_shop's own "available → claim" path. Added to `DEFAULT_ORDER` alongside mailbox/cafe/stamina/gem_shop, since it's a pure free reclaim with no decision to make. **A `reference-parity-reviewer` pass the same day caught and fixed three real gaps**: a too-generic terminal-state check that could false-positive "already checked in" from a stuck non-home starting state (fixed via `navigation.return_to_home(driver)` at the top of `run()`, matching arena.py/bounty.py precedent); an unverified reward-dismiss Enter press and closing Escape (fixed via `_dismiss_reward`/`_leave_circle`, bounded retry-until-verified loops directly porting `gem_shop.py`'s `_claim_free_package`/`_close_gem_shop`); and a missing `driver.focus_game()` escalation in the entry retry loop for the `XIGNCODE` overlay (which had already struck live during this task's own calibration) — fixed to match `navigation.click_back`'s own escalation convention. Re-confirmed live via `./ba_dailies.sh circle` after all three fixes. See `plan.md`'s Phase 17 for the full writeup. | | Bounty | `module/rewarded_task.py` | `implement` (main flow, `get_task_count`/`purchase_bounty_ticket`/per-area `rewarded_task_status` loop, not ported -- see Status), `to_bounty`/`to_choose_bounty` (nav), `get_los`/`one_detect`/`bounty_common_operation` (per-row SSS-color scan + sweep) | `ba_auto/tasks/bounty.py` | `to_bounty`'s bottom-nav "bus" icon -> this client's Work-hub `指名手配` card (`config.BOUNTY_CARD`), landing directly on Location Select with no separate bus-icon sub-navigation step; `get_los`/`one_detect`'s per-row `color.check_sweep_availability` SSS-color scan across however many rows are visible -> a fixed bottom-most row click (`config.BOUNTY_LATEST_STAGE_ROW_Y`), since this client's 3 areas each have exactly 10 stages (confirmed live, scrolling past the 10th is a no-op) all already SSS-cleared, making "scroll to the bottom extreme, click the last row" equivalent to "the latest stage available" by construction; the reference's config-string per-area sweep-count list (`rewarded_task_times`, `get_task_count`) and its loop across all 3 areas -> a single date-ordinal-modulo rotation choosing ONE area per run, mirroring story_sweep.py/event_sweep.py's own rotation, per explicit user direction (2026-07-11); `purchase_bounty_ticket` (buying more tickets with real currency) -> not ported, matching plan.md's "OCR optional for coin balance/refresh logic, can skip for first version" | Done, live-tested for real -- see `plan.md` Phase 15. Live-calibrated against nik-gpu 2026-07-11 with **zero real tickets spent** during calibration (every ticket-usage confirm dialog reached was cancelled via Escape, verified by the ticket counter (6/6) unchanged before/after, across all 3 areas). All 3 areas (ハイウェイ/砂漠の線路/校舎, matching the reference's OVERPASS/DESSERT RAILWAY/CLASSROOM groupings) confirmed to share an identical layout: same area-row/stage-row/modal-button coordinates, same 10-stage-per-area structure. The 任務情報 modal's MAX stepper, minus-button raised-color check, and ticket-usage confirm dialog are pixel-identical to event_sweep.py's own stage modal / shared `SWEEP_CONFIRM_*` dialog -- confirmed live -- and reused directly. The modal-open probe could NOT be reused from `EVENT_STAGE_MODAL_PROBE`: that corner point reads dark on this screen regardless of modal state (different background art), so a fresh `BOUNTY_STAGE_MODAL_PROBE` was calibrated that does discriminate both states. **2 real sweeps confirmed live** (credits +180,000 then +36,000, clean automatic return home both times), which surfaced and fixed two real bugs: (1) `_set_sweep_count`'s count==1 path wrongly assumed the modal defaults to count=1 on open -- it actually remembers the last-used count -- causing an unintended 5-ticket spend instead of the intended 1; fixed via a new `_click_min_and_verify` that always forces a known baseline first. (2) `BOUNTY_SWEEP_RESULT_BUTTON_REGION`'s first-guess copy of event_sweep's own region overlapped `BOUNTY_SWEEP_START_BUTTON`'s real cyan pixels, causing `_find_result_button` to re-click it once tickets hit 0 -- which surfaced a real Pyroxene ticket-purchase prompt (no gem actually spent, confirmed by an unchanged balance, but a real near-miss). Fixed three ways: the region was corrected to the real pixel-scanned OK-button bbox; a new `min_pixels` parameter on `detector.find_color_centroid` (plus `config.BOUNTY_RESULT_BUTTON_MIN_PIXELS`) filters out a second, smaller contamination source (stray cyan-range pixels in the modal's own reward-icon artwork); and `_watch_sweep_result` now has an explicit `_is_ticket_purchase_prompt` guard as a named `ends` condition. **Not yet re-confirmed live**: a fresh sweep with the fixes deployed (account at 0/6 tickets as of session end) and any bulk/MAX-count sweep's result-screen flow (only count=1 was ever tested; a bulk sweep may show a SKIP-then-OK sequence like story_sweep/event_sweep instead of the single-OK dialog confirmed here). **Real hazard found and fixed via a real unattended `daily` cron run (2026-07-17)**: reported live by the user -- a real "Battle Complete" screen (a live ~3-minute combat timer) was found on the account instead of an instant sweep, with `_watch_sweep_result` having reported "swept" cleanly and no warnings anywhere in the log; every task that ran for the rest of that `daily` invocation failed to open its own screen. Manually reproducing the flow live (reaching the real confirm dialog and cancelling before confirming, the same safe-calibration pattern the original live-testing used) found the actual hazard: the 任務情報 modal has TWO separate action buttons stacked vertically -- the intended cyan 掃討開始 (start sweep, instant) and a separate gold 任務開始 (start mission, a REAL manual battle) directly below it, both showing an identical ticket-cost preview. Every check along the commit path (`_count_raised_above_one`/`_is_sweep_usage_confirm`/`_watch_sweep_result`'s own "swept" conditions) is a generic color/position probe with no verification of what's actually showing. Fixed with `_confirm_dialog_is_sweep`, an OCR text check (`config.BOUNTY_SWEEP_CONFIRM_TEXT_RECT`, captured live from the real dialog: "指名手配チケットをN使用して、掃討をN回行いますか?") gating the one irreversible click in this flow -- cancels rather than confirms if the dialog doesn't read as a sweep. The exact mechanism that let a real run reach 任務開始 instead of 掃討開始 was not fully reproduced live (would mean deliberately repeating a real battle); the fix closes the hazard regardless of the exact upstream cause. Not yet re-verified live against a fresh real sweep. | | Login | `core/Baas_thread.py`, `module/restart.py` | `to_main_page` (generic post-launch arrival routine, reused by every other reference feature's own navigation -- no separate "login" module exists in the reference), `restart.py`'s `implement`/`start` (check app running, launch if not) | `ba_auto/tasks/login.py` | Reference detects every one-off popup (~20 named `img_reactions`/`rgb_possibles`) via `picture.co_detect` image-template matching; this port scopes down to what was actually confirmed live (title screen via a fixed-chrome logo-color probe, a real network-error notice, the daily attendance card, S.C.H.A.L.E NEWS) plus a bounded generic Enter-press fallback for anything else recognized, mirroring co_detect's own "blind action once nothing matches" fallback shape but using this project's own established Enter-dismiss idiom rather than a blind coordinate click. `module/restart.py`'s kill-then-relaunch pattern ported directly via new `driver.kill_game`/`launch_game`/`is_game_running`/`window_exists` primitives (`pkill -f`/the account's own `/usr/local/bin/launch-blue-archive.sh`/`pgrep -f`/`xdotool search`) | Done. Live-calibrated 2026-07-16 on nik-gpu against the account's real overnight login-screen state (native 1920x1200 captures throughout, not the non-native `screenshots/daily_login/*.png` reference photos originally supplied -- same not-1:1 gap already documented for `screenshots/gem_shop/`/`screenshots/cafe/student/`). Confirmed live: the title screen's own logo reads a fixed brand-chrome color independent of rotating seasonal background art (confirmed across two different pieces), bright when clean and uniformly dimmed when a real notice is open on top of it (a genuine "ネットワークへの接続に失敗しました" network error surfaced unprompted during calibration); the daily attendance card only appears once per day (confirmed absent on an immediate same-day re-run after being claimed) and, like the network notice, responds to a plain Enter with no dedicated detection needed; the S.C.H.A.L.E NEWS popup needed a dedicated header-color probe since `navigation.is_on_subscreen`/`is_modal_open` both proved unreliable on it (same class of mismatch as `gem_shop.py`'s own dialog -- confirmed live by direct pixel comparison). **A real stuck-loading incident hit live during calibration itself**: the loading transition (a full-bleed variant with no chrome, distinct from a brief chrome-visible variant also seen) stalled past 6 minutes with zero progress, confirmed not a network/process-health issue; the user's own live guidance ("kill the game and rerun it") identified the relaunch script, and a second attempt after manually killing+relaunching completed the entire remaining flow (title tap -> attendance card -> home) in well under 15 seconds, confirming the stall was a genuine stuck state now handled automatically by `_recover`. Also observed live (twice, non-deterministically) but deliberately NOT worked around: a known pre-existing client rendering bug (per the user) where the news popup's own promo image can get stuck as a blank white rectangle after closing -- confirmed harmless to this module specifically (sits clear of every probe point used here, and the shared home-check probes still read correctly through it), and the user's own fix for it (reload the app) is already this module's existing stuck-recovery path. Added as the very first step in `DEFAULT_ORDER`, ahead of mailbox, since no other task can reach home from the title/loading/attendance-card states on its own. **Not yet live-confirmed**: the infrequent 業務復帰ログインボーナス welcome-back login bonus card from the user's own reference screenshots (account wasn't in that state during calibration) -- expected to fall through to the same generic-Enter path already confirmed for the attendance card and network notice, but not yet exercised for real; and the kill+relaunch recovery path has only been exercised once, manually, not yet through a fresh invocation of the actual `login.py` module hitting a real stuck state on its own. **Connectivity-probe fix, `_connectivity_confirmed` (2026-07-17)**: reported live by the user via real pulled cron logs -- a 4:30 AM `daily` fire logged a clean `[login] reached home`, but every task that ran afterward (cafe, event_sweep, circle, lesson, arena, both shops, gem_shop, mailbox, stamina) failed to open its own screen, persisting across multiple consecutive `q4h` fires spanning hours. Per the user's own direct knowledge: the server's daily reset or a concurrent login from another device can silently kill the session while the client keeps showing a cached-looking, visually normal home screen -- the error only surfaces as the game's own "connection lost" popup once an actual navigation/API call is attempted, which `_true_home`'s purely-visual checks never triggered. Fixed per the user's own suggested design ("enter the stamina claim/reward area then go back home"): `_connectivity_confirmed` now opens the Mission panel and closes it again before `_wait_for_home` will return success. Reproducing this live (the user logged into the account on their phone to trigger a real session kill) surfaced two FURTHER false positives in the same family, both from an unusually long, multi-frame animated loading sequence that never resolved on its own: first with `navigation.is_on_subscreen` (single-pixel) as the panel-opened check, then again after upgrading to `navigation.is_header_bar_visible` (8-point) -- each independently got fooled by a different coincidental splash frame within the same run. Fixed by requiring `_true_home` to re-verify a second time, after a short wait, before finally trusting a passed connectivity check. **Confirmed live**: the stuck session was cleared with a manual kill+relaunch, then a genuinely fresh cold-start `login` run (game process not running at all) correctly launched the game, passed through the title screen, and reached independently-verified true home (full HUD -- Lv/AP/credits/gems/bottom nav all visible, confirmed via direct screenshot and a separate `_true_home`/`is_header_bar_visible` check, not just the module's own printed message). Not yet re-confirmed against a fresh instance of the original silent-session-death failure mode specifically (the live reproduction available this session was the full-kick-to-login-screen variant, not the "still looks like home but isn't" variant from the cron logs), though the same `_connectivity_confirmed` mechanism covers both by design. **Faster stuck-loading detection, `_loading_buffer_visible` (2026-07-19)**: a manual `ba_cron_run.sh daily` run left the game stuck at login; `_recover`'s existing kill+relaunch DID fire correctly once the generic 240s timeout elapsed (confirmed live -- the user reported "the game did relaunched"), but waiting the full generic timeout for this specific, recognizable state is slower than necessary. Live-captured the real stuck screen and pixel-probed it (`screenshots/daily_login/stuck_loading_buffer.png`, calibrated via `scratchpad/probe_login_buffer.py`): the full-bleed loading variant's center spinner badge reads a flat, exact neutral gray `(118,118,118)` at a fixed screen-center position, independent of whichever splash-art frame is rotating behind it -- unlike the earlier `_true_home`/`_connectivity_confirmed` false positives, this doesn't share the "different frame fools a single-snapshot check" failure class, since the badge is a fixed UI overlay, not art. `_wait_for_home` now tracks continuous badge visibility and bails out to `_recover` after `config.LOGIN_LOADING_BUFFER_STUCK_SECONDS` (60s) instead of the full 240s once this exact state is confidently recognized. Verified offline against the real capture (all 6 probe points matched). Not yet live-confirmed against a fresh stuck instance recovering via this specific faster path (the triggering instance had already recovered via the pre-existing generic timeout by the time the fix was written). | | Commissions | `module/clear_special_task_power.py` | Need to inspect | `ba_auto/tasks/commission.py` | sweep/color adaptation | Not started | | Exit game | None -- searched `~/repo/baas-reference/` thoroughly, no counterpart exists. The closest match, `core/Baas_thread.py`'s `shutdown()`/`start_shutdown()` (line ~984), is an optional full Windows OS shutdown (`subprocess.run(["shutdown", "-s", "-t", "60"])`) gated by a user toggle, not a game-client exit -- out of scope (this project's game and X display run on `nik-gpu`, shutting the host down would kill the SSH session too, nothing like what was requested). The only other app-closing call, `connection.py`'s `close_current_app` (line 381, plain ADB `app_stop`), exists purely for `Baas_thread.py`'s own error-recovery restarts (`deal_with_package_incorrect`/`deal_with_func_call_timeout`) inside a persistent background scheduler that's designed to keep running indefinitely -- it never deliberately exits once daily tasks finish. This feature is new, desktop-specific convenience logic motivated by this project's different architecture (cron launches a fresh one-shot process per preset, so closing the game after a run has real value the reference's always-on scheduler never needed), not a port of anything. | `ba_auto/tasks/exit_game.py` | No new driver/detector primitives needed -- reuses `navigation.return_to_home` (must-be-true-home gate), `navigation.is_modal_open` (verifies the Escape-triggered "exit the game?" dialog actually opened, the same generic dim-probe reused across bounty/gem_shop/circle's own confirm dialogs), `driver.keypress("Escape"/"Return")`, and `driver.window_exists()` (verifies the game process/window is actually gone afterward, not just that Enter was sent) | Done. `navigation.return_to_home`'s own docstring already documents the mechanism this relies on: an Escape press on the confirmed true home screen (nowhere else) raises this exact dialog -- every other task treats that as a hazard to avoid triggering by accident; this is the one task that wants it, gated the same way (only fires from a verified true-home state). No force-kill fallback if the graceful Escape/Enter path doesn't verify -- matches this project's established "abort cleanly on unknown state rather than guess" convention (gem_shop/bounty), rather than reaching for `driver.kill_game()`. Opt-in only, appended to the end of the `daily`/`q4h` presets (not `DEFAULT_ORDER`), since it deliberately ends the session. **Confirmed live (2026-07-18)** via a standalone `./ba_dailies.sh exit_game` run, user-reported "works well" -- `is_modal_open`'s generic dim-probe correctly read the exit-confirmation dialog after Escape, and the game closed cleanly after Enter. Not yet exercised as the tail end of a full `daily`/`q4h` preset run (only standalone so far), though nothing in its own logic depends on which task ran before it. | | Arena | `module/arena.py` | `implement` (main flow), `to_tactical_challenge` (nav from main page), `get_tickets` (ticket-count OCR), `choose_enemy` (self/opponent level OCR + bounded refresh-reroll loop), `check_skip_button` (skip-toggle color probe), `fight` (click fight, wait for win/lose), `collect_tactical_challenge_reward` (two reward-slot color probes) | `ba_auto/tasks/arena.py` | Ticket count/self level/opponent level/rank are plain digit OCR (`detector.read_int`/`read_text`, plus a new `read_int_white_on_dark` for the profile card's bright-on-dark level text). Skip-toggle state and the two reward-slot claimed-vs-claimable colors are plain pixel-color probes (`driver.color_at`/`_color_in_range`). The post-fight WIN/LOSE result modal and an unrelated list-refresh-expired notice are NOT detected by precisely locating a button (a color-region search proved unreliable — see Status); they're dismissed via a bounded blind-Enter-press loop matching `lesson.py`'s own `_run_one_schedule` pattern, gated by a hard safety check against the one modal where Enter is dangerous (opponent-info's own attack-formation button, checked via its fixed-position gold button). `choose_enemy`'s refresh-reroll loop is direct Python control flow, bounded by `maxArenaRefreshTimes`. Config knobs carried over from reference defaults: `ArenaComponentNumber`=1, `ArenaLevelDiff`=0, `maxArenaRefreshTimes`=10, `ArenaStopFightWhenRank1`=False | Done. Live-tested for real across all 5 of the account's daily tickets (2 WIN, 1 LOSE, 2 spent debugging the result-modal detection — see `plan.md` Phase 13 for the full writeup). Real navigation differences confirmed live: Tactical Challenge is a Work-hub card, not a bottom-nav icon; the reference's separate opponent-info and formation-edit screens are merged into one modal here with a live ticket-preview; `navigation.is_modal_open`'s shared probe reads *inverted* on this screen (own `_is_modal_open` via `config.ARENA_MODAL_PROBE`). Three real bugs fixed: a level-OCR crop too small for tesseract despite looking legible (fixed by widening the crop, not the pipeline); level text being bright-on-dark unlike every other OCR read in this project (fixed via `read_int_white_on_dark`); and the result-modal detection cycling through two failed color-based designs (fixed by switching to bounded blind-Enter dismissal with a hard safety gate — a real near-miss of the same "mistimed keypress" hazard class `CLAUDE.md` already documents from story_sweep). Deliberately opt-in only, never in `DEFAULT_ORDER` — unlike every other opt-in task so far (which spend a known-safe resource on a config-driven target list), this one fights a real ranked PvP battle that can win or lose and moves the account's actual arena rank. Per explicit user decision: fights exactly one battle per invocation, matching the reference's own per-call pacing (its `next_time = 55` background-thread rescheduling has no equivalent in this project's one-shot CLI). Not yet exercised live: the "no ticket" mid-flow race, an actual reroll click (every opponent offered was already an acceptable level), and `ArenaStopFightWhenRank1`'s rank-1 stop condition — all implemented per the reference's logic, just not yet hit by real game state. `detector.find_template`/`template_visible` (generalized named-template matcher, built during scaffolding) ended up unused — state detection stayed OCR/color-probe-driven throughout, like every other task in this project. Follow-up (2026-07-11): live bug report — this task never returned home at the end, so a second consecutive invocation starting from wherever the first left the game (the Tactical Challenge screen itself) sent `_open_tactical_challenge`'s home-relative clicks to the wrong place and failed all 3 retries. Fixed by calling the shared `navigation.return_to_home(driver)` at the very start of `run()`, reusing the generic Escape-based recovery primitive built for `event_sweep.py`'s own wrong-page recovery. Confirmed live (2026-07-11): reproduced the stuck-on-arena-screen scenario manually, then a real `arena` invocation recovered and completed a full fight normally (rank 14位's opponent-list entry moved 10位→9位, ticket 2→1, credits +1,080), with no repeat of the original failure. Follow-up #2 (2026-07-12), per explicit user request: reversed the original "exactly one battle per invocation" decision — `_fight_one` now holds the single-battle logic and `run()` loops it (`while tickets > 0 and fights < config.ARENA_MAX_FIGHTS_PER_RUN`), re-reading the OCR'd ticket count after each fight and waiting `config.ARENA_POST_BATTLE_COOLDOWN` (30s, per the user's own info about the real in-game lockout between fights) before continuing. `ArenaStopFightWhenRank1` is now re-checked before every fight in the loop, not just once. See `plan.md`'s Phase 13 follow-up #2 — not yet live-tested (spends multiple real tickets, needs the user's go-ahead first). **This DID subsequently run live via cron and surfaced a real hazard (2026-07-17)**: the log showed "battle skip already on" for all 5 fights and a clean "fought 5 battle(s) this run", but the account was left showing a real "Battle Complete" screen with a ~3-minute combat timer -- battles had run in full, not skipped -- and every task for the rest of that `daily` invocation failed to open its own screen. Two compounding bugs: (1) `_ensure_skip_on`'s "on" detection was never actually verified to reject a real "off" state (`ARENA_SKIP_ON_RGB` was only ever confirmed against a session where skip happened to already be on); (2) `_wait_for_result` had no early-exit signal and always ran its full fixed 15s budget before unconditionally declaring success -- far too short for a real battle -- and `_fight_one` discarded `_wait_for_result`'s return value entirely, so even a correctly detected failure never stopped the fight loop. Fixed by widening `RESULT_MODAL_MAX_POLLS` substantially (tolerating a real battle's full duration regardless of whether the skip-mode detection gets fully root-caused) plus a new `_back_on_challenge_list` early exit (so the normal fast case doesn't slow down), and by making `_fight_one` actually stop on a `_wait_for_result` failure. The skip-toggle's own "off" detection was not independently re-verified (would need a real ticket at a moment the account had none left) -- deliberately structured so the fix is safe even if that root cause isn't fully resolved. | | Common Shop | `module/shop/common_shop.py`, `module/shop/shop_utils.py` | `implement`, `to_common_shop`, `get_item_position`/`ensure_choose`/`buy` (shared, see Tactical Shop row) | `ba_auto/tasks/shop_common.py`, `ba_auto/tasks/shop_utils.py` | `get_item_position`'s color+template item-state scan → fixed grid-position targets (`config.COMMON_SHOP_TARGETS`) + price-digit OCR verify, since the reference's own item-identification here indexes an external static price table (`self.static_config.common_shop_price_list`, fetched from a remote resource) this repo doesn't have — not per-item OCR, so this isn't an OCR-avoidance shortcut. Purchase-confirm dialog + reward-acquired banner handled via a single overlay-darkness probe (`config.SHOP_OVERLAY_PROBE`) instead of tracking each dialog's own layout | Done. Live-tested with real purchases (all 8 configured targets bought, cost matched exactly). Discovered live: these items have a per-refresh-cycle purchase cap not shown as a visible counter (unlike the 青輝石 tab's "あと1回購入可能" labels) — confirmed by re-running the task after purchase and observing it correctly detect the now-unselectable items (checkbox + individual 購入 button both unresponsive) and safely decline rather than guess. A fresh, everything-available run hasn't been re-verified since the account had already exhausted this cycle's purchases via that same test | | Tactical Shop | `module/shop/tactical_challenge_shop.py`, `module/shop/shop_utils.py` | `implement`, `goto_shop_by_name`, shared `get_item_position`/`ensure_choose`/`buy` | `ba_auto/tasks/shop_tactical.py`, `ba_auto/tasks/shop_utils.py` | `goto_shop_by_name`'s OCR swipe-search over the shop-tab list → fixed click (`config.SHOP_TAB_TACTICAL`): this account's tab list is only 7 entries and fits on screen with no scroll needed, confirmed live, so there's nothing to search for — not an OCR-avoidance shortcut. Same grid-position + price-OCR-verify + overlay-probe design as Common Shop, sharing `shop_utils.run_shop_tab` | Done. Live-tested with real purchases (both configured AP-recovery drinks bought; AP and tactical-coin balance changes matched exactly) | | Lesson/Schedule | `module/lesson.py` | `implement`, `to_lesson_location_select`/`to_select_location`/`to_all_locations` (nav state machine), `get_lesson_region_num`/`switch_lesson_region_page`/`to_lesson_region` (paged region nav), `get_lesson_each_region_status`+`check_region_availability` (per-cell status via isometric-parallelogram pixel scan), `get_lesson_relationship_counts` (per-cell affection pip count via color count), `choose_lesson` (selection policy), `execute_lesson`/`to_location_info`/`start_lesson` (click cell -> info panel -> start -> result) | `ba_auto/tasks/lesson.py` | `picture.co_detect` -> `navigation.wait_for_state`-style bounded Enter-press loop (see below); the reference's paged-arrow region nav (needing OCR to know current position) -> this client renders the 12 regions as a scrollable list instead, which only ever settles at two scroll positions (`config.LESSON_REGION_ROW_Y`), so navigation is direct index-based clicking with nothing to OCR-locate; the reference's isometric `Parallelogram`/`Triangle` per-cell scan (tuned to the reference's own screen layout) -> reading each portrait's heart-shaped affection badge via a dedicated OCR path (`detector.read_int_on_heart_badge`) needs no isometric geometry at all | Done. Config-driven scope only in the sense of the *policy* (affection-first selection, sweep every unlocked region until tickets/lessons run out, no ticket purchasing, no favor-student targeting) -- unlike shop, no user-specific target list was needed since the reference's own `lesson_region_name.JP` (embedded directly in its `default_config.py`, not externally fetched) already names all 12 regions, used here only for logging. Live-tested for real: 5 real tickets spent across 3 regions with correct outcomes (ticket count, cleanup navigation, home-screen return all verified). Two real bugs were found and fixed from that run -- see below and `plan.md`'s Lesson phase. **Regression fix (Phase 12 follow-up)**: `LESSON_TICKET_OCR_RECT`'s left edge clipped in a stray katakana fragment next to the first digit, making tesseract drop the whole leading digit (`"7/7"` -> `"/7"`) and aborting every run outright; fixed by tightening the rect, re-validated with a full real run (7 tickets spent, correct count re-read after every schedule, clean stop and home-screen return). **Regression fix (Phase 12 follow-up #2)**: clicking the schedule icon doesn't always land on the Location Select list -- the game can resume directly on whichever region's per-region isometric map was last open (confirmed live: a previous run's Ctrl-C interruption left it stuck there, breaking `_open_region_grid` for every region in the next run identically). Fixed via a new `lesson._ensure_location_select_list` recovery check (detects the per-region map's own "すべてのスケジュール" button already showing before any row's been clicked, and returns via the back button if so); validated by deliberately reproducing the stuck state and confirming a real run recovered, spent the account's real remaining ticket, and finished cleanly. **Selection-policy rewrite (Phase 12 follow-up #3, 2026-07-13)**: per explicit user direction, replaced "always pick the single highest affection value" with a tiered min/max-farming priority -- any 3-student cell anywhere first, then any 2-student cell anywhere, then single-student cells sorted lowest-affection-first. This needs the whole board's state before deciding, not just the current region's, so the flow is now scan-all-then-execute (`_scan_all_regions`/`_build_priority_queue`/`_run_queue`) rather than the old per-region sweep-and-pick-best loop (`_find_best_cell`/`_sweep_region`, both removed). Verified offline against a synthetic board (correct tier ordering) and live against the real board via a zero-cost scan-only call (69 real schedulable cells found across 12 regions -- 7 triples, 30 doubles, 32 singles -- correctly bucketed and the singles tail exactly ascending by real affection value), with zero tickets spent since the account was fully out that day. Real ticket-spending execution (`_run_queue` actually running schedules) is not yet live-tested -- deferred to the user once tickets regenerate. | | Hard story AP sweep | `module/explore_tasks/sweep_task.py`, `module/explore_tasks/task_utils.py` | `sweep_hard_task` (main flow, 20 AP/hit, 3x cap), `to_hard_event`/`to_region` (nav), `start_sweep`'s named-outcome contract (shared with story_sweep.py's, ported the same way via `navigation.wait_for_state`) | `ba_auto/tasks/story_sweep_hard.py` | Reference's `unfinished_hard_tasks` persisted config list -> a fixed, user-supplied priority-ordered list (`config.HARD_STORY_SWEEP_TARGETS`: 18-3, 30-3, 27-3, 28-3, 17-3, 13-3, 23-2, 16-1, 20-3, 17-2, 15-2, 14-3, 10-3, 14-2, 7-1, 5-3, 4-1), swept in that fixed order every run rather than popped/persisted across runs; each target uses MAX (capped at 3x by the game itself), matching `config.STORY_SWEEP_TARGETS`'s own MAX convention. Reuses story_sweep.py's region-nav/stage-row-OCR/modal/result-watching machinery where the Hard screen's layout matches (region browser, stage list, sweep modal) rather than re-deriving it. New: a campaign-active guard with no reference equivalent at all (not present anywhere in baas-reference) -- per explicit user direction, OCRs for the pink "キャンペーン中" text (confirmed live on both the home screen and the Hard region browser) before spending any AP, skipping the whole run if no campaign is active unless an explicit override is passed | Implemented, not yet live-tested. Live investigation on nik-gpu (2026-07-20) confirmed: the Hard tab (`config.HARD_TAB`) sits atop the same region-browser screen story_sweep.py already opens, and stays selected across region navigation (no re-click needed between regions, unlike the reference's own per-move to_hard_event re-assertion); Hard's stage list always shows exactly 3 fixed rows (missions 1-3, no "-A" bonus, no scrolling -- `config.HARD_STAGE_ROWS_Y`, replacing story_sweep's scroll+OCR row search entirely); the stage-info modal is pixel-identical to story_sweep's own (all of `SWEEP_MAX_BUTTON`/`SWEEP_START_BUTTON`/`STAGE_MODAL_CLOSE_BUTTON`/confirm-dialog constants reused directly, confirmed via a real zero-AP-spent dry run: opened the confirm dialog, screenshotted it, cancelled). The in-panel "キャンペーン中" campaign banner (`config.HARD_CAMPAIGN_BADGE_RECT`) was confirmed live on two different regions (30 and 29) with identical text ("任務Hardで獲得できる報酬量が2倍(+100%)になっています"), read as account-wide rather than per-region -- chosen over the home screen's own ambiguous "キャンペーン中" badge (which can point at unrelated concurrent campaigns) as the actual gate signal. **Also confirmed live**: this modal has the exact same two-stacked-action-button hazard bounty.py hit for real (cyan 掃討開始 "start sweep" directly above gold 任務開始 "start mission", a real manual battle, both showing an identical AP-cost preview) -- ported bounty.py's own `_confirm_dialog_is_sweep` OCR text-gate proactively here rather than waiting to discover the same hazard live a second time. **Live-tested for real across three rounds (2026-07-20/21) -- see `plan.md` Phase 21 for the full writeup.** Multiple genuine Hard sweeps confirmed for real (H18-3/H30-3 both fully exhausted their daily 3/3 clears; six more targets swept for real in the final run). Five real bugs found and fixed: (1) a navigation-cascade bug where every exit path's leftover "if on subscreen, press Escape" cleanup backed out of the region browser entirely after every single target, breaking all subsequent targets -- fixed via a new `_ensure_hard_screen` self-heal check per loop iteration, replacing the removed Escape presses; (2) a false-negative MAX-click verification that wrongly aborted 11 real, affordable targets once AP dropped below 40 (MAX legitimately caps at 1x there, indistinguishable from "click failed") -- fixed by dropping the verification gate and trusting the downstream confirm-dialog checks instead; (3) a genuinely new real-money hazard found live: a stage already at 0/3 remaining today still shows a clickable 入場 button, and sweeping it raises a real "spend 40 gems to refill today's clears?" dialog -- fixed with a two-layer guard (`_remaining_sweeps_today`'s pre-check OCR on the modal's own count field, `_is_challenge_count_alert`'s OCR fallback on the dialog text, both declining via Escape, never a positional click; deliberately reproduced twice with zero gems spent, confirmed by screenshot); (4) a cosmetic `"unrecognized_state"` misreport on every real sweep, root-caused to the same terminal-state gap event_sweep.py hit historically (post-sweep flow can return to the bare region browser instead of leaving the modal open) -- fixed by porting event_sweep.py's own `clicked_any`-gated second `ends` condition; (5) a still-not-fully-root-caused region-navigation flakiness (30->27 failed identically across two runs) mitigated with a short OCR-read retry (`REGION_READ_RETRIES`), not yet independently re-confirmed live since account AP was exhausted (down to 6) by that point in the session. | Do not implement a feature without filling at least the relevant row.