31 KiB

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_rangedriver.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_rangedriver.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 R<G, badge-pink pixels sampled R>G, 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.
Stamina/AP module/collect_daily_free_power.py, module/collect_daily_task_power.py to_tasks/implement (task-power, ported); to_purchase_pyroxenes_menu (free-power, not ported) ba_auto/tasks/stamina.py color.rgb_in_rangedriver.color_at; reference's per-tab claim loop → live UI's single "一括受取" bulk-claim button + Enter Partially migrated: Mission-panel claim done (see plan.md Phase 8). Daily Free Power (real-money purchase menu) deliberately not automated
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; MAX click verified via SWEEP_MINUS_BUTTON_PROBE color check (reused, still correct), 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). Config-driven exact (region, stage, count) targets (config.STORY_SWEEP_TARGETS), not latest-region/random-stage. Opt-in only, not in default flow
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.
Group/Club AP module/group.py Need to inspect ba_auto/tasks/group.py fixed click + state check via local driver Not started
Bounty module/rewarded_task.py implement (main flow, get_task_count/purchase_bounty_ticket/per-area rewarded_task_status loop, not ported -- see Status), to_bounty/to_choose_bounty (nav), get_los/one_detect/bounty_common_operation (per-row SSS-color scan + sweep) ba_auto/tasks/bounty.py to_bounty's bottom-nav "bus" icon -> this client's Work-hub 指名手配 card (config.BOUNTY_CARD), landing directly on Location Select with no separate bus-icon sub-navigation step; get_los/one_detect's per-row color.check_sweep_availability SSS-color scan across however many rows are visible -> a fixed bottom-most row click (config.BOUNTY_LATEST_STAGE_ROW_Y), since this client's 3 areas each have exactly 10 stages (confirmed live, scrolling past the 10th is a no-op) all already SSS-cleared, making "scroll to the bottom extreme, click the last row" equivalent to "the latest stage available" by construction; the reference's config-string per-area sweep-count list (rewarded_task_times, get_task_count) and its loop across all 3 areas -> a single date-ordinal-modulo rotation choosing ONE area per run, mirroring story_sweep.py/event_sweep.py's own rotation, per explicit user direction (2026-07-11); purchase_bounty_ticket (buying more tickets with real currency) -> not ported, matching plan.md's "OCR optional for coin balance/refresh logic, can skip for first version" Done, live-tested for real -- see plan.md Phase 15. Live-calibrated against nik-gpu 2026-07-11 with zero real tickets spent during calibration (every ticket-usage confirm dialog reached was cancelled via Escape, verified by the ticket counter (6/6) unchanged before/after, across all 3 areas). All 3 areas (ハイウェイ/砂漠の線路/校舎, matching the reference's OVERPASS/DESSERT RAILWAY/CLASSROOM groupings) confirmed to share an identical layout: same area-row/stage-row/modal-button coordinates, same 10-stage-per-area structure. The 任務情報 modal's MAX stepper, minus-button raised-color check, and ticket-usage confirm dialog are pixel-identical to event_sweep.py's own stage modal / shared SWEEP_CONFIRM_* dialog -- confirmed live -- and reused directly. The modal-open probe could NOT be reused from EVENT_STAGE_MODAL_PROBE: that corner point reads dark on this screen regardless of modal state (different background art), so a fresh BOUNTY_STAGE_MODAL_PROBE was calibrated that does discriminate both states. 2 real sweeps confirmed live (credits +180,000 then +36,000, clean automatic return home both times), which surfaced and fixed two real bugs: (1) _set_sweep_count's count==1 path wrongly assumed the modal defaults to count=1 on open -- it actually remembers the last-used count -- causing an unintended 5-ticket spend instead of the intended 1; fixed via a new _click_min_and_verify that always forces a known baseline first. (2) BOUNTY_SWEEP_RESULT_BUTTON_REGION's first-guess copy of event_sweep's own region overlapped BOUNTY_SWEEP_START_BUTTON's real cyan pixels, causing _find_result_button to re-click it once tickets hit 0 -- which surfaced a real Pyroxene ticket-purchase prompt (no gem actually spent, confirmed by an unchanged balance, but a real near-miss). Fixed three ways: the region was corrected to the real pixel-scanned OK-button bbox; a new min_pixels parameter on detector.find_color_centroid (plus config.BOUNTY_RESULT_BUTTON_MIN_PIXELS) filters out a second, smaller contamination source (stray cyan-range pixels in the modal's own reward-icon artwork); and _watch_sweep_result now has an explicit _is_ticket_purchase_prompt guard as a named ends condition. Not yet re-confirmed live: a fresh sweep with the fixes deployed (account at 0/6 tickets as of session end) and any bulk/MAX-count sweep's result-screen flow (only count=1 was ever tested; a bulk sweep may show a SKIP-then-OK sequence like story_sweep/event_sweep instead of the single-OK dialog confirmed here).
Commissions module/clear_special_task_power.py Need to inspect ba_auto/tasks/commission.py sweep/color adaptation Not started
Arena module/arena.py implement (main flow), to_tactical_challenge (nav from main page), get_tickets (ticket-count OCR), choose_enemy (self/opponent level OCR + bounded refresh-reroll loop), check_skip_button (skip-toggle color probe), fight (click fight, wait for win/lose), collect_tactical_challenge_reward (two reward-slot color probes) ba_auto/tasks/arena.py Ticket count/self level/opponent level/rank are plain digit OCR (detector.read_int/read_text, plus a new read_int_white_on_dark for the profile card's bright-on-dark level text). Skip-toggle state and the two reward-slot claimed-vs-claimable colors are plain pixel-color probes (driver.color_at/_color_in_range). The post-fight WIN/LOSE result modal and an unrelated list-refresh-expired notice are NOT detected by precisely locating a button (a color-region search proved unreliable — see Status); they're dismissed via a bounded blind-Enter-press loop matching lesson.py's own _run_one_schedule pattern, gated by a hard safety check against the one modal where Enter is dangerous (opponent-info's own attack-formation button, checked via its fixed-position gold button). choose_enemy's refresh-reroll loop is direct Python control flow, bounded by maxArenaRefreshTimes. Config knobs carried over from reference defaults: ArenaComponentNumber=1, ArenaLevelDiff=0, maxArenaRefreshTimes=10, ArenaStopFightWhenRank1=False Done. Live-tested for real across all 5 of the account's daily tickets (2 WIN, 1 LOSE, 2 spent debugging the result-modal detection — see plan.md Phase 13 for the full writeup). Real navigation differences confirmed live: Tactical Challenge is a Work-hub card, not a bottom-nav icon; the reference's separate opponent-info and formation-edit screens are merged into one modal here with a live ticket-preview; navigation.is_modal_open's shared probe reads inverted on this screen (own _is_modal_open via config.ARENA_MODAL_PROBE). Three real bugs fixed: a level-OCR crop too small for tesseract despite looking legible (fixed by widening the crop, not the pipeline); level text being bright-on-dark unlike every other OCR read in this project (fixed via read_int_white_on_dark); and the result-modal detection cycling through two failed color-based designs (fixed by switching to bounded blind-Enter dismissal with a hard safety gate — a real near-miss of the same "mistimed keypress" hazard class CLAUDE.md already documents from story_sweep). Deliberately opt-in only, never in DEFAULT_ORDER — unlike every other opt-in task so far (which spend a known-safe resource on a config-driven target list), this one fights a real ranked PvP battle that can win or lose and moves the account's actual arena rank. Per explicit user decision: fights exactly one battle per invocation, matching the reference's own per-call pacing (its next_time = 55 background-thread rescheduling has no equivalent in this project's one-shot CLI). Not yet exercised live: the "no ticket" mid-flow race, an actual reroll click (every opponent offered was already an acceptable level), and ArenaStopFightWhenRank1's rank-1 stop condition — all implemented per the reference's logic, just not yet hit by real game state. detector.find_template/template_visible (generalized named-template matcher, built during scaffolding) ended up unused — state detection stayed OCR/color-probe-driven throughout, like every other task in this project. Follow-up (2026-07-11): live bug report — this task never returned home at the end, so a second consecutive invocation starting from wherever the first left the game (the Tactical Challenge screen itself) sent _open_tactical_challenge's home-relative clicks to the wrong place and failed all 3 retries. Fixed by calling the shared navigation.return_to_home(driver) at the very start of run(), reusing the generic Escape-based recovery primitive built for event_sweep.py's own wrong-page recovery. Confirmed live (2026-07-11): reproduced the stuck-on-arena-screen scenario manually, then a real arena invocation recovered and completed a full fight normally (rank 14位's opponent-list entry moved 10位→9位, ticket 2→1, credits +1,080), with no repeat of the original failure. Follow-up #2 (2026-07-12), per explicit user request: reversed the original "exactly one battle per invocation" decision — _fight_one now holds the single-battle logic and run() loops it (while tickets > 0 and fights < config.ARENA_MAX_FIGHTS_PER_RUN), re-reading the OCR'd ticket count after each fight and waiting config.ARENA_POST_BATTLE_COOLDOWN (30s, per the user's own info about the real in-game lockout between fights) before continuing. ArenaStopFightWhenRank1 is now re-checked before every fight in the loop, not just once. See plan.md's Phase 13 follow-up #2 — not yet live-tested (spends multiple real tickets, needs the user's go-ahead first).
Common Shop module/shop/common_shop.py, module/shop/shop_utils.py implement, to_common_shop, get_item_position/ensure_choose/buy (shared, see Tactical Shop row) ba_auto/tasks/shop_common.py, ba_auto/tasks/shop_utils.py get_item_position's color+template item-state scan → fixed grid-position targets (config.COMMON_SHOP_TARGETS) + price-digit OCR verify, since the reference's own item-identification here indexes an external static price table (self.static_config.common_shop_price_list, fetched from a remote resource) this repo doesn't have — not per-item OCR, so this isn't an OCR-avoidance shortcut. Purchase-confirm dialog + reward-acquired banner handled via a single overlay-darkness probe (config.SHOP_OVERLAY_PROBE) instead of tracking each dialog's own layout Done. Live-tested with real purchases (all 8 configured targets bought, cost matched exactly). Discovered live: these items have a per-refresh-cycle purchase cap not shown as a visible counter (unlike the 青輝石 tab's "あと1回購入可能" labels) — confirmed by re-running the task after purchase and observing it correctly detect the now-unselectable items (checkbox + individual 購入 button both unresponsive) and safely decline rather than guess. A fresh, everything-available run hasn't been re-verified since the account had already exhausted this cycle's purchases via that same test
Tactical Shop module/shop/tactical_challenge_shop.py, module/shop/shop_utils.py implement, goto_shop_by_name, shared get_item_position/ensure_choose/buy ba_auto/tasks/shop_tactical.py, ba_auto/tasks/shop_utils.py goto_shop_by_name's OCR swipe-search over the shop-tab list → fixed click (config.SHOP_TAB_TACTICAL): this account's tab list is only 7 entries and fits on screen with no scroll needed, confirmed live, so there's nothing to search for — not an OCR-avoidance shortcut. Same grid-position + price-OCR-verify + overlay-probe design as Common Shop, sharing shop_utils.run_shop_tab Done. Live-tested with real purchases (both configured AP-recovery drinks bought; AP and tactical-coin balance changes matched exactly)
Lesson/Schedule module/lesson.py implement, to_lesson_location_select/to_select_location/to_all_locations (nav state machine), get_lesson_region_num/switch_lesson_region_page/to_lesson_region (paged region nav), get_lesson_each_region_status+check_region_availability (per-cell status via isometric-parallelogram pixel scan), get_lesson_relationship_counts (per-cell affection pip count via color count), choose_lesson (selection policy), execute_lesson/to_location_info/start_lesson (click cell -> info panel -> start -> result) ba_auto/tasks/lesson.py picture.co_detect -> navigation.wait_for_state-style bounded Enter-press loop (see below); the reference's paged-arrow region nav (needing OCR to know current position) -> this client renders the 12 regions as a scrollable list instead, which only ever settles at two scroll positions (config.LESSON_REGION_ROW_Y), so navigation is direct index-based clicking with nothing to OCR-locate; the reference's isometric Parallelogram/Triangle per-cell scan (tuned to the reference's own screen layout) -> reading each portrait's heart-shaped affection badge via a dedicated OCR path (detector.read_int_on_heart_badge) needs no isometric geometry at all Done. Config-driven scope only in the sense of the policy (affection-first selection, sweep every unlocked region until tickets/lessons run out, no ticket purchasing, no favor-student targeting) -- unlike shop, no user-specific target list was needed since the reference's own lesson_region_name.JP (embedded directly in its default_config.py, not externally fetched) already names all 12 regions, used here only for logging. Live-tested for real: 5 real tickets spent across 3 regions with correct outcomes (ticket count, cleanup navigation, home-screen return all verified). Two real bugs were found and fixed from that run -- see below and plan.md's Lesson phase. Regression fix (Phase 12 follow-up): LESSON_TICKET_OCR_RECT's left edge clipped in a stray katakana fragment next to the first digit, making tesseract drop the whole leading digit ("7/7" -> "/7") and aborting every run outright; fixed by tightening the rect, re-validated with a full real run (7 tickets spent, correct count re-read after every schedule, clean stop and home-screen return). Regression fix (Phase 12 follow-up #2): clicking the schedule icon doesn't always land on the Location Select list -- the game can resume directly on whichever region's per-region isometric map was last open (confirmed live: a previous run's Ctrl-C interruption left it stuck there, breaking _open_region_grid for every region in the next run identically). Fixed via a new lesson._ensure_location_select_list recovery check (detects the per-region map's own "すべてのスケジュール" button already showing before any row's been clicked, and returns via the back button if so); validated by deliberately reproducing the stuck state and confirming a real run recovered, spent the account's real remaining ticket, and finished cleanly. Selection-policy rewrite (Phase 12 follow-up #3, 2026-07-13): per explicit user direction, replaced "always pick the single highest affection value" with a tiered min/max-farming priority -- any 3-student cell anywhere first, then any 2-student cell anywhere, then single-student cells sorted lowest-affection-first. This needs the whole board's state before deciding, not just the current region's, so the flow is now scan-all-then-execute (_scan_all_regions/_build_priority_queue/_run_queue) rather than the old per-region sweep-and-pick-best loop (_find_best_cell/_sweep_region, both removed). Verified offline against a synthetic board (correct tier ordering) and live against the real board via a zero-cost scan-only call (69 real schedulable cells found across 12 regions -- 7 triples, 30 doubles, 32 singles -- correctly bucketed and the singles tail exactly ascending by real affection value), with zero tickets spent since the account was fully out that day. Real ticket-spending execution (_run_queue actually running schedules) is not yet live-tested -- deferred to the user once tickets regenerate.

Do not implement a feature without filling at least the relevant row.