feat: implement AP floor guard for story_sweep, story_sweep_hard, and event_sweep

This commit is contained in:
Nik Afiq 2026-07-24 23:34:15 +09:00
parent 62206494d5
commit 4ddd0d7fba
6 changed files with 111 additions and 1 deletions

View File

@ -260,8 +260,23 @@ STAGE_ROWS_AT_BOTTOM_Y = (483, 630, 778, 926)
# relevant scroll positions (top/bottom extremes, both already known-good),
# full swipe-and-retry generality isn't needed -- just OCR each of the 4
# visible rows at each extreme and match by label text.
#
# bottom_pad=4 (i.e. cropping up to row_y-4) turned out to still clip the
# bottom of a "2" glyph's flat closing stroke just enough to make tesseract
# read it as "9" -- confirmed live 2026-07-24 (region 29's real rotation
# target landed on stage 2 for the first time since switching the rotation
# region from 30, and every single OCR config tried against the *unmodified*
# rect misread "29-2" as "99-9"/"90-9"/etc., even though the crop looks
# completely unambiguous to the eye -- see scratchpad/probe_stage_ocr_*.py).
# "1"/"3"/"4"/"5" never showed this because they don't have that bottom
# stroke shape. Extending the crop 8px further down (bottom_pad=-4, i.e.
# row_y+4) gave a clean, unanimous "29-2" across every threshold/psm/oem
# combination tried, without breaking any of the other seven already-correct
# row reads (validated across both scroll extremes) -- a geometry fix, not a
# digit-guessing one, matching detector.py's own read_int_bordered precedent
# for a different tesseract edge-clipping misread.
STAGE_LABEL_OCR_X = (1030, 1150)
STAGE_LABEL_OCR_Y_PAD = (40, 4)
STAGE_LABEL_OCR_Y_PAD = (40, -4)
# 任務情報 (stage info) modal's sweep sub-panel. This modal is wide enough
# that navigation.MODAL_DIM_PROBE (960, 200) lands on the modal's own white
@ -343,6 +358,19 @@ SWEEP_CONFIRM_GOLD = ((200, 200, 50), (256, 256, 150))
# hardcoding each dialog's exact Y position.
SWEEP_RESULT_BUTTON_REGION = (700, 700, 1300, 1050)
# Below this current AP, story_sweep/story_sweep_hard/event_sweep skip
# outright (checked via navigation.current_ap right after driver.focus_game,
# while still on the home screen) rather than spend a full navigation cycle
# on a sweep that likely can't even afford one battle's worth of AP -- per
# explicit user direction 2026-07-24 (found while diagnosing a real run that
# left AP maxed out because a *different* bug, an OCR misread, silently
# skipped the day's target; this is a separate, deliberate efficiency guard,
# not a fix for that bug). Applies unconditionally, including the
# story_sweep_force/story_sweep_hard_force variants -- force only bypasses
# the campaign-active *business* gate in those modules, not this basic
# feasibility floor.
SWEEP_MIN_AP = 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

@ -1,5 +1,7 @@
"""Shared navigation helpers (home, menu, popups, back/escape)."""
from ba_auto import detector
# The mailbox/cafe/shop-style header bar renders a plain light background
# here; the home screen shows character art instead.
SUBSCREEN_HEADER_PROBE = (500, 10)
@ -9,6 +11,19 @@ SUBSCREEN_HEADER_MIN_CHANNEL = 200
MODAL_DIM_PROBE = (960, 200)
MODAL_DIM_MAX_CHANNEL = 150
# Home screen's own AP ("357/240"-style) display, in the header pill next to
# the lightning-bolt icon -- pixel-scanned live 2026-07-24 (x starts just
# clear of the icon, x2 leaves enough room for a 3-digit current value
# without reaching into the "+" button beyond it; tighter/looser variants
# tried on the same frame either clipped a digit or picked up stray
# icon/button pixels as spurious extra characters). Only meaningful on the
# true home screen -- every other subscreen's header has no AP display at
# all -- which is why the AP-floor guard in story_sweep.py/
# story_sweep_hard.py/event_sweep.py reads this immediately after
# driver.focus_game(), before any navigation away from home.
HOME_AP_OCR_RECT = (800, 30, 955, 72)
HOME_AP_READ_RETRIES = 3
# Shared top-left back-arrow position -- every subscreen calibrated so far
# (mailbox, cafe, shop, lesson, event) puts its own back button here
# (confirmed identical across config.py's LESSON_BACK_BUTTON/
@ -28,6 +43,28 @@ def is_modal_open(driver):
return r < MODAL_DIM_MAX_CHANNEL and g < MODAL_DIM_MAX_CHANNEL and b < MODAL_DIM_MAX_CHANNEL
def current_ap(driver):
"""OCR the home screen's own AP display, returning the current value
only (not the max after the "/"), or None if unreadable after
HOME_AP_READ_RETRIES attempts.
Local port of the reference's Baas_thread.get_ap (is_main_page=True
branch) -- same split-on-"/"-and-parse-the-head approach as lesson.py's
own _read_ticket_count, just against the header's AP pill instead of the
lesson-ticket counter. Retries a transient None the same way
story_sweep._read_current_region does for this same header row.
"""
for attempt in range(1, HOME_AP_READ_RETRIES + 1):
text = detector.read_text(HOME_AP_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())
if digits:
return int(digits)
if attempt < HOME_AP_READ_RETRIES:
driver.wait(1)
return None
# Every confirmed subscreen (mailbox/cafe/shop/lesson/event) renders a
# uniform light header bar spanning nearly the full screen width at this y --
# is_on_subscreen only samples one x on that row (SUBSCREEN_HEADER_PROBE),

View File

@ -646,6 +646,11 @@ def _rotation_target(config):
def run(driver, config):
driver.focus_game()
ap = navigation.current_ap(driver)
if ap is not None and ap < config.SWEEP_MIN_AP:
print(f"[event_sweep] current AP ({ap}) is below the minimum ({config.SWEEP_MIN_AP}) -- skipping, nothing to do")
return
rotation = _rotation_target(config)
if not rotation:
print("[event_sweep] no rotation target configured (config.EVENT_SWEEP_ROTATION_STAGE_MIN is unset), nothing to do")

View File

@ -468,6 +468,11 @@ def _rotation_target(config):
def run(driver, config, force=False):
driver.focus_game()
ap = navigation.current_ap(driver)
if ap is not None and ap < config.SWEEP_MIN_AP:
print(f"[story_sweep] current AP ({ap}) is below the minimum ({config.SWEEP_MIN_AP}) -- skipping, nothing to do")
return
targets = list(config.STORY_SWEEP_TARGETS)
rotation = _rotation_target(config)
if rotation:

View File

@ -409,6 +409,11 @@ def _sweep_target(driver, config, region, stage):
def run(driver, config, force=False):
driver.focus_game()
ap = navigation.current_ap(driver)
if ap is not None and ap < config.SWEEP_MIN_AP:
print(f"[story_sweep_hard] current AP ({ap}) is below the minimum ({config.SWEEP_MIN_AP}) -- skipping, nothing to do")
return
targets = list(config.HARD_STORY_SWEEP_TARGETS)
if not targets:
print("[story_sweep_hard] no targets configured (config.HARD_STORY_SWEEP_TARGETS is empty), nothing to do")

30
plan.md
View File

@ -1030,6 +1030,36 @@ Rewrote `ba_auto/tasks/story_sweep.py`, porting every fix from Phase 21 even tho
**Live-tested for real the same session**: a real run swept the day's rotation target (30-2) end-to-end successfully on the first attempt -- campaign guard correctly passed (banner was genuinely showing), region navigation succeeded, the OCR stage-row search matched correctly despite the same known leading-digit misread already documented in this module ("20-2" was read for the "30-2" row, but `_label_suffix`'s suffix-only comparison still matched on "2"), the sweep-usage confirm dialog passed both the color and the new OCR text gate, and the result was correctly detected as `"swept"` (not the old cosmetic misreport story_sweep_hard.py hit) on the very first real attempt. AP dropped from ~139 to 1 and credits rose by 13,966, confirming a real, substantial MAX sweep. Clean return to home confirmed by screenshot. No new bugs found -- unlike Phase 21, this rewrite worked correctly the first time it touched the real game, likely because every fix ported in was already proven live in story_sweep_hard.py rather than being newly speculative here.
### Phase 23: `STAGE_LABEL_OCR_Y_PAD` bottom-clip bug -- stage suffix "2" misread as "9", region 29's rotation stage 2 never swept (2026-07-24)
Found while investigating the user's report that a real `q4h` cron run (21:00-21:09 JST) left AP maxed out (357/240) despite the Normal-task 2x reward campaign being genuinely active. `~/ba_logs/q4h.log` showed `story_sweep` correctly detecting the campaign and navigating to region 29 (that same day's `fix(config)` commit had just switched `STORY_SWEEP_ROTATION_REGION` from 30 to 29, `STAGE_COUNT` from 6 to 5), but every stage-row OCR read for what should have been "29-2" (today's rotation stage, `ordinal % 5 + 1 = 2`) instead came back as `"99-9"`/`"990-4"`-shaped garbage across both scroll positions, so `_find_stage_row` correctly (safely) returned `stage_not_found` rather than guessing -- no AP spent, nothing risky clicked, but nothing swept either.
This directly contradicts `_label_suffix`'s own existing doc comment ("the stage suffix after the dash reads correctly across every row tested") -- true as far as it had been tested, but region 29's rotation was brand new that same day, and this was the first time the rotation had ever actually landed on stage 2 specifically.
Root cause, confirmed via `scratchpad/probe_stage29_row2_ocr.py` and `scratchpad/probe_stage_ocr_tuning.py`/`_ypad.py`/`_ypad_validate.py` (temporary, since deleted) run live against the real game on nik-gpu (game was closed from the cron run's own `exit_game` step; user relaunched it manually so this could be investigated against the real UI): the raw crop for every affected row visually read as an unambiguous, clean "29-2" to the eye, but `config.STAGE_LABEL_OCR_Y_PAD`'s `bottom_pad=4` (i.e. cropping up to `row_y-4`) clipped just enough of a "2" glyph's flat closing bottom stroke to make tesseract read it as "9" -- reproduced under every threshold (120-200) / upscale (2x-6x) / psm (6/7/8/13) / oem (1/3) combination tried, none of which recovered a correct read from the *unmodified* rect. "1"/"3"/"4"/"5" don't have that stroke shape and were unaffected, which is why only stage 2's row ever broke, and why it slipped past Phase 22's own live sweep of "30-2" (that row happened to land at `STAGE_ROWS_AT_TOP_Y`'s first position, `row_y=424`, which -- also confirmed tonight -- was the one row position that stayed readable even under the too-tight crop).
Fix: extended `STAGE_LABEL_OCR_Y_PAD` from `(40, 4)` to `(40, -4)` (crop now runs to `row_y+4` instead of `row_y-4`, 8px taller) -- a geometry fix, not a digit-guessing one, matching `detector.py`'s own `read_int_bordered` precedent for a different tesseract edge-clipping misread (event_sweep's "08"/"09"). Validated against all 8 row positions (both scroll extremes, all of stages 1-5) using the exact `ss._row_label_rect` codepath before and after the change -- before: only "29-1" (row 424) read clean, every other row was garbled; after: all 8 read cleanly as their correct labels, live against the real game, no click made past the Normal tab (no AP spent during diagnosis).
Deployed to nik-gpu (`config.py` only). Not yet re-confirmed via a full real `story_sweep`/`story_sweep_force` run actually completing a stage-2 sweep end-to-end (AP-spend confirmation, matching Phase 22's own "AP dropped, credits rose" check) -- that's the natural next live-test step, either by invoking `story_sweep_force` directly or waiting for the next scheduled `q4h` fire (01:00 JST) to land on the same stage-2 target again.
Unrelated, noticed in the same log but out of scope for this investigation: `story_sweep_hard` correctly skipped (no active Hard-task campaign that run), and `event_sweep` failed both its attempts this run (`"landed on the wrong event page"` then `"stage_not_found"` for stage 10) -- a separate, pre-existing issue not touched here.
### Phase 24: AP-floor guard for story_sweep/story_sweep_hard/event_sweep (2026-07-24)
Direct follow-up to Phase 23, per explicit user direction: skip `story_sweep`/`story_sweep_hard`/`event_sweep` outright -- including their `_force` variants -- whenever current AP is under 20, checked at the home screen before any task-specific navigation starts. This is a deliberate efficiency guard, not a safety fix -- each task's own existing `_is_ap_purchase_prompt`/AP-usage-confirm checks already prevent an actually-unaffordable sweep from spending anything; this just avoids burning a full navigation cycle (open Work hub, open task screen, open Normal/Hard tab, scroll/OCR the stage list) on an attempt that's very likely to fail anyway.
Reference: `Baas_thread.get_ap` (`is_main_page=True` branch) OCRs the home page's own AP display and splits on `"/"` for the current value; `explore_tasks/sweep_task.py`'s `unfinishedNormalTaskLoop`/`sweep_hard_task` both already call `get_ap` before attempting a sweep and bail if `current_ap < base_ap`. This project's guard ports that same "check AP before bothering to sweep" concept, just proactively at the home screen (before opening any task UI) rather than only once already inside the sweep flow, and with a fixed round-number floor (20) rather than a per-stage AP-cost calculation.
Added:
- `ba_auto/navigation.py`: `current_ap(driver)` -- OCRs `HOME_AP_OCR_RECT` (a new local module constant, `(800, 30, 955, 72)`, pixel-scanned live 2026-07-24 against the header's lightning-bolt AP pill on the true home screen), splits on `"/"` and parses the head as digits, same idiom as `lesson.py`'s own `_read_ticket_count`. Retries a transient `None` read up to `HOME_AP_READ_RETRIES` (3) times, matching `story_sweep._read_current_region`'s own retry-on-`None` precedent for this same header row. Kept as a config-free local probe (its own `HOME_AP_OCR_RECT`/`HOME_AP_READ_RETRIES` constants live directly in `navigation.py`, not `config.py`) to match how `is_on_subscreen`/`is_modal_open`'s own probe constants are already kept local to that file rather than in `config.py` -- `config.py` is reserved for per-feature tuning knobs, which the AP floor itself (below) still is.
- `ba_auto/config.py`: `SWEEP_MIN_AP = 20`.
- `story_sweep.run`/`story_sweep_hard.run`/`event_sweep.run`: immediately after `driver.focus_game()` (i.e. while still on the home screen, before opening any task UI), call `navigation.current_ap(driver)` and return early with a one-line log if it's a non-`None` value under `config.SWEEP_MIN_AP`. Applies unconditionally in the two modules with a `force` parameter -- `force` only bypasses the campaign-active *business* gate in those modules, never this basic feasibility floor, so the check runs before the `force`/campaign branch entirely.
A `None` read (OCR failure, or a full-screen ambient/cutscene state with no header at all -- confirmed live the same session, see below) is treated as "proceed as normal," not "skip": the guard is a new efficiency optimization layered on top of already-safe existing behavior, so failing open on an uncertain read preserves that existing behavior rather than risking silently disabling these tasks whenever this one OCR read hiccups.
**Live-tested for real the same session** against the account's actual live AP (genuinely 10-11/240 at the time, well under the new floor -- no need to fabricate a low-AP scenario): confirmed `navigation.current_ap(driver)` reads the true value correctly against the live header (`10`, then `11` a few minutes later, matching the visible regen), and confirmed all three of `story_sweep.run(driver, config, force=True)`, `story_sweep_hard.run(driver, config, force=True)`, and `event_sweep.run(driver, config)` printed the new skip message and returned immediately with zero navigation -- verified via `navigation.is_on_subscreen(driver)` still reading `False` (still home) directly afterward. Also incidentally reconfirmed, while calibrating `HOME_AP_OCR_RECT`, that the game can spontaneously drop into a full-screen ambient/idle cutscene with no header chrome at all even while sitting on what `_ensure_home`/`is_on_subscreen` both still call "home" -- a plain Enter keypress cleared it back to the ordinary home view both times it was hit. Not a new bug (same class of state `cafe.py`'s `_dismiss_rank_up_if_shown` and `login.py`'s own state catalog already exist to handle), just newly relevant here since it's the specific state that makes `current_ap` correctly return `None`.
## Prerequisites
### OCR