"""Lesson/Schedule. Reference: baas-reference/module/lesson.py. Selection priority, per explicit user direction (2026-07-13), superseding the original v1 "always pick the single highest affection value" rule (lesson_relationship_first=True, ported unchanged from the reference) with a min/max-farming-focused tiered priority instead: 1. Any cell (location card) with all 3 student slots schedulable -- a single ticket raises 3 students' affection at once, so these are done first, in any region. 2. Once no 3-available cell remains anywhere, any cell with 2 schedulable slots. 3. Once no 2-or-3-available cell remains anywhere, single-slot cells -- lowest current affection value first, to catch up whichever student is furthest behind rather than keep maxing out whoever's already highest. This requires knowing the full board (every region's every cell) before deciding what to do next, not just the current region's -- see `_scan_all_regions`/`_build_priority_queue` below. Sweeps every unlocked region until either lesson tickets or queued cells run out; no lesson-ticket purchasing (same real-currency-adjacent caution as Daily Free Power / the shop's manual refresh button -- see CLAUDE.md), no favor-student targeting (deferred, matching plan.md). This client renders the reference's paged single-region view + isometric 3x3 status grid (get_lesson_each_region_status/get_lesson_relationship_counts, built on an isometric Parallelogram/Triangle pixel scan tuned to the reference's own screen layout) as a two-level UI instead: a scrollable list of the same 12 named regions (config.LESSON_REGION_NAMES, taken directly from the reference's own lesson_region_name.JP list -- used here only for logging, since unlike the reference's paged arrows this list's scroll position is deterministic and needs no OCR to locate), each opening a grid modal of up to 9 location cards. Each card shows up to 3 student portraits with a heart-shaped affection-count badge -- reading those via OCR (this project's local equivalent of the reference's pip-counting get_lesson_relationship_counts) needs no isometric geometry at all. A locked location isn't listed in the grid modal at all, so there's nothing to do there; a "no relationship yet" portrait has no badge and its OCR read comes back empty, scoring as a non-candidate. An "already done today" portrait was initially assumed to also blank its badge, but live testing showed that assumption was wrong: it keeps showing its (unchanged) number and gets a green checkmark added at top-right instead -- so done-ness is checked separately via that checkmark's color, not inferred from the OCR read. Reading the badge itself also needed its own OCR path (detector.read_int_on_heart_badge): a plain grayscale-threshold read (this project's usual approach) misreads it, because the heart's own outline stroke happens to survive the same threshold as the digit glyph and tesseract sometimes fuses the two into extra digits (see that function's docstring, and config.py's LESSON_GRID_BADGE_MAX_PLAUSIBLE for the plausibility-cap backstop this project keeps as a second line of defense). """ from ba_auto import detector, navigation OPEN_RETRIES = 3 REGIONS_PER_SCREEN = 6 TOTAL_REGIONS = 12 GRID_ROWS = 3 GRID_COLS = 3 GRID_SLOTS = 3 def _read_ticket_count(driver, config): text = detector.read_text(config.LESSON_TICKET_OCR_RECT, whitelist="0123456789/", psm=7) head = text.split("/")[0] if "/" in text else text digits = "".join(ch for ch in head if ch.isdigit()) return int(digits) if digits else None def _ensure_location_select_list(driver, config): """Recover to the Location Select list if the schedule icon resumed directly on a specific region's isometric map instead. Confirmed live: the game remembers the last-viewed region and reopens directly to its per-region map when the schedule icon is clicked again, rather than always landing on the Location Select list -- e.g. after a previous run was interrupted (Ctrl-C) mid-region. Every region-index-based click in this module assumes it's starting from the list, so a resumed per-region map silently breaks navigation for every region, not just the one that was open (confirmed live: this made `_open_region_grid` fail all 3 retries for every single region in the sweep, since the list's scroll/row-click coordinates don't do anything useful on that screen). Detected via the per-region map's own "すべてのスケジュール" button already being visible before any row here has been clicked -- that button only exists on the per-region screen, never on the list (confirmed by direct pixel sample: the same coordinate reads as plain dark background on the list screen). """ for attempt in range(1, OPEN_RETRIES + 1): if not _is_action_button_showing(driver, config, config.LESSON_ALL_SCHEDULES_BUTTON): return True print(f"[lesson] schedule screen resumed on a specific region's map instead of the Location Select list -- returning (attempt {attempt}/{OPEN_RETRIES})") driver.click(*config.LESSON_BACK_BUTTON) driver.wait(1.5) return not _is_action_button_showing(driver, config, config.LESSON_ALL_SCHEDULES_BUTTON) def _open_schedule_screen(driver, config): for attempt in range(1, OPEN_RETRIES + 1): driver.click(*config.LESSON_ICON) driver.wait(2) if navigation.is_on_subscreen(driver): return _ensure_location_select_list(driver, config) print(f"[lesson] schedule screen not detected after click (attempt {attempt}/{OPEN_RETRIES})") return False def _scroll_region_list(driver, config, to_bottom): x, y = config.LESSON_REGION_LIST_SCROLL_POINT direction = "down" if to_bottom else "up" driver.scroll(x, y, direction, config.LESSON_REGION_LIST_SCROLL_CLICKS) driver.wait(0.8) def _is_grid_idle(driver, config): lo, hi = config.LESSON_GRID_IDLE_RGB return detector.region_contains_color(config.LESSON_GRID_IDLE_PROBE_RECT, lo, hi) def _is_action_button_showing(driver, config, position): r, g, b = driver.color_at(*position) lo, hi = config.LESSON_ACTION_BUTTON_RGB return lo[0] <= r <= hi[0] and lo[1] <= g <= hi[1] and lo[2] <= b <= hi[2] def _open_region_grid(driver, config, region_index): # Retries the whole click-row -> click-all-schedules sequence from # scratch rather than trying to separately verify "did the isometric map # open" -- that screen shares the same bright header as the plain list # (navigation.is_on_subscreen can't tell them apart), so the grid modal # actually opening is the only reliable signal either step worked. screen_bottom = region_index >= REGIONS_PER_SCREEN row = region_index % REGIONS_PER_SCREEN for attempt in range(1, OPEN_RETRIES + 1): _scroll_region_list(driver, config, to_bottom=screen_bottom) driver.click(config.LESSON_REGION_ROW_X, config.LESSON_REGION_ROW_Y[row]) driver.wait(1.5) driver.click(*config.LESSON_ALL_SCHEDULES_BUTTON) driver.wait(1.2) if _is_grid_idle(driver, config): return True print(f"[lesson] schedule grid not detected for region index {region_index} (attempt {attempt}/{OPEN_RETRIES})") return False def _close_grid_modal(driver, config): driver.click(*config.LESSON_GRID_MODAL_CLOSE_BUTTON) driver.wait(1) def _slot_center(config, row, col, slot): cx = config.LESSON_GRID_COL_X[col] + slot * config.LESSON_GRID_PORTRAIT_STEP_X cy = config.LESSON_GRID_ROW_PORTRAIT_Y[row] return cx, cy def _badge_rect(config, row, col, slot): cx, cy = _slot_center(config, row, col, slot) ox, oy = config.LESSON_GRID_BADGE_OFFSET hx, hy = config.LESSON_GRID_BADGE_OCR_HALF_SIZE cx, cy = cx + ox, cy + oy return (cx - hx, cy - hy, cx + hx, cy + hy) def _checkmark_rect(config, row, col, slot): cx, cy = _slot_center(config, row, col, slot) ox, oy = config.LESSON_GRID_CHECKMARK_OFFSET hx, hy = config.LESSON_GRID_CHECKMARK_HALF_SIZE cx, cy = cx + ox, cy + oy return (cx - hx, cy - hy, cx + hx, cy + hy) def _is_slot_already_done(driver, config, row, col, slot): lo, hi = config.LESSON_GRID_CHECKMARK_RGB return detector.region_contains_color(_checkmark_rect(config, row, col, slot), lo, hi) def _read_slot_affection(driver, config, row, col, slot): if _is_slot_already_done(driver, config, row, col, slot): return None value = detector.read_int_on_heart_badge(_badge_rect(config, row, col, slot)) if value is not None and value > config.LESSON_GRID_BADGE_MAX_PLAUSIBLE: # Contamination from portrait art bleeding into the crop's edge, # not a real affection value -- see config.py's comment. return None return value def _scan_open_grid_cells(driver, config): """Scan the CURRENTLY OPEN grid modal's 9 cells. Returns a list of (row, col, available_count, values) for every cell with at least one schedulable (not already-done-today, has a relationship) student slot -- `values` is that cell's available slots' affection numbers, in slot order. Cells with zero schedulable slots (locked, or every student already done/absent) are omitted entirely. """ cells = [] for row in range(GRID_ROWS): for col in range(GRID_COLS): values = [] for slot in range(GRID_SLOTS): value = _read_slot_affection(driver, config, row, col, slot) if value is not None: values.append(value) if values: cells.append((row, col, len(values), values)) return cells def _close_region_grid(driver, config): _close_grid_modal(driver, config) driver.wait(0.5) driver.click(*config.LESSON_BACK_BUTTON) driver.wait(1.5) def _scan_all_regions(driver, config): """Open every region's grid once, record its schedulable cells, close it again -- a pure read, spends no tickets. Needed because the priority below (3-available cells anywhere > 2-available anywhere > lowest affection anywhere) requires knowing the whole board, not just whichever region a fixed sweep order would visit first. Returns a flat list of (region_index, row, col, available_count, values) across all regions that opened successfully -- a region whose grid can't be confirmed open is skipped (logged, not fatal), matching this project's existing "abort without pressing further keys" convention for a single step, not the whole run. """ all_cells = [] for region_index in range(TOTAL_REGIONS): name = config.LESSON_REGION_NAMES[region_index] if not _open_region_grid(driver, config, region_index): print(f"[lesson] could not confirm schedule grid opened for {name} during scan, skipping") continue cells = _scan_open_grid_cells(driver, config) print(f"[lesson] scanned {name}: {len(cells)} cell(s) with a schedulable student") for row, col, count, values in cells: all_cells.append((region_index, row, col, count, values)) _close_region_grid(driver, config) return all_cells def _build_priority_queue(all_cells): """Order scanned cells by the user's min/max priority (2026-07-13): triples first (any order), then doubles (any order), then singles sorted ascending by their one available student's affection value (the most-behind student goes first). Returns an ordered list of (region_index, row, col). """ triples = [c for c in all_cells if c[3] == 3] doubles = [c for c in all_cells if c[3] == 2] singles = sorted((c for c in all_cells if c[3] == 1), key=lambda c: c[4][0]) return [(region_index, row, col) for region_index, row, col, _count, _values in triples + doubles + singles] def _click_cell(driver, config, row, col): driver.click(config.LESSON_GRID_COL_X[col], config.LESSON_GRID_ROW_HEADER_Y[row]) driver.wait(1.2) def _run_one_schedule(driver, config, row, col): """Click a grid cell through to a completed schedule. Returns True once settled back at the idle grid modal, False if it never got there.""" _click_cell(driver, config, row, col) if not _is_action_button_showing(driver, config, config.LESSON_INFO_START_BUTTON): print("[lesson] info panel start button not detected, aborting this cell without pressing further keys") return False driver.click(*config.LESSON_INFO_START_BUTTON) driver.wait(1.5) # A variable number of intermediate screens can follow (a bond-rank-up # cutscene, a results modal, occasionally both twice under a "2x # schedule" campaign multiplier -- see config.py's comment). Neither # intermediate screen has a reliable fixed marker of its own except the # results modal's OK button, so anything that isn't "back at the idle # grid" or "OK button showing" gets a plain Enter press, bounded. for _ in range(config.LESSON_POST_SCHEDULE_MAX_ENTER_PRESSES): if _is_grid_idle(driver, config): return True if _is_action_button_showing(driver, config, config.LESSON_REPORT_OK_BUTTON): driver.click(*config.LESSON_REPORT_OK_BUTTON) else: driver.keypress("Return") driver.wait(1.5) return _is_grid_idle(driver, config) def _run_queue(driver, config, queue, tickets): """Execute a priority-ordered queue of (region_index, row, col) targets, one ticket per cell, stopping when tickets run out or the queue is exhausted. Only reopens a region's grid when the target's region differs from whichever is currently open, since consecutive queue entries are often in the same region (all of one region's triples tend to sort together, for instance). A cell that fails to run (_run_one_schedule returns False -- the info panel didn't show, or the schedule never settled back to the idle grid) forces a re-open of its region before the next queued cell, rather than assuming the grid is still in a good state; this mirrors the recovery every other click-then-verify step in this project already does after a miss. """ open_region = None for region_index, row, col in queue: if tickets <= 0: print("[lesson] out of lesson tickets -- stopping") break if region_index != open_region: if open_region is not None: _close_region_grid(driver, config) name = config.LESSON_REGION_NAMES[region_index] if not _open_region_grid(driver, config, region_index): print(f"[lesson] could not reopen {name} to run its queued schedule(s), skipping") open_region = None continue open_region = region_index name = config.LESSON_REGION_NAMES[region_index] print(f"[lesson] {name}: running queued cell row {row} col {col}") if not _run_one_schedule(driver, config, row, col): print(f"[lesson] warning: could not confirm return to the schedule grid after {name} row {row} col {col} -- will reopen before the next queued cell") open_region = None continue new_count = _read_ticket_count(driver, config) tickets = new_count if new_count is not None else tickets - 1 print(f"[lesson] tickets remaining: {tickets}") if open_region is not None: _close_region_grid(driver, config) return tickets def run(driver, config): driver.focus_game() if not _open_schedule_screen(driver, config): print("[lesson] could not confirm schedule screen is open, aborting without pressing further keys") return tickets = _read_ticket_count(driver, config) if tickets is None: print("[lesson] could not OCR ticket count, aborting without pressing further keys") return print(f"[lesson] starting tickets: {tickets}") if tickets <= 0: print("[lesson] no lesson tickets available, nothing to do") else: print("[lesson] scanning all regions for schedulable students") all_cells = _scan_all_regions(driver, config) queue = _build_priority_queue(all_cells) triple_count = sum(1 for c in all_cells if c[3] == 3) double_count = sum(1 for c in all_cells if c[3] == 2) single_count = sum(1 for c in all_cells if c[3] == 1) print(f"[lesson] priority queue: {triple_count} triple(s), {double_count} double(s), {single_count} single(s)") _run_queue(driver, config, queue, tickets) driver.click(*config.LESSON_BACK_BUTTON) driver.wait(1.5) print("[lesson] Done.")