277 lines
12 KiB
Python

"""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.")