Nik Afiq c5873f4e58 Refactor arena and lesson tasks for improved efficiency and reliability
- Updated arena.py to allow multiple battles per invocation, looping until tickets are exhausted or a rank-1 condition is met. Introduced _fight_one function for single battle logic and added cooldown handling between fights.
- Enhanced lesson.py to implement a tiered priority system for scheduling lessons based on student slots available, replacing the previous highest affection value selection. Introduced functions for scanning all regions and building a priority queue for lesson scheduling.
- Centralized return-to-home logic in ba_daily.py to ensure the game returns to the home screen before and after each task, improving robustness against navigation issues.
- Added retry mechanism for returning to home, allowing for transient navigation issues to be handled gracefully without aborting tasks.
2026-07-13 10:32:53 +09:00

366 lines
16 KiB
Python

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