Compare commits
No commits in common. "d266b47e5896ba6fc391f99add62034c1d368b4d" and "e0744fc799610645657bdeedf115f6bbae008265" have entirely different histories.
d266b47e58
...
e0744fc799
@ -188,18 +188,6 @@ CAFE_INVITE_HEART_X = 758
|
||||
# building a second OCR path for what's confirmed to be the same widget.
|
||||
CAFE_INVITE_HEART_OCR_HALF_SIZE = (40, 23)
|
||||
|
||||
# Name text OCR rect, offset from row_y -- pixel-calibrated live 2026-08-13
|
||||
# against a real open invite list (scratchpad/probe_invite_list.png, since
|
||||
# deleted): the name sits directly above the heart badge, right of the
|
||||
# portrait. (700, 1050) on X clears the portrait on the left and stays well
|
||||
# short of the 招待 button on the right; (-70, -15) on Y (offset from
|
||||
# row_y) was confirmed by direct crop inspection against 5 real rows of
|
||||
# varying name length (short "ミカ" through longer "ナギサ(水着)") with
|
||||
# no clipping and only a harmless sliver of the heart badge's own top edge
|
||||
# creeping into the very bottom of the crop.
|
||||
CAFE_INVITE_NAME_RECT_X = (700, 1050)
|
||||
CAFE_INVITE_NAME_RECT_Y_OFFSET = (-70, -15)
|
||||
|
||||
# The dialog raised by clicking a row's 招待 button. Live-confirmed 3
|
||||
# distinct cases, all sharing the exact same "通知"-style dialog component
|
||||
# this project already uses everywhere else -- SWEEP_CONFIRM_BUTTON/
|
||||
@ -408,14 +396,6 @@ SWEEP_RESULT_BUTTON_REGION = (700, 700, 1300, 1050)
|
||||
# feasibility floor.
|
||||
SWEEP_MIN_AP = 20
|
||||
|
||||
# exit_game alerts (via ba_cron_run.sh's AP_ALERT log-grep, see plan.md
|
||||
# Phase 30) if AP is still above this when the game closes -- catches AP
|
||||
# quietly capping out unspent between cron fires. A separate constant from
|
||||
# SWEEP_MIN_AP above even though the value happens to match: that one is a
|
||||
# feasibility floor for attempting a sweep, this one is a "you left AP on
|
||||
# the table" waste threshold, and they're free to diverge independently.
|
||||
EXIT_AP_ALERT_THRESHOLD = 20
|
||||
|
||||
# (region, stage, count) targets to sweep, mirroring the reference's
|
||||
# unfinished_normal_tasks shape (module/explore_tasks/sweep_task.py). `stage`
|
||||
# is 1-5, or the string "A" for the bonus stage that only exists when
|
||||
|
||||
@ -128,25 +128,16 @@ def template_visible(template_path, region=None, threshold=0.85):
|
||||
return find_template(template_path, region=region, threshold=threshold) is not None
|
||||
|
||||
|
||||
def capture_screen():
|
||||
"""One fresh, decoded screenshot -- for callers that need to read several
|
||||
regions off the same frame instead of paying for a separate scrot
|
||||
capture per region_contains_color()/read_int_on_heart_badge() call (see
|
||||
lesson.py's _scan_open_grid_cells, which reads up to 27 cells per region
|
||||
scan and, unbatched, was firing a fresh capture for nearly every one)."""
|
||||
return driver.read_screenshot(OCR_SHOT_PATH)
|
||||
|
||||
|
||||
def _color_mask(region, rgb_min, rgb_max, image=None):
|
||||
def _color_mask(region, rgb_min, rgb_max):
|
||||
x1, y1, x2, y2 = region
|
||||
img = image if image is not None else driver.read_screenshot(OCR_SHOT_PATH)
|
||||
img = driver.read_screenshot(OCR_SHOT_PATH)
|
||||
crop = img[y1:y2, x1:x2]
|
||||
b, g, r = crop[:, :, 0].astype(np.int16), crop[:, :, 1].astype(np.int16), crop[:, :, 2].astype(np.int16)
|
||||
(r_lo, g_lo, b_lo), (r_hi, g_hi, b_hi) = rgb_min, rgb_max
|
||||
return (r >= r_lo) & (r <= r_hi) & (g >= g_lo) & (g <= g_hi) & (b >= b_lo) & (b <= b_hi)
|
||||
|
||||
|
||||
def region_contains_color(region, rgb_min, rgb_max, image=None):
|
||||
def region_contains_color(region, rgb_min, rgb_max):
|
||||
"""Whether any pixel within `region` (x1, y1, x2, y2) falls in the given
|
||||
RGB range. Useful for presence checks on small, non-convex glyphs (e.g.
|
||||
an arrow chevron) where a single fixed-point probe can land in the
|
||||
@ -154,12 +145,8 @@ def region_contains_color(region, rgb_min, rgb_max, image=None):
|
||||
for story_sweep's region-arrow chevron fell squarely in the notch
|
||||
between its two strokes, reading as "absent" even while the arrow was
|
||||
clearly rendered a few pixels away. See plan.md Phase 10.
|
||||
|
||||
`image` optionally supplies an already-decoded frame (from
|
||||
capture_screen()) instead of taking a fresh screenshot -- for batched
|
||||
reads of several regions that are known not to change between them.
|
||||
"""
|
||||
return bool(_color_mask(region, rgb_min, rgb_max, image=image).any())
|
||||
return bool(_color_mask(region, rgb_min, rgb_max).any())
|
||||
|
||||
|
||||
def find_color_centroid(region, rgb_min, rgb_max, min_pixels=1):
|
||||
@ -282,7 +269,7 @@ def read_int_bordered(region, psm=7, border=20):
|
||||
return int(digits) if digits else None
|
||||
|
||||
|
||||
def read_int_on_heart_badge(region, psm=7, image=None):
|
||||
def read_int_on_heart_badge(region, psm=7):
|
||||
"""OCR a small dark-navy digit rendered on lesson.py's pink/magenta
|
||||
heart-shaped affection badge.
|
||||
|
||||
@ -296,13 +283,9 @@ def read_int_on_heart_badge(region, psm=7, image=None):
|
||||
sampled (fill and outline, light and dark) is R > G, so masking on that
|
||||
channel relationship instead of raw brightness cleanly drops the badge
|
||||
shape and keeps just the glyph.
|
||||
|
||||
`image` optionally supplies an already-decoded frame (from
|
||||
capture_screen()) instead of taking a fresh screenshot -- see
|
||||
region_contains_color's own `image` param for why.
|
||||
"""
|
||||
x1, y1, x2, y2 = region
|
||||
img = image if image is not None else driver.read_screenshot(OCR_SHOT_PATH)
|
||||
img = driver.read_screenshot(OCR_SHOT_PATH)
|
||||
crop = img[y1:y2, x1:x2]
|
||||
b, g, r = crop[:, :, 0].astype(np.int16), crop[:, :, 1].astype(np.int16), crop[:, :, 2].astype(np.int16)
|
||||
ink = (r < g) & (np.maximum(np.maximum(r, g), b) < 170)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -3,25 +3,18 @@
|
||||
Student invitation (招待券), added 2026-07-14 per explicit user direction,
|
||||
ports module/cafe_reward.py's invite_girl/invite_by_affection/
|
||||
checkConfirmInvite: invite a student into each room before farming it (a
|
||||
newly-invited student can be patted the same run), among the first 5
|
||||
visible in the MomoTalk list (matching the reference's own
|
||||
invite_by_affection bound -- no scrolling), and always skipping (never
|
||||
confirming) a candidate that would swap an already-seated student's
|
||||
costume or move one in from the other room. This directly ports the
|
||||
reference's own checkConfirmInvite behavior with its default config
|
||||
(cafe_reward_allow_exchange_student/cafe_reward_allow_duplicate_invite
|
||||
both False) -- there is no equivalent config in this project to make
|
||||
either configurable, so both are always disallowed. See config.py's
|
||||
"Cafe student invitation" section for the full live-calibration writeup,
|
||||
including all 3 real dialog variants this was confirmed against (zero
|
||||
real tickets spent during calibration).
|
||||
|
||||
Per explicit user direction (2026-08-13): room 1 invites the
|
||||
HIGHEST-affection candidate (the original, and still the default), room 2
|
||||
invites the LOWEST-affection one instead -- `_invite_student`'s
|
||||
`prefer_highest` flag controls this by sorting the MomoTalk list ascending
|
||||
rather than descending before the same row-walking logic runs (see
|
||||
`_ensure_invite_sort`).
|
||||
newly-invited student can be patted the same run), preferring the
|
||||
HIGHEST-affection candidate among the first 5 visible in the MomoTalk list
|
||||
(matching the reference's own invite_by_affection bound -- no scrolling),
|
||||
and always skipping (never confirming) a candidate that would swap an
|
||||
already-seated student's costume or move one in from the other room. This
|
||||
directly ports the reference's own checkConfirmInvite behavior with its
|
||||
default config (cafe_reward_allow_exchange_student/
|
||||
cafe_reward_allow_duplicate_invite both False) -- there is no equivalent
|
||||
config in this project to make either configurable, so both are always
|
||||
disallowed. See config.py's "Cafe student invitation" section for the full
|
||||
live-calibration writeup, including all 3 real dialog variants this was
|
||||
confirmed against (zero real tickets spent during calibration).
|
||||
|
||||
Confirmed live with real tickets spent, both rooms, same day: room 1
|
||||
correctly skipped one 衣装替え (costume-swap) candidate then invited row 1
|
||||
@ -331,27 +324,7 @@ def _read_invite_affection(driver, config, row_index):
|
||||
return detector.read_int_on_heart_badge(_invite_heart_rect(config, row_index))
|
||||
|
||||
|
||||
def _invite_name_rect(config, row_index):
|
||||
row_y = config.CAFE_INVITE_ROW_Y[row_index]
|
||||
x1, x2 = config.CAFE_INVITE_NAME_RECT_X
|
||||
y_top, y_bottom = config.CAFE_INVITE_NAME_RECT_Y_OFFSET
|
||||
return (x1, row_y + y_top, x2, row_y + y_bottom)
|
||||
|
||||
|
||||
def _read_invite_name(driver, config, row_index):
|
||||
# psm=7 (single line) badly garbled short 2-character names live --
|
||||
# "ミカ" read back as "' ーー、テテー", most likely because the
|
||||
# rect's generous fixed width (sized to fit the longest sampled name,
|
||||
# "ナギサ(水着)") leaves mostly blank space around a short one, which
|
||||
# psm=7 seems to misparse as further line content. psm=8 (single word)
|
||||
# read all 5 rows sampled live correctly, at the cost of a little stray
|
||||
# leading/trailing punctuation noise ("、ミカ (水着) 。", "-ミカー"),
|
||||
# which the strip() below cleans up.
|
||||
text = detector.read_text(_invite_name_rect(config, row_index), psm=8, lang="jpn")
|
||||
return text.strip(" 、。-ー'\"") or None
|
||||
|
||||
|
||||
def _ensure_invite_sort(driver, config, descending=True):
|
||||
def _ensure_invite_sort(driver, config):
|
||||
# Explicitly (re-)select 絆ランク as the sort field every run, rather
|
||||
# than trusting whatever a previous manual session left selected --
|
||||
# mirrors the reference's own explicit change_order_type step.
|
||||
@ -362,24 +335,18 @@ def _ensure_invite_sort(driver, config, descending=True):
|
||||
driver.click(*config.CAFE_INVITE_SORT_OK_BUTTON)
|
||||
driver.wait(1)
|
||||
|
||||
# Rather than reading the direction-toggle icon's own arrow glyph,
|
||||
# compare the top two rows' actual OCR'd affection values -- if they
|
||||
# disagree with the requested direction, one toggle click flips it.
|
||||
# Reuses the same OCR path already needed to evaluate candidates, and is
|
||||
# Per explicit user direction: highest affection first. Rather than
|
||||
# reading the direction-toggle icon's own arrow glyph, compare the top
|
||||
# two rows' actual OCR'd affection values -- if row 0 reads lower than
|
||||
# row 1, the list is sorted ascending and needs one toggle click. Reuses
|
||||
# the same OCR path already needed to evaluate candidates, and is
|
||||
# confirmed live in both directions (descending: 38,35; ascending: 1,2).
|
||||
top = _read_invite_affection(driver, config, 0)
|
||||
second = _read_invite_affection(driver, config, 1)
|
||||
if top is None or second is None:
|
||||
return
|
||||
currently_ascending = top < second
|
||||
if descending and currently_ascending:
|
||||
if top is not None and second is not None and top < second:
|
||||
print(f"[cafe] invite list sorted ascending ({top} < {second}) -- toggling to descending")
|
||||
driver.click(*config.CAFE_INVITE_SORT_DIRECTION_TOGGLE)
|
||||
driver.wait(1)
|
||||
elif not descending and not currently_ascending:
|
||||
print(f"[cafe] invite list sorted descending ({top} >= {second}) -- toggling to ascending")
|
||||
driver.click(*config.CAFE_INVITE_SORT_DIRECTION_TOGGLE)
|
||||
driver.wait(1)
|
||||
|
||||
|
||||
def _is_swap_or_move_warning(title):
|
||||
@ -388,57 +355,50 @@ def _is_swap_or_move_warning(title):
|
||||
|
||||
def _try_invite_row(driver, config, row_index):
|
||||
"""Click a row's 招待 button and resolve whatever dialog appears.
|
||||
Returns a (result, name) pair -- result is "invited" (confirmed a real
|
||||
invite), "skipped" (a swap/move warning was detected and cancelled
|
||||
without spending anything), or "no_dialog" (the click didn't seem to
|
||||
open anything -- treated as a miss, not a decision, so the caller stops
|
||||
rather than guess further); name is the row's OCR'd student name (or
|
||||
None if unreadable), read before the click since the list (and the
|
||||
name text with it) is gone once a dialog is open.
|
||||
Returns "invited" (confirmed a real invite), "skipped" (a swap/move
|
||||
warning was detected and cancelled without spending anything), or
|
||||
"no_dialog" (the click didn't seem to open anything -- treated as a
|
||||
miss, not a decision, so the caller stops rather than guess further).
|
||||
"""
|
||||
name = _read_invite_name(driver, config, row_index)
|
||||
row_y = config.CAFE_INVITE_ROW_Y[row_index]
|
||||
driver.click(config.CAFE_INVITE_BUTTON_X, row_y)
|
||||
driver.wait(1.5)
|
||||
if not navigation.is_modal_open(driver):
|
||||
print(f"[cafe] invite click on row {row_index} did not open a dialog")
|
||||
return "no_dialog", name
|
||||
return "no_dialog"
|
||||
|
||||
title = detector.read_text(config.CAFE_INVITE_DIALOG_TITLE_RECT, psm=6, lang="jpn")
|
||||
print(f"[cafe] row {row_index} ('{name}') invite dialog title: '{title}'")
|
||||
print(f"[cafe] row {row_index} invite dialog title: '{title}'")
|
||||
if _is_swap_or_move_warning(title):
|
||||
print(f"[cafe] row {row_index} would swap an already-seated student's costume or move one in from the other room -- skipping")
|
||||
driver.keypress("Escape")
|
||||
driver.wait(1.5)
|
||||
return "skipped", name
|
||||
return "skipped"
|
||||
|
||||
driver.click(*config.SWEEP_CONFIRM_BUTTON)
|
||||
driver.wait(2)
|
||||
return "invited", name
|
||||
return "invited"
|
||||
|
||||
|
||||
def _invite_student(driver, config, prefer_highest=True):
|
||||
"""Invite an available student into the current room, before farming
|
||||
it -- the highest-affection candidate when prefer_highest is True
|
||||
(room 1), the lowest when False (room 2, per explicit user direction
|
||||
2026-08-13). Tries up to len(CAFE_INVITE_ROW_Y) visible candidates
|
||||
(matching the reference's own invite_by_affection bound -- no
|
||||
scrolling) from whichever end of the affection order was requested,
|
||||
stopping at the first one that invites cleanly; skips (never confirms)
|
||||
any candidate that would swap an already-seated student's costume or
|
||||
move one in from the other room.
|
||||
def _invite_student(driver, config):
|
||||
"""Invite the highest-affection available student into the current
|
||||
room, before farming it. Tries up to len(CAFE_INVITE_ROW_Y) visible
|
||||
candidates (matching the reference's own invite_by_affection bound --
|
||||
no scrolling), stopping at the first one that invites cleanly; skips
|
||||
(never confirms) any candidate that would swap an already-seated
|
||||
student's costume or move one in from the other room.
|
||||
"""
|
||||
if not _open_invite_list(driver, config):
|
||||
print("[cafe] could not open the invitation ticket list -- no ticket available or a click missed, skipping invite")
|
||||
return
|
||||
|
||||
_ensure_invite_sort(driver, config, descending=prefer_highest)
|
||||
_ensure_invite_sort(driver, config)
|
||||
|
||||
invited = False
|
||||
for row_index in range(len(config.CAFE_INVITE_ROW_Y)):
|
||||
result, name = _try_invite_row(driver, config, row_index)
|
||||
result = _try_invite_row(driver, config, row_index)
|
||||
if result == "invited":
|
||||
print(f"[cafe] invited {name or f'row {row_index}'} (row {row_index})")
|
||||
print(f"[cafe] invited row {row_index}")
|
||||
invited = True
|
||||
break
|
||||
if result == "no_dialog":
|
||||
@ -463,8 +423,8 @@ def run(driver, config):
|
||||
print("[cafe] could not confirm cafe is open, aborting without pressing further keys")
|
||||
return
|
||||
|
||||
print("[cafe] room 1: inviting a student if available (highest affection)")
|
||||
_invite_student(driver, config, prefer_highest=True)
|
||||
print("[cafe] room 1: inviting a student if available")
|
||||
_invite_student(driver, config)
|
||||
|
||||
print("[cafe] room 1: farming affection")
|
||||
_pat_room(driver, config)
|
||||
@ -476,8 +436,8 @@ def run(driver, config):
|
||||
driver.wait(1.5)
|
||||
return
|
||||
|
||||
print("[cafe] room 2: inviting a student if available (lowest affection)")
|
||||
_invite_student(driver, config, prefer_highest=False)
|
||||
print("[cafe] room 2: inviting a student if available")
|
||||
_invite_student(driver, config)
|
||||
|
||||
print("[cafe] room 2: farming affection")
|
||||
_pat_room(driver, config)
|
||||
|
||||
@ -34,24 +34,6 @@ CLOSE_WAIT_ITERATIONS = 15
|
||||
CLOSE_WAIT_INTERVAL = 1
|
||||
|
||||
|
||||
def _alert_if_ap_left_unspent(driver, config):
|
||||
"""Print a distinctly-tagged, greppable log line if AP is still above
|
||||
config.EXIT_AP_ALERT_THRESHOLD right as the game is about to close --
|
||||
ba_cron_run.sh greps its own captured run output for the "AP_ALERT:"
|
||||
tag and fires an extra Discord alert on it (see plan.md Phase 30).
|
||||
Alert-only: never blocks or delays the exit itself.
|
||||
|
||||
Must run while still confirmed on true home (navigation.current_ap's
|
||||
OCR rect is only meaningful there, same precondition already documented
|
||||
on that function). An unreadable OCR result (None) is skipped silently,
|
||||
matching this project's "don't guess on unreadable OCR" convention
|
||||
rather than alerting on a possibly-wrong value.
|
||||
"""
|
||||
ap = navigation.current_ap(driver)
|
||||
if ap is not None and ap > config.EXIT_AP_ALERT_THRESHOLD:
|
||||
print(f"[exit_game] AP_ALERT: exiting with {ap} AP unspent (threshold {config.EXIT_AP_ALERT_THRESHOLD})")
|
||||
|
||||
|
||||
def _raise_exit_dialog(driver, config):
|
||||
for attempt in range(1, CONFIRM_RETRIES + 1):
|
||||
if attempt == CONFIRM_RETRIES and driver.window_exists():
|
||||
@ -82,8 +64,6 @@ def run(driver, config):
|
||||
print("[exit_game] could not confirm the true home screen -- aborting without pressing Escape")
|
||||
return
|
||||
|
||||
_alert_if_ap_left_unspent(driver, config)
|
||||
|
||||
if not _raise_exit_dialog(driver, config):
|
||||
print("[exit_game] could not confirm the exit-confirmation dialog opened -- aborting without pressing Enter")
|
||||
return
|
||||
|
||||
@ -177,15 +177,15 @@ def _checkmark_rect(config, row, col, slot):
|
||||
return (cx - hx, cy - hy, cx + hx, cy + hy)
|
||||
|
||||
|
||||
def _is_slot_already_done(driver, config, row, col, slot, image=None):
|
||||
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, image=image)
|
||||
return detector.region_contains_color(_checkmark_rect(config, row, col, slot), lo, hi)
|
||||
|
||||
|
||||
def _read_slot_affection(driver, config, row, col, slot, image=None):
|
||||
if _is_slot_already_done(driver, config, row, col, slot, image=image):
|
||||
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), image=image)
|
||||
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.
|
||||
@ -200,21 +200,13 @@ def _scan_open_grid_cells(driver, config):
|
||||
-- `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)
|
||||
value = _read_slot_affection(driver, config, row, col, slot)
|
||||
if value is not None:
|
||||
values.append(value)
|
||||
if values:
|
||||
@ -238,7 +230,7 @@ def _close_region_grid(driver, config):
|
||||
print("[lesson] warning: could not confirm return to the Location Select list after closing the region grid")
|
||||
|
||||
|
||||
def _scan_all_regions(driver, config, tickets):
|
||||
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
|
||||
@ -249,21 +241,8 @@ def _scan_all_regions(driver, config, tickets):
|
||||
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):
|
||||
@ -273,12 +252,7 @@ def _scan_all_regions(driver, config, tickets):
|
||||
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
|
||||
|
||||
|
||||
@ -393,7 +367,7 @@ def run(driver, config):
|
||||
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)
|
||||
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)
|
||||
|
||||
@ -470,22 +470,10 @@ def _wait_for_home(driver, config):
|
||||
|
||||
|
||||
def _wait_for_window(driver, config):
|
||||
# A window that satisfies the check below can still flicker away again
|
||||
# during game/Proton startup before it settles into the real, persistent
|
||||
# window -- confirmed live (2026-08-07, q4h cron traceback): the old
|
||||
# version returned True right after ONE window_exists() hit plus a fixed
|
||||
# 3s wait, but the window had disappeared again by the time the caller's
|
||||
# very next line (driver.focus_game()) ran its own independent check,
|
||||
# raising an uncaught RuntimeError and crashing the whole run. Re-check
|
||||
# window_exists() again after the stabilization wait, and keep polling
|
||||
# instead of trusting a stale True, so callers only ever get True for a
|
||||
# window confirmed present twice a few seconds apart.
|
||||
for _ in range(config.LOGIN_RELAUNCH_WAIT_ATTEMPTS):
|
||||
if driver.window_exists():
|
||||
driver.wait(3)
|
||||
if driver.window_exists():
|
||||
return True
|
||||
continue
|
||||
return True
|
||||
driver.wait(2)
|
||||
return False
|
||||
|
||||
@ -501,13 +489,6 @@ def _recover(driver, config):
|
||||
if not _wait_for_window(driver, config):
|
||||
print("[login] warning: game window did not reappear after relaunch")
|
||||
return False
|
||||
if not driver.window_exists():
|
||||
# Same flicker race as run() below, checked again right before the
|
||||
# call that would otherwise crash -- matches the established
|
||||
# window_exists()-then-focus_game() convention in navigation.py's
|
||||
# return_to_home/click_back.
|
||||
print("[login] warning: game window disappeared again right before focus -- giving up")
|
||||
return False
|
||||
driver.focus_game()
|
||||
return True
|
||||
|
||||
@ -519,9 +500,6 @@ def run(driver, config):
|
||||
if not _wait_for_window(driver, config):
|
||||
print("[login] warning: game window never appeared after launch -- giving up")
|
||||
return
|
||||
if not driver.window_exists():
|
||||
print("[login] warning: game window disappeared again right before focus -- giving up")
|
||||
return
|
||||
driver.focus_game()
|
||||
|
||||
for attempt in range(1, config.LOGIN_MAX_RELAUNCHES + 1):
|
||||
|
||||
@ -33,17 +33,6 @@
|
||||
# would just be noise. A missing, incomplete, or malformed .env silently
|
||||
# disables alerting only -- it can never affect the wrapped ba_dailies.sh
|
||||
# run or this script's own OK/FAILED/SKIPPED logging.
|
||||
#
|
||||
# Separately (see plan.md Phase 30): if the exit_game task ends the run
|
||||
# with AP still above config.EXIT_AP_ALERT_THRESHOLD, it prints a
|
||||
# distinctly-tagged "AP_ALERT:" line rather than posting to Discord itself
|
||||
# (Python has no .env/alert-bridge access by design -- see .env.example).
|
||||
# This script greps only the lines this run itself appended to LOG_FILE
|
||||
# (tracked via a before/after line count, not $(...) capture or `>()`
|
||||
# process substitution, so the log keeps streaming live and neither of
|
||||
# CLAUDE.md's Bash-safety triggers for those forms applies) and fires an
|
||||
# extra `error`-level alert (pings you) independent of the run's own
|
||||
# OK/FAILED outcome.
|
||||
set -uo pipefail
|
||||
|
||||
# Resolves its own directory (same pattern ba_dailies.sh itself already
|
||||
@ -101,9 +90,6 @@ alert() {
|
||||
start_epoch=$(date +%s)
|
||||
alert info "Starting $PRESET"
|
||||
|
||||
start_line=$(wc -l < "$LOG_FILE" 2>/dev/null)
|
||||
start_line="${start_line:-0}"
|
||||
|
||||
"$SCRIPT_DIR/ba_dailies.sh" "$PRESET"
|
||||
status=$?
|
||||
elapsed=$(( $(date +%s) - start_epoch ))
|
||||
@ -118,9 +104,4 @@ alert() {
|
||||
echo "=== $(date -Iseconds) finished $PRESET FAILED (exit $status) ==="
|
||||
alert error "$PRESET FAILED (exit $status) after $duration"
|
||||
fi
|
||||
|
||||
ap_alert_line=$(tail -n +"$((start_line + 1))" "$LOG_FILE" 2>/dev/null | grep -m1 "AP_ALERT:")
|
||||
if [ -n "$ap_alert_line" ]; then
|
||||
alert error "$PRESET: $ap_alert_line"
|
||||
fi
|
||||
} >> "$LOG_FILE" 2>&1
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user