"""Lesson/Schedule. Reference: baas-reference/module/lesson.py. Scoped for v1 per plan.md's "Suggested first version" and explicit user direction: affection-first selection (mirrors the reference's lesson_relationship_first=True), sweep every unlocked region in a fixed order until either lesson tickets or scoreable lessons 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 _find_best_cell(driver, config): best_score, best_cell = -1, None for row in range(GRID_ROWS): for col in range(GRID_COLS): cell_score = -1 for slot in range(GRID_SLOTS): value = _read_slot_affection(driver, config, row, col, slot) if value is not None and value > cell_score: cell_score = value if cell_score > best_score: best_score, best_cell = cell_score, (row, col) return best_cell, best_score 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 _sweep_region(driver, config, region_index, remaining_tickets): 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}, skipping") return remaining_tickets while remaining_tickets > 0: cell, score = _find_best_cell(driver, config) if cell is None: print(f"[lesson] no schedulable lesson found in {name}") break row, col = cell print(f"[lesson] {name}: best available affection {score} at 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} -- stopping this region") break new_count = _read_ticket_count(driver, config) remaining_tickets = new_count if new_count is not None else remaining_tickets - 1 print(f"[lesson] tickets remaining: {remaining_tickets}") _close_grid_modal(driver, config) driver.wait(0.5) driver.click(*config.LESSON_BACK_BUTTON) driver.wait(1.5) return remaining_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: for region_index in range(TOTAL_REGIONS): if tickets <= 0: print("[lesson] out of lesson tickets -- stopping") break tickets = _sweep_region(driver, config, region_index, tickets) driver.click(*config.LESSON_BACK_BUTTON) driver.wait(1.5) print("[lesson] Done.")