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.
|
# building a second OCR path for what's confirmed to be the same widget.
|
||||||
CAFE_INVITE_HEART_OCR_HALF_SIZE = (40, 23)
|
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
|
# The dialog raised by clicking a row's 招待 button. Live-confirmed 3
|
||||||
# distinct cases, all sharing the exact same "通知"-style dialog component
|
# distinct cases, all sharing the exact same "通知"-style dialog component
|
||||||
# this project already uses everywhere else -- SWEEP_CONFIRM_BUTTON/
|
# this project already uses everywhere else -- SWEEP_CONFIRM_BUTTON/
|
||||||
@ -408,14 +396,6 @@ SWEEP_RESULT_BUTTON_REGION = (700, 700, 1300, 1050)
|
|||||||
# feasibility floor.
|
# feasibility floor.
|
||||||
SWEEP_MIN_AP = 20
|
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
|
# (region, stage, count) targets to sweep, mirroring the reference's
|
||||||
# unfinished_normal_tasks shape (module/explore_tasks/sweep_task.py). `stage`
|
# 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
|
# 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
|
return find_template(template_path, region=region, threshold=threshold) is not None
|
||||||
|
|
||||||
|
|
||||||
def capture_screen():
|
def _color_mask(region, rgb_min, rgb_max):
|
||||||
"""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):
|
|
||||||
x1, y1, x2, y2 = region
|
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]
|
crop = img[y1:y2, x1:x2]
|
||||||
b, g, r = crop[:, :, 0].astype(np.int16), crop[:, :, 1].astype(np.int16), crop[:, :, 2].astype(np.int16)
|
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
|
(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)
|
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
|
"""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.
|
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
|
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
|
for story_sweep's region-arrow chevron fell squarely in the notch
|
||||||
between its two strokes, reading as "absent" even while the arrow was
|
between its two strokes, reading as "absent" even while the arrow was
|
||||||
clearly rendered a few pixels away. See plan.md Phase 10.
|
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):
|
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
|
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
|
"""OCR a small dark-navy digit rendered on lesson.py's pink/magenta
|
||||||
heart-shaped affection badge.
|
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
|
sampled (fill and outline, light and dark) is R > G, so masking on that
|
||||||
channel relationship instead of raw brightness cleanly drops the badge
|
channel relationship instead of raw brightness cleanly drops the badge
|
||||||
shape and keeps just the glyph.
|
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
|
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]
|
crop = img[y1:y2, x1:x2]
|
||||||
b, g, r = crop[:, :, 0].astype(np.int16), crop[:, :, 1].astype(np.int16), crop[:, :, 2].astype(np.int16)
|
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)
|
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,
|
Student invitation (招待券), added 2026-07-14 per explicit user direction,
|
||||||
ports module/cafe_reward.py's invite_girl/invite_by_affection/
|
ports module/cafe_reward.py's invite_girl/invite_by_affection/
|
||||||
checkConfirmInvite: invite a student into each room before farming it (a
|
checkConfirmInvite: invite a student into each room before farming it (a
|
||||||
newly-invited student can be patted the same run), among the first 5
|
newly-invited student can be patted the same run), preferring the
|
||||||
visible in the MomoTalk list (matching the reference's own
|
HIGHEST-affection candidate among the first 5 visible in the MomoTalk list
|
||||||
invite_by_affection bound -- no scrolling), and always skipping (never
|
(matching the reference's own invite_by_affection bound -- no scrolling),
|
||||||
confirming) a candidate that would swap an already-seated student's
|
and always skipping (never confirming) a candidate that would swap an
|
||||||
costume or move one in from the other room. This directly ports the
|
already-seated student's costume or move one in from the other room. This
|
||||||
reference's own checkConfirmInvite behavior with its default config
|
directly ports the reference's own checkConfirmInvite behavior with its
|
||||||
(cafe_reward_allow_exchange_student/cafe_reward_allow_duplicate_invite
|
default config (cafe_reward_allow_exchange_student/
|
||||||
both False) -- there is no equivalent config in this project to make
|
cafe_reward_allow_duplicate_invite both False) -- there is no equivalent
|
||||||
either configurable, so both are always disallowed. See config.py's
|
config in this project to make either configurable, so both are always
|
||||||
"Cafe student invitation" section for the full live-calibration writeup,
|
disallowed. See config.py's "Cafe student invitation" section for the full
|
||||||
including all 3 real dialog variants this was confirmed against (zero
|
live-calibration writeup, including all 3 real dialog variants this was
|
||||||
real tickets spent during calibration).
|
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`).
|
|
||||||
|
|
||||||
Confirmed live with real tickets spent, both rooms, same day: room 1
|
Confirmed live with real tickets spent, both rooms, same day: room 1
|
||||||
correctly skipped one 衣装替え (costume-swap) candidate then invited row 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))
|
return detector.read_int_on_heart_badge(_invite_heart_rect(config, row_index))
|
||||||
|
|
||||||
|
|
||||||
def _invite_name_rect(config, row_index):
|
def _ensure_invite_sort(driver, config):
|
||||||
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):
|
|
||||||
# Explicitly (re-)select 絆ランク as the sort field every run, rather
|
# Explicitly (re-)select 絆ランク as the sort field every run, rather
|
||||||
# than trusting whatever a previous manual session left selected --
|
# than trusting whatever a previous manual session left selected --
|
||||||
# mirrors the reference's own explicit change_order_type step.
|
# 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.click(*config.CAFE_INVITE_SORT_OK_BUTTON)
|
||||||
driver.wait(1)
|
driver.wait(1)
|
||||||
|
|
||||||
# Rather than reading the direction-toggle icon's own arrow glyph,
|
# Per explicit user direction: highest affection first. Rather than
|
||||||
# compare the top two rows' actual OCR'd affection values -- if they
|
# reading the direction-toggle icon's own arrow glyph, compare the top
|
||||||
# disagree with the requested direction, one toggle click flips it.
|
# two rows' actual OCR'd affection values -- if row 0 reads lower than
|
||||||
# Reuses the same OCR path already needed to evaluate candidates, and is
|
# 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).
|
# confirmed live in both directions (descending: 38,35; ascending: 1,2).
|
||||||
top = _read_invite_affection(driver, config, 0)
|
top = _read_invite_affection(driver, config, 0)
|
||||||
second = _read_invite_affection(driver, config, 1)
|
second = _read_invite_affection(driver, config, 1)
|
||||||
if top is None or second is None:
|
if top is not None and second is not None and top < second:
|
||||||
return
|
|
||||||
currently_ascending = top < second
|
|
||||||
if descending and currently_ascending:
|
|
||||||
print(f"[cafe] invite list sorted ascending ({top} < {second}) -- toggling to descending")
|
print(f"[cafe] invite list sorted ascending ({top} < {second}) -- toggling to descending")
|
||||||
driver.click(*config.CAFE_INVITE_SORT_DIRECTION_TOGGLE)
|
driver.click(*config.CAFE_INVITE_SORT_DIRECTION_TOGGLE)
|
||||||
driver.wait(1)
|
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):
|
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):
|
def _try_invite_row(driver, config, row_index):
|
||||||
"""Click a row's 招待 button and resolve whatever dialog appears.
|
"""Click a row's 招待 button and resolve whatever dialog appears.
|
||||||
Returns a (result, name) pair -- result is "invited" (confirmed a real
|
Returns "invited" (confirmed a real invite), "skipped" (a swap/move
|
||||||
invite), "skipped" (a swap/move warning was detected and cancelled
|
warning was detected and cancelled without spending anything), or
|
||||||
without spending anything), or "no_dialog" (the click didn't seem to
|
"no_dialog" (the click didn't seem to open anything -- treated as a
|
||||||
open anything -- treated as a miss, not a decision, so the caller stops
|
miss, not a decision, so the caller stops rather than guess further).
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
name = _read_invite_name(driver, config, row_index)
|
|
||||||
row_y = config.CAFE_INVITE_ROW_Y[row_index]
|
row_y = config.CAFE_INVITE_ROW_Y[row_index]
|
||||||
driver.click(config.CAFE_INVITE_BUTTON_X, row_y)
|
driver.click(config.CAFE_INVITE_BUTTON_X, row_y)
|
||||||
driver.wait(1.5)
|
driver.wait(1.5)
|
||||||
if not navigation.is_modal_open(driver):
|
if not navigation.is_modal_open(driver):
|
||||||
print(f"[cafe] invite click on row {row_index} did not open a dialog")
|
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")
|
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):
|
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")
|
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.keypress("Escape")
|
||||||
driver.wait(1.5)
|
driver.wait(1.5)
|
||||||
return "skipped", name
|
return "skipped"
|
||||||
|
|
||||||
driver.click(*config.SWEEP_CONFIRM_BUTTON)
|
driver.click(*config.SWEEP_CONFIRM_BUTTON)
|
||||||
driver.wait(2)
|
driver.wait(2)
|
||||||
return "invited", name
|
return "invited"
|
||||||
|
|
||||||
|
|
||||||
def _invite_student(driver, config, prefer_highest=True):
|
def _invite_student(driver, config):
|
||||||
"""Invite an available student into the current room, before farming
|
"""Invite the highest-affection available student into the current
|
||||||
it -- the highest-affection candidate when prefer_highest is True
|
room, before farming it. Tries up to len(CAFE_INVITE_ROW_Y) visible
|
||||||
(room 1), the lowest when False (room 2, per explicit user direction
|
candidates (matching the reference's own invite_by_affection bound --
|
||||||
2026-08-13). Tries up to len(CAFE_INVITE_ROW_Y) visible candidates
|
no scrolling), stopping at the first one that invites cleanly; skips
|
||||||
(matching the reference's own invite_by_affection bound -- no
|
(never confirms) any candidate that would swap an already-seated
|
||||||
scrolling) from whichever end of the affection order was requested,
|
student's costume or move one in from the other room.
|
||||||
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):
|
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")
|
print("[cafe] could not open the invitation ticket list -- no ticket available or a click missed, skipping invite")
|
||||||
return
|
return
|
||||||
|
|
||||||
_ensure_invite_sort(driver, config, descending=prefer_highest)
|
_ensure_invite_sort(driver, config)
|
||||||
|
|
||||||
invited = False
|
invited = False
|
||||||
for row_index in range(len(config.CAFE_INVITE_ROW_Y)):
|
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":
|
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
|
invited = True
|
||||||
break
|
break
|
||||||
if result == "no_dialog":
|
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")
|
print("[cafe] could not confirm cafe is open, aborting without pressing further keys")
|
||||||
return
|
return
|
||||||
|
|
||||||
print("[cafe] room 1: inviting a student if available (highest affection)")
|
print("[cafe] room 1: inviting a student if available")
|
||||||
_invite_student(driver, config, prefer_highest=True)
|
_invite_student(driver, config)
|
||||||
|
|
||||||
print("[cafe] room 1: farming affection")
|
print("[cafe] room 1: farming affection")
|
||||||
_pat_room(driver, config)
|
_pat_room(driver, config)
|
||||||
@ -476,8 +436,8 @@ def run(driver, config):
|
|||||||
driver.wait(1.5)
|
driver.wait(1.5)
|
||||||
return
|
return
|
||||||
|
|
||||||
print("[cafe] room 2: inviting a student if available (lowest affection)")
|
print("[cafe] room 2: inviting a student if available")
|
||||||
_invite_student(driver, config, prefer_highest=False)
|
_invite_student(driver, config)
|
||||||
|
|
||||||
print("[cafe] room 2: farming affection")
|
print("[cafe] room 2: farming affection")
|
||||||
_pat_room(driver, config)
|
_pat_room(driver, config)
|
||||||
|
|||||||
@ -34,24 +34,6 @@ CLOSE_WAIT_ITERATIONS = 15
|
|||||||
CLOSE_WAIT_INTERVAL = 1
|
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):
|
def _raise_exit_dialog(driver, config):
|
||||||
for attempt in range(1, CONFIRM_RETRIES + 1):
|
for attempt in range(1, CONFIRM_RETRIES + 1):
|
||||||
if attempt == CONFIRM_RETRIES and driver.window_exists():
|
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")
|
print("[exit_game] could not confirm the true home screen -- aborting without pressing Escape")
|
||||||
return
|
return
|
||||||
|
|
||||||
_alert_if_ap_left_unspent(driver, config)
|
|
||||||
|
|
||||||
if not _raise_exit_dialog(driver, config):
|
if not _raise_exit_dialog(driver, config):
|
||||||
print("[exit_game] could not confirm the exit-confirmation dialog opened -- aborting without pressing Enter")
|
print("[exit_game] could not confirm the exit-confirmation dialog opened -- aborting without pressing Enter")
|
||||||
return
|
return
|
||||||
|
|||||||
@ -177,15 +177,15 @@ def _checkmark_rect(config, row, col, slot):
|
|||||||
return (cx - hx, cy - hy, cx + hx, cy + hy)
|
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
|
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):
|
def _read_slot_affection(driver, config, row, col, slot):
|
||||||
if _is_slot_already_done(driver, config, row, col, slot, image=image):
|
if _is_slot_already_done(driver, config, row, col, slot):
|
||||||
return None
|
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:
|
if value is not None and value > config.LESSON_GRID_BADGE_MAX_PLAUSIBLE:
|
||||||
# Contamination from portrait art bleeding into the crop's edge,
|
# Contamination from portrait art bleeding into the crop's edge,
|
||||||
# not a real affection value -- see config.py's comment.
|
# 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
|
-- `values` is that cell's available slots' affection numbers, in slot
|
||||||
order. Cells with zero schedulable slots (locked, or every student
|
order. Cells with zero schedulable slots (locked, or every student
|
||||||
already done/absent) are omitted entirely.
|
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 = []
|
cells = []
|
||||||
for row in range(GRID_ROWS):
|
for row in range(GRID_ROWS):
|
||||||
for col in range(GRID_COLS):
|
for col in range(GRID_COLS):
|
||||||
values = []
|
values = []
|
||||||
for slot in range(GRID_SLOTS):
|
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:
|
if value is not None:
|
||||||
values.append(value)
|
values.append(value)
|
||||||
if values:
|
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")
|
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
|
"""Open every region's grid once, record its schedulable cells, close it
|
||||||
again -- a pure read, spends no tickets. Needed because the priority
|
again -- a pure read, spends no tickets. Needed because the priority
|
||||||
below (3-available cells anywhere > 2-available anywhere > lowest
|
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
|
confirmed open is skipped (logged, not fatal), matching this project's
|
||||||
existing "abort without pressing further keys" convention for a single
|
existing "abort without pressing further keys" convention for a single
|
||||||
step, not the whole run.
|
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 = []
|
all_cells = []
|
||||||
triple_count = 0
|
|
||||||
for region_index in range(TOTAL_REGIONS):
|
for region_index in range(TOTAL_REGIONS):
|
||||||
name = config.LESSON_REGION_NAMES[region_index]
|
name = config.LESSON_REGION_NAMES[region_index]
|
||||||
if not _open_region_grid(driver, config, 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")
|
print(f"[lesson] scanned {name}: {len(cells)} cell(s) with a schedulable student")
|
||||||
for row, col, count, values in cells:
|
for row, col, count, values in cells:
|
||||||
all_cells.append((region_index, row, col, count, values))
|
all_cells.append((region_index, row, col, count, values))
|
||||||
if count == 3:
|
|
||||||
triple_count += 1
|
|
||||||
_close_region_grid(driver, config)
|
_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
|
return all_cells
|
||||||
|
|
||||||
|
|
||||||
@ -393,7 +367,7 @@ def run(driver, config):
|
|||||||
print("[lesson] no lesson tickets available, nothing to do")
|
print("[lesson] no lesson tickets available, nothing to do")
|
||||||
else:
|
else:
|
||||||
print("[lesson] scanning all regions for schedulable students")
|
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)
|
queue = _build_priority_queue(all_cells)
|
||||||
triple_count = sum(1 for c in all_cells if c[3] == 3)
|
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)
|
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):
|
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):
|
for _ in range(config.LOGIN_RELAUNCH_WAIT_ATTEMPTS):
|
||||||
if driver.window_exists():
|
if driver.window_exists():
|
||||||
driver.wait(3)
|
driver.wait(3)
|
||||||
if driver.window_exists():
|
|
||||||
return True
|
return True
|
||||||
continue
|
|
||||||
driver.wait(2)
|
driver.wait(2)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -501,13 +489,6 @@ def _recover(driver, config):
|
|||||||
if not _wait_for_window(driver, config):
|
if not _wait_for_window(driver, config):
|
||||||
print("[login] warning: game window did not reappear after relaunch")
|
print("[login] warning: game window did not reappear after relaunch")
|
||||||
return False
|
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()
|
driver.focus_game()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -519,9 +500,6 @@ def run(driver, config):
|
|||||||
if not _wait_for_window(driver, config):
|
if not _wait_for_window(driver, config):
|
||||||
print("[login] warning: game window never appeared after launch -- giving up")
|
print("[login] warning: game window never appeared after launch -- giving up")
|
||||||
return
|
return
|
||||||
if not driver.window_exists():
|
|
||||||
print("[login] warning: game window disappeared again right before focus -- giving up")
|
|
||||||
return
|
|
||||||
driver.focus_game()
|
driver.focus_game()
|
||||||
|
|
||||||
for attempt in range(1, config.LOGIN_MAX_RELAUNCHES + 1):
|
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
|
# would just be noise. A missing, incomplete, or malformed .env silently
|
||||||
# disables alerting only -- it can never affect the wrapped ba_dailies.sh
|
# disables alerting only -- it can never affect the wrapped ba_dailies.sh
|
||||||
# run or this script's own OK/FAILED/SKIPPED logging.
|
# 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
|
set -uo pipefail
|
||||||
|
|
||||||
# Resolves its own directory (same pattern ba_dailies.sh itself already
|
# Resolves its own directory (same pattern ba_dailies.sh itself already
|
||||||
@ -101,9 +90,6 @@ alert() {
|
|||||||
start_epoch=$(date +%s)
|
start_epoch=$(date +%s)
|
||||||
alert info "Starting $PRESET"
|
alert info "Starting $PRESET"
|
||||||
|
|
||||||
start_line=$(wc -l < "$LOG_FILE" 2>/dev/null)
|
|
||||||
start_line="${start_line:-0}"
|
|
||||||
|
|
||||||
"$SCRIPT_DIR/ba_dailies.sh" "$PRESET"
|
"$SCRIPT_DIR/ba_dailies.sh" "$PRESET"
|
||||||
status=$?
|
status=$?
|
||||||
elapsed=$(( $(date +%s) - start_epoch ))
|
elapsed=$(( $(date +%s) - start_epoch ))
|
||||||
@ -118,9 +104,4 @@ alert() {
|
|||||||
echo "=== $(date -Iseconds) finished $PRESET FAILED (exit $status) ==="
|
echo "=== $(date -Iseconds) finished $PRESET FAILED (exit $status) ==="
|
||||||
alert error "$PRESET FAILED (exit $status) after $duration"
|
alert error "$PRESET FAILED (exit $status) after $duration"
|
||||||
fi
|
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
|
} >> "$LOG_FILE" 2>&1
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user