Nik Afiq 51ad297f69 feat: Implement Lesson/Schedule task
- 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.
2026-07-08 23:45:18 +09:00

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