412 lines
19 KiB
Python
412 lines
19 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).
|
|
|
|
Uses navigation.click_back (not a bare driver.click) since this is a
|
|
BACK_BUTTON press like any other -- see that helper's docstring for why
|
|
plain unverified back-clicks in this module were a real gap (an
|
|
XIGNCODE overlay incident during a bounty run, 2026-07-12, found this
|
|
exact click had no retry or overlay defense, unlike return_to_home).
|
|
"""
|
|
if not _is_action_button_showing(driver, config, config.LESSON_ALL_SCHEDULES_BUTTON):
|
|
return True
|
|
print("[lesson] schedule screen resumed on a specific region's map instead of the Location Select list -- returning")
|
|
return navigation.click_back(
|
|
driver, lambda d: not _is_action_button_showing(d, config, config.LESSON_ALL_SCHEDULES_BUTTON),
|
|
max_attempts=OPEN_RETRIES,
|
|
)
|
|
|
|
|
|
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, image=None):
|
|
lo, hi = config.LESSON_GRID_CHECKMARK_RGB
|
|
return detector.region_contains_color(_checkmark_rect(config, row, col, slot), lo, hi, image=image)
|
|
|
|
|
|
def _read_slot_affection(driver, config, row, col, slot, image=None):
|
|
if _is_slot_already_done(driver, config, row, col, slot, image=image):
|
|
return None
|
|
value = detector.read_int_on_heart_badge(_badge_rect(config, row, col, slot), image=image)
|
|
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.
|
|
|
|
Reads all 27 (row, col, slot) checkmark/badge probes off ONE captured
|
|
frame instead of one scrot capture per probe -- this is a pure read
|
|
with no clicks in between (see _scan_all_regions's own docstring), so
|
|
nothing on screen changes across the loop; unbatched, this was firing
|
|
up to ~50 screenshot captures for a single region (see plan.md's
|
|
"Performance improvement plan").
|
|
"""
|
|
image = detector.capture_screen()
|
|
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, image=image)
|
|
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):
|
|
# Called up to 12x per run (once per region during the scan, again
|
|
# during execution) -- previously a single unverified driver.click, the
|
|
# highest-risk gap the XIGNCODE-overlay audit found (2026-07-12): a
|
|
# silently-missed click here leaves the next region's _open_region_grid
|
|
# call starting from the wrong screen, which then fails its own
|
|
# retries too and silently skips that region. navigation.click_back
|
|
# adds the retry + overlay-recovery escalation this always needed.
|
|
_close_grid_modal(driver, config)
|
|
driver.wait(0.5)
|
|
if not navigation.click_back(
|
|
driver, lambda d: not _is_action_button_showing(d, config, config.LESSON_ALL_SCHEDULES_BUTTON),
|
|
):
|
|
print("[lesson] warning: could not confirm return to the Location Select list after closing the region grid")
|
|
|
|
|
|
def _scan_all_regions(driver, config, tickets):
|
|
"""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.
|
|
|
|
Stops scanning early once enough triples (3-available cells) have been
|
|
found to cover every available ticket. _build_priority_queue always
|
|
runs triples first, in scan order, with no further sort among them, and
|
|
_run_queue stops the instant tickets hit 0 -- so once triple_count >=
|
|
tickets, any cell in a not-yet-scanned region can only ever land AFTER
|
|
enough triples to already exhaust the ticket budget, and _run_queue
|
|
would never reach it. The queue actually executed is therefore
|
|
identical to what a full scan would produce; the only difference is
|
|
fewer regions get looked at when there's no way that data could change
|
|
the outcome. See plan.md's "Performance improvement plan" for the log
|
|
analysis this was built from.
|
|
"""
|
|
all_cells = []
|
|
triple_count = 0
|
|
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))
|
|
if count == 3:
|
|
triple_count += 1
|
|
_close_region_grid(driver, config)
|
|
if triple_count >= tickets:
|
|
print(f"[lesson] found {triple_count} triple(s), enough to cover all {tickets} ticket(s) -- stopping scan early")
|
|
break
|
|
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, tickets)
|
|
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)
|
|
|
|
# navigation.return_to_home over a bare click here too -- final cleanup
|
|
# click, lower-risk than the mid-run ones above since ba_daily.py's own
|
|
# wrapper calls return_to_home again regardless, but no reason not to
|
|
# use the verified+overlay-safe path directly instead of an unverified
|
|
# click first.
|
|
if not navigation.return_to_home(driver):
|
|
print("[lesson] warning: could not confirm return to home screen")
|
|
print("[lesson] Done.")
|