- Added `lesson.py` to handle the scheduling of lessons based on affection values. - Integrated OCR functionality to read affection counts from heart badges. - Updated `README.md` to include the new lesson task in the task status section. - Enhanced `config.py` with necessary configurations for the lesson task. - Modified `detector.py` to include a new function for reading heart badge values accurately. - Updated `mapping.md` to reflect the new lesson task implementation. - Adjusted `ba_daily.py` to include the lesson task in the command dispatch. - Updated `plan.md` to document the completion of the lesson task and its testing outcomes. - Modified `setup.sh` to include the lesson task in the run command instructions.
248 lines
10 KiB
Python
248 lines
10 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 _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 True
|
|
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.")
|