Compare commits

..

2 Commits

Author SHA1 Message Date
d266b47e58 feat: optimize screenshot handling for batch processing in lesson tasks 2026-08-21 18:20:30 +09:00
68a7ed2529 feat: cafe lowest-affection invite for room 2, exit_game AP-left-unspent alert, login window-flicker fix
- cafe.py: room 2 now invites the lowest-affection candidate instead of
  highest (room 1 unchanged), plus OCR'd invited-student name logging
- exit_game.py: alert (AP_ALERT: log tag, picked up by ba_cron_run.sh's
  Discord alerting) if AP is still unspent above threshold when the game
  closes
- login.py: re-check window_exists() after the stabilization wait to catch
  a startup window-flicker race that could crash a run
- config.py/mapping.md: supporting constants and reference-mapping updates

Also rewrites plan.md: retires the phase-by-phase changelog (now fully
superseded by CLAUDE.md's own documentation) down to forward-looking
backlog items, and adds a performance-improvement plan from a cron-log
time analysis (lesson.py's per-cell screenshot redundancy, event_sweep's
multi-call design).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 19:28:59 +09:00
9 changed files with 264 additions and 1735 deletions

View File

@ -188,6 +188,18 @@ 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/
@ -396,6 +408,14 @@ 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

View File

@ -128,16 +128,25 @@ def template_visible(template_path, region=None, threshold=0.85):
return find_template(template_path, region=region, threshold=threshold) is not None
def _color_mask(region, rgb_min, rgb_max):
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):
x1, y1, x2, y2 = region
img = driver.read_screenshot(OCR_SHOT_PATH)
img = image if image is not None else 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):
def region_contains_color(region, rgb_min, rgb_max, image=None):
"""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
@ -145,8 +154,12 @@ def region_contains_color(region, rgb_min, rgb_max):
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).any())
return bool(_color_mask(region, rgb_min, rgb_max, image=image).any())
def find_color_centroid(region, rgb_min, rgb_max, min_pixels=1):
@ -269,7 +282,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):
def read_int_on_heart_badge(region, psm=7, image=None):
"""OCR a small dark-navy digit rendered on lesson.py's pink/magenta
heart-shaped affection badge.
@ -283,9 +296,13 @@ def read_int_on_heart_badge(region, psm=7):
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 = driver.read_screenshot(OCR_SHOT_PATH)
img = image if image is not None else 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

View File

@ -3,18 +3,25 @@
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), 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).
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`).
Confirmed live with real tickets spent, both rooms, same day: room 1
correctly skipped one 衣装替え (costume-swap) candidate then invited row 1
@ -324,7 +331,27 @@ def _read_invite_affection(driver, config, row_index):
return detector.read_int_on_heart_badge(_invite_heart_rect(config, row_index))
def _ensure_invite_sort(driver, config):
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):
# 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.
@ -335,18 +362,24 @@ def _ensure_invite_sort(driver, config):
driver.click(*config.CAFE_INVITE_SORT_OK_BUTTON)
driver.wait(1)
# 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
# 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
# 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 not None and second is not None and top < second:
if top is None or second is None:
return
currently_ascending = top < second
if descending and currently_ascending:
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):
@ -355,50 +388,57 @@ 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 "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).
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.
"""
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"
return "no_dialog", name
title = detector.read_text(config.CAFE_INVITE_DIALOG_TITLE_RECT, psm=6, lang="jpn")
print(f"[cafe] row {row_index} invite dialog title: '{title}'")
print(f"[cafe] row {row_index} ('{name}') 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"
return "skipped", name
driver.click(*config.SWEEP_CONFIRM_BUTTON)
driver.wait(2)
return "invited"
return "invited", name
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.
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.
"""
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)
_ensure_invite_sort(driver, config, descending=prefer_highest)
invited = False
for row_index in range(len(config.CAFE_INVITE_ROW_Y)):
result = _try_invite_row(driver, config, row_index)
result, name = _try_invite_row(driver, config, row_index)
if result == "invited":
print(f"[cafe] invited row {row_index}")
print(f"[cafe] invited {name or f'row {row_index}'} (row {row_index})")
invited = True
break
if result == "no_dialog":
@ -423,8 +463,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")
_invite_student(driver, config)
print("[cafe] room 1: inviting a student if available (highest affection)")
_invite_student(driver, config, prefer_highest=True)
print("[cafe] room 1: farming affection")
_pat_room(driver, config)
@ -436,8 +476,8 @@ def run(driver, config):
driver.wait(1.5)
return
print("[cafe] room 2: inviting a student if available")
_invite_student(driver, config)
print("[cafe] room 2: inviting a student if available (lowest affection)")
_invite_student(driver, config, prefer_highest=False)
print("[cafe] room 2: farming affection")
_pat_room(driver, config)

View File

@ -34,6 +34,24 @@ 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():
@ -64,6 +82,8 @@ 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

View File

@ -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):
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)
return detector.region_contains_color(_checkmark_rect(config, row, col, slot), lo, hi, image=image)
def _read_slot_affection(driver, config, row, col, slot):
if _is_slot_already_done(driver, config, row, col, slot):
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))
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.
@ -200,13 +200,21 @@ 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)
value = _read_slot_affection(driver, config, row, col, slot, image=image)
if value is not None:
values.append(value)
if values:
@ -230,7 +238,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):
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
@ -241,8 +249,21 @@ def _scan_all_regions(driver, config):
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):
@ -252,7 +273,12 @@ def _scan_all_regions(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
@ -367,7 +393,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)
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)

View File

@ -470,10 +470,22 @@ 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)
return True
if driver.window_exists():
return True
continue
driver.wait(2)
return False
@ -489,6 +501,13 @@ 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
@ -500,6 +519,9 @@ 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):

View File

@ -33,6 +33,17 @@
# 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
@ -90,6 +101,9 @@ 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 ))
@ -104,4 +118,9 @@ 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

1721
plan.md

File diff suppressed because it is too large Load Diff