From a5d70e5ec6700e28f8c93eefba93ef34dd45fc47 Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Wed, 22 Jul 2026 23:07:43 +0900 Subject: [PATCH] Refactor story_sweep.py to align with story_sweep_hard.py best practices and add campaign-active guard - Rewrote story_sweep.py to incorporate fixes from story_sweep_hard.py's Phase 21, addressing latent bugs and improving functionality. - Introduced a campaign-active guard for Normal mode, ensuring AP is only spent when the reward campaign banner is visible. - Added explicit Normal-tab navigation and self-healing checks to maintain task screen integrity. - Updated click functions for MAX and PLUS buttons to remove reliance on visible count increases, addressing false-negative issues. - Implemented OCR verification for the sweep confirmation dialog to prevent misclicks on stacked buttons. - Enhanced region reading with retry logic for transient OCR failures. - Added force parameter to run function, allowing bypass of campaign checks via story_sweep_force command. - Updated ba_daily.py to include new force commands for both story_sweep and story_sweep_hard. - Documented changes and live-tested functionality to ensure reliability and correctness. --- CLAUDE.md | 5 +- ba_auto/config.py | 58 +++++-- ba_auto/reference_notes/mapping.md | 2 +- ba_auto/tasks/story_sweep.py | 255 ++++++++++++++++++++++------- ba_auto/tasks/story_sweep_hard.py | 10 +- ba_daily.py | 29 ++-- plan.md | 27 +++ 7 files changed, 290 insertions(+), 96 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index aac805a..8c06142 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -668,9 +668,9 @@ When debugging on `nik-gpu`, copy relevant screenshots or debug images back into Current project state: mailbox, cafe, stamina, story_sweep, story_sweep_hard, event_sweep, shop_common, shop_tactical, lesson, arena, and bounty are all migrated to real Python. No Bash feature logic remains. (This list has drifted behind a few other migrated tasks -- login, gem_shop, circle, exit_game -- not tracked here yet; see `ba_daily.py`'s own `TASKS` dict for the true current set.) - `ba_dailies.sh` is a thin launcher that execs `ba_daily.py` -- `ba_daily.py` dispatches `mailbox`, `cafe`, `stamina`, `story_sweep`, `story_sweep_hard`/`story_sweep_hard_force`, `event_sweep`, `shop_common`, `shop_tactical`, `lesson`, `arena`, `bounty`, and default flow to `ba_auto/tasks/` +- `ba_daily.py` dispatches `mailbox`, `cafe`, `stamina`, `story_sweep`/`story_sweep_force`, `story_sweep_hard`/`story_sweep_hard_force`, `event_sweep`, `shop_common`, `shop_tactical`, `lesson`, `arena`, `bounty`, and default flow to `ba_auto/tasks/` - default flow is `mailbox`, `cafe`, `stamina` -- `story_sweep`, `story_sweep_hard` (and its `story_sweep_hard_force` override), `event_sweep`, `shop_common`, `shop_tactical`, `lesson`, `arena`, and `bounty` are opt-in only since they spend AP/credits/tactical coin/lesson tickets/an arena ticket/a bounty ticket rather than reclaiming something free. `story_sweep_hard` additionally refuses to run at all unless a Hard-task reward campaign is currently active, unless overridden via `story_sweep_hard_force` -- see `ba_auto/tasks/story_sweep_hard.py` and `plan.md` Phase 21 +- `story_sweep` (and its `story_sweep_force` override), `story_sweep_hard` (and its `story_sweep_hard_force` override), `event_sweep`, `shop_common`, `shop_tactical`, `lesson`, `arena`, and `bounty` are opt-in only since they spend AP/credits/tactical coin/lesson tickets/an arena ticket/a bounty ticket rather than reclaiming something free. Both `story_sweep` and `story_sweep_hard` additionally refuse to run at all unless their tab's reward campaign is currently active, unless overridden via `story_sweep_force`/`story_sweep_hard_force` -- see `ba_auto/tasks/story_sweep.py`/`story_sweep_hard.py` and `plan.md` Phases 21-22 - every task, whether run individually or as part of the default flow, self-heals back to the home screen both before it starts and after it ends — `ba_daily.py`'s `_run_task()` calls a retrying `_ensure_home()` before dispatch and wraps the dispatch itself in a `try/finally` calling `navigation.return_to_home()`, so cleanup runs regardless of success, an early-return failure, or an uncaught exception. The pre-task check is self-healing, not a hard gate, per explicit user direction: if `_ensure_home()` still can't confirm home after its own bounded retries, the task is attempted anyway rather than aborted, trusting each task's own click-then-verify steps to fail safely if the starting state really was bad. This is centralized rather than duplicated per-task; see plan.md's "Return-to-home audit" (and its self-heal-not-abort follow-up) for why (a real audit found most tasks had little to no reliable cleanup on several paths) and for a real bug this surfaced and fixed in `navigation.is_on_subscreen`/`return_to_home` itself (a modal open on top of a subscreen was indistinguishable from the true home screen using the header-brightness probe alone — fixed by also checking `is_modal_open`) - `ba_auto/tasks/mailbox.py` and `ba_auto/tasks/cafe.py` click with `ba_auto/driver.py` primitives and verify state with `driver.color_at` and `ba_auto/navigation.py` - mailbox and cafe were ported from the reference patterns around `module/mail.py` and `module/cafe_reward.py` @@ -680,6 +680,7 @@ Current project state: mailbox, cafe, stamina, story_sweep, story_sweep_hard, ev - `ba_auto/tasks/story_sweep.py` sweeps a config-driven list of exact `(region, stage, count)` targets from `config.STORY_SWEEP_TARGETS`, plus one daily-rotating target (`config.STORY_SWEEP_ROTATION_*`) that cycles through a fixed region's stages one per day - story sweep navigates via OCR, using region-number readout and stage-label matching, rather than random selection - see `plan.md` Phase 10 for the OCR-based story sweep port, and its "Phase 10 follow-up" for the rotation-target fix +- `story_sweep.py` also has its own campaign-active guard (`story_sweep_force` to override), an explicit Normal-tab click-and-verify (needed now that `story_sweep_hard` can run earlier in the same preset and leave Hard tab selected), and the same `_confirm_dialog_is_sweep`/self-heal-navigation/no-false-negative-MAX-verify fixes `story_sweep_hard.py` needed — see `plan.md` Phase 22 for the full rewrite, live-tested successfully on the first real attempt (a real MAX sweep of the day's rotation target, correctly detected as swept) - `ba_auto/tasks/story_sweep_hard.py` sweeps a fixed, user-supplied priority-ordered list of Hard-mode `(region, stage)` targets (`config.HARD_STORY_SWEEP_TARGETS`) via the in-game MAX button (capped at 3x by the game itself), reusing story_sweep.py's region-nav and stage-info-modal machinery directly (confirmed pixel-identical) but with its own fixed 3-row stage list (no scrolling/OCR-label search — Hard always has exactly missions 1-3) and its own campaign-active guard: it refuses to spend any AP unless the pink "キャンペーン中" reward-campaign banner is showing on the region-info card, unless run via the separate `story_sweep_hard_force` command - a real gold-button money hazard exists in this same modal for Hard specifically: a stage that already used all 3 of today's auto-sweep clears still shows a clickable 入場 button, and attempting to sweep it raises a real "spend 40 Pyroxene/blue gems to refill today's clear count?" dialog, not just an AP-insufficient prompt — guarded by two independent layers (an OCR pre-check on the modal's own count field before ever clicking MAX, plus an OCR fallback on the dialog's own text, both declining via Escape rather than any positional click) - see `plan.md` Phase 21 for the full live-testing writeup, including the navigation-cascade bug, the false-negative MAX-click abort bug, and the gem-refill hazard, all found and fixed against the real game diff --git a/ba_auto/config.py b/ba_auto/config.py index 90e9b60..6921a0b 100644 --- a/ba_auto/config.py +++ b/ba_auto/config.py @@ -196,6 +196,18 @@ WORK_ICON = (1793, 1138) # screenshot, not just a hunch. (1250, 380) sits solidly mid-card, on the # "任務" title text itself, well clear of every edge. TASK_CARD = (1250, 380) +# Normal/Hard toggle tab atop the region browser's stage-list panel. +# story_sweep.py explicitly clicks+verifies this (mirroring +# story_sweep_hard.py's own HARD_TAB/_open_hard_tab) rather than assuming +# Normal is always the screen's default -- added 2026-07-21 once +# story_sweep_hard.py started running earlier in the same q4h preset +# sequence and could plausibly leave Hard selected. NORMAL_TAB_ACTIVE_PROBE +# is sampled clear of the "Normal" glyph itself (dark navy background, +# confirmed live 2026-07-21); note this is a DIFFERENT active-tab color than +# Hard's own red (HARD_TAB_ACTIVE_RGB below) -- each tab has its own accent. +NORMAL_TAB = (1200, 297) +NORMAL_TAB_ACTIVE_PROBE = (1100, 297) +NORMAL_TAB_ACTIVE_RGB = ((20, 40, 60), (100, 120, 140)) REGION_RIGHT_ARROW = (1862, 598) # Pixel-scanline-scanned (not visually estimated -- see plan.md Phase 8's # lesson) from scratchpad/stage_info.png: the "<" chevron's navy-blue pixel @@ -295,6 +307,16 @@ STAGE_MODAL_CLOSE_BUTTON = (1691, 225) # yet; confirm it before relying on a non-"max" configured count (see # plan.md Phase 10). SWEEP_PLUS_BUTTON = (1520, 550) +# "MIN" stepper button -- forces a known baseline of 1 before applying "+" +# raises for a configured exact (non-"max") count. Added 2026-07-21: the +# stepper remembers its last-used value across opens (confirmed live via +# bounty.py's own real overspend incident, see plan.md Phase 15 and +# CLAUDE.md's stepper-default guidance), so blindly clicking "+" count-1 +# times on top of an unknown starting value could under- or over-shoot the +# intended count. Same row/layout as SWEEP_MAX_BUTTON/SWEEP_PLUS_BUTTON, +# pixel-scanned directly (confirmed live, the leftmost button in the +# MIN/-/count/+/MAX row). +SWEEP_MIN_BUTTON = (1180, 555) # Clicking 掃討開始 (start sweep) always raises an "AP使用して、掃討を # 回行いますか?" usage-confirmation dialog before the sweep actually @@ -419,27 +441,33 @@ HARD_STAGE_ENTER_PROBE_HALF_SIZE = (25, 15) HARD_COUNT_FIELD_RECT = (1338, 535, 1474, 575) HARD_CHALLENGE_ALERT_TEXT_RECT = (600, 385, 1330, 470) -# Hard-only reward campaign banner, rendered directly on the region-info card -# (left panel) whenever a reward campaign is active for Hard tasks -- -# confirmed live 2026-07-20 on both Area 30 and Area 29 with the exact text -# "任務Hardで獲得できる報酬量が2倍(+100%)になっています。", so this reads as -# account-wide, not per-region. No reference equivalent exists at all +# Reward campaign banner, rendered directly on the region-info card (left +# panel) whenever a reward campaign is active for whichever tab (Normal or +# Hard) is currently selected -- confirmed live both times: "任務Hardで獲得 +# できる報酬量が2倍(+100%)になっています。" (2026-07-20, Area 30/29) and +# "任務Normalで獲得できる報酬量が2倍(+100%)になっています。" (2026-07-21, +# Area 30), pixel-identical rect/color in both cases, only the tab name in +# the text differs. Reads as account-wide, not per-region (same text on +# multiple regions each time). No reference equivalent exists at all # (baas-reference has no campaign/drop-rate concept anywhere) -- per explicit -# user direction, this task refuses to spend AP unless this banner is -# showing, unless explicitly overridden (see story_sweep_hard.py's -# force= parameter / the story_sweep_hard_force CLI command). The home -# screen's own smaller "キャンペーン中" badge (top-right notice stack) was -# considered but rejected as the actual gate signal: it can point at any of -# several concurrent campaigns (e.g. a totally unrelated "大決戦開催中" -# banner sits right above it), not necessarily Hard task rewards -# specifically, while this in-panel banner explicitly names "任務Hard". +# user direction, both story_sweep.py and story_sweep_hard.py refuse to spend +# AP unless this banner is showing for their own tab, unless explicitly +# overridden (force= parameter / the story_sweep_force / story_sweep_hard_force +# CLI commands). The home screen's own smaller "キャンペーン中" badge (top-right +# notice stack) was considered but rejected as the actual gate signal: it can +# point at any of several concurrent campaigns (e.g. a totally unrelated +# "大決戦開催中" banner sits right above it), not necessarily story-task +# rewards specifically, while this in-panel banner explicitly names the tab. # Detected via a flat color-presence scan (not OCR) since the pink chip is # clean and high-contrast and the banner is either fully rendered or entirely # absent (never a different overlapping color) -- matching this project's # own established convention for unambiguous binary state signals (e.g. # gem_shop.py's claimed/unclaimed probe) rather than OCR'ing the banner text. -HARD_CAMPAIGN_BADGE_RECT = (164, 426, 344, 464) -HARD_CAMPAIGN_BADGE_RGB = ((230, 100, 160), (256, 210, 256)) +# Named generically (not HARD_-prefixed) since both story_sweep.py and +# story_sweep_hard.py share it -- originally added for Hard only, renamed +# 2026-07-21 once story_sweep.py's own campaign check reused it unchanged. +TASK_CAMPAIGN_BADGE_RECT = (164, 426, 344, 464) +TASK_CAMPAIGN_BADGE_RGB = ((230, 100, 160), (256, 210, 256)) # (region, stage) priority-ordered targets to sweep, in the exact order given # by the user (highest farming priority first, not region-ascending) -- diff --git a/ba_auto/reference_notes/mapping.md b/ba_auto/reference_notes/mapping.md index d4db8a4..4b0dfba 100644 --- a/ba_auto/reference_notes/mapping.md +++ b/ba_auto/reference_notes/mapping.md @@ -8,7 +8,7 @@ Maps each local feature to the corresponding `~/repo/baas-reference/module/...` | Cafe | `module/cafe_reward.py` | `to_cafe` (its `relationship_rank_up` popup-handling now also ported, see below), `interaction_for_cafe_solve_method3`, `collect`, `invite_girl`/`invite_by_affection`/`checkConfirmInvite` (student invitation, added 2026-07-14) | `ba_auto/tasks/cafe.py` | `picture.co_detect`/`color.rgb_in_range` → `driver.color_at` pixel-probe checks; sparkle template match ported in-process into `ba_auto/detector.py` (`find_cafe_sparkle`, now multi-scale) | Migrated: real Python, state-verified via color probes (no legacy bridge). Pat loop now polls for the full attempt budget instead of stopping on the first miss (see `plan.md` Phase 6 follow-up) — not yet confirmed against a live sparkle since none was available during testing. `_dismiss_rank_up_if_shown` reuses `navigation.is_on_subscreen` to detect and clear the full-screen bond-rank-up cutscene after a pat (see `plan.md` Phase 6 follow-up: rank-up popups) — not yet live-confirmed against a real trigger. **Student invitation (2026-07-14)**: per explicit user direction, invite a student into each room before farming it, preferring highest affection, always skipping (never confirming) a candidate that would swap an already-seated student's costume or move one in from the other room — directly ports `invite_by_affection`/`checkConfirmInvite`'s own logic with the reference's default `cafe_reward_allow_exchange_student`/`cafe_reward_allow_duplicate_invite` both `False` (no local config exists to make either configurable). Live-calibrated against nik-gpu with **zero real tickets spent** — all 3 real dialog variants (normal confirm, same-room costume-swap warning, neighboring-room move warning) found and confirmed live, every one cancelled rather than confirmed during calibration. The heart-shaped affection badge OCR reuses `detector.read_int_on_heart_badge` directly (pixel-confirmed the same widget lesson.py's own badges use — digit pixels sampled RG, matching that function's exact masking assumption) rather than building a second OCR path. Dialog-type detection OCRs the title bar and checks for either warning's own distinctive substring (`衣装`/`隣`) rather than an exact match, mirroring `event_sweep.py`'s own `"終了"` substring-match reasoning. All 5 heart-badge reads and all 3 dialog classifications verified offline against the real saved calibration screenshots before deploying (exact match, zero mismatches). **Confirmed live with real tickets spent, both rooms**: room 1 correctly skipped one 衣装替え (costume-swap) candidate then invited row 1 cleanly; room 2 correctly skipped three consecutive 隣のカフェの生徒を招待 (neighboring-room-move) candidates (expected — room 1's own invite had just taken the account's highest-affection students) then invited row 3 cleanly. Both newly-invited students were immediately patted successfully in the same run (the "newly invited student can be farmed" requirement), income was claimed, and the task returned cleanly to the true home screen with no warnings anywhere in the log. **Horizontal camera panning (2026-07-14)**: per explicit user direction ("due to my screen size... move screen most right and most left then farm"), `_pat_room` now pans the room camera to its rightmost extreme via a new `driver.drag()` primitive (this project's first — distinct from `driver.scroll()`'s wheel-based gesture, which is for list widgets, not a room camera), farms there, pans to the leftmost extreme, farms there too — no vertical panning, per the user's own instruction. Ports the reference's `zoom_out`'s underlying intent (see the whole room regardless of width) via the user's own specified mechanism (panning) rather than the reference's (zoom). Live-confirmed drag-direction-to-reveal-side mapping, that HUD elements (status bar, ticket buttons) stay fixed regardless of pan (no camera reset needed afterward), and that one drag already reaches the true extreme (extra repeats are a confirmed no-op, kept as a safety margin). **Confirmed live with a real game-state change**: a full run patted a real sparkle in room 2 (score 0.997) specifically after panning to an extreme, while room 1 found nothing that run (expected — per-student cooldown). Clean end-to-end completion, no warnings. **Invite ticket cooldown bugfix (2026-07-14)**: real-usage report (with screenshot) that the invite step kept failing once the account's ticket went on cooldown — `_open_invite_list`'s original check (`not is_on_subscreen`) couldn't distinguish the real MomoTalk list opening from a "通知" cooldown notice ("待機時間が経過した後に、再度招待することができます。") opening directly instead, since both dim the header the same way; misread as "list opened", it sent `_ensure_invite_sort`/`_try_invite_row`'s fixed coordinates into a dialog that has none of them. Fixed by checking `navigation.is_modal_open` (the darker real-dialog reading) first — a dialog appearing before any row is clicked can only mean the ticket click raised one directly — and dismissing it via the shared `SWEEP_CONFIRM_BUTTON`, returning `False` so the existing "skip this room's invite" fallback handles it. **Confirmed live**: both rooms correctly detected and dismissed the cooldown notice with no cascading errors, task completed cleanly (exit 0, true home screen confirmed via screenshot). See `plan.md`'s Phase 6 follow-up #4. **Two further real-usage fixes (2026-07-15, `next_fix.md`)**: (1) the cooldown-notice dismiss above now presses Escape and verifies via `navigation.is_modal_open` with bounded retry, instead of a fixed-coordinate click that could miss and leave the notice open under the following pan drags; (2) `_dismiss_rank_up_if_shown` switched from `navigation.is_on_subscreen`'s single-pixel header probe (found live to misread some characters' rank-up cutscene art as bright) to a new `navigation.is_header_bar_visible`, requiring 8 spread-out header-row points to all read bright via one atomic multi-point capture (`driver.colors_at`, added alongside it). Both best-effort — not yet re-confirmed live post-fix. **A real rank-up happened naturally via cron (2026-07-16)**, reported live with a screenshot of the game left stuck on the cutscene — exposed a third bug, a timing gap rather than a threshold problem: `_dismiss_rank_up_if_shown` was only ever called once, immediately after a pat, with no wait beforehand, but the cutscene renders with its own client-side animation delay, so that single check could catch the tail end of the still-normal room view and conclude "clear" a beat before the actual cutscene appeared. Confirmed via a live pixel-check that the resulting stuck cutscene reads under `is_on_subscreen`/`is_modal_open`'s thresholds too (`SUBSCREEN_HEADER_PROBE` read `(180,227,244)`, r=180 < the 200 both checks need), so `navigation.return_to_home`'s generic cleanup also couldn't recover it — every "are we home" check downstream falsely agreed nothing was wrong, and the whole `q4h` cron run finished "successfully" with the game actually left stuck. Fixed by checking on every `_pat_current_view` poll iteration instead of only right after a pat, so a delayed cutscene appearance is caught (and dismissed) roughly a second later instead of never. **Confirmed live**: manually reproduced the exact stuck state left by the incident and confirmed a single Enter (the same action `_dismiss_rank_up_if_shown` already takes) cleared it back to the real cafe room view; the per-iteration check itself has not yet been re-exercised against a fresh live rank-up trigger end-to-end (same "could not force one on demand" caveat as the original fix). **`SPARKLE_CLICK_OFFSET` recalibrated (2026-07-17)**: reported live with a screenshot (`screenshots/cafe/bug_student_close_together.png`) — when two students stand close together, the original `(75, 47)` offset (ported byte-for-byte from `scripts/detect_and_click.py`, never independently verified) overshoots past the intended target's head and lands on a different, closer student instead; the click doesn't register, so `find_cafe_sparkle()` matches the same still-showing sparkle again next iteration, repeatedly clicking nearly the same point for the rest of the room's budget (confirmed live: 13 of 16 "pats" in one run clustered within a ~10px box). First recalibration attempt, `(0, 0)` (raw template-match center), was also wrong — reported live ("clicking the actual sparkle instead of the student head"): a coincidental ambient thought-bubble animation at the test position had been misread as a hover confirmation. Properly recalibrated via numbered candidate-point overlays (screenshots with several labeled offset options drawn on a real live sparkle; the user picked the correct one directly on-screen) against two independent real students in different poses (sitting at an arcade cabinet, lying on a couch) — both converged on the same small offset, `(51, 15)`. **Confirmed live** via the real `./ba_dailies.sh cafe` CLI path: 6 sparkles patted across both rooms at genuinely distinct coordinates with zero repeated-click clustering, clean `[cafe] Done.` and true-home return. | | Stamina/AP | `module/collect_daily_task_power.py` | `to_tasks`/`implement` | `ba_auto/tasks/stamina.py` | `color.rgb_in_range` → `driver.color_at`; reference's per-tab claim loop → live UI's single "一括受取" bulk-claim button + Enter | Migrated: Mission-panel claim done (see `plan.md` Phase 8). The gem shop's own Daily Free Power flow (`module/collect_daily_free_power.py`) is a separate task now — see the "Gem shop daily free package" row below. **Daily gem reward bugfix (2026-07-16)**: reported live by the user against a real account state — the reference's own `implement` checks TWO separate button regions (the main 一括受取 area, then a second, separate "claim daily pyroxenes" button checked independently after), but only the first was ported originally. Fixed by porting the second region too: a new `MISSION_DAILY_GEM_CLAIM_BUTTON` (config.py), reusing the same bright-yellow-vs-grey color-probe style as the existing `MISSION_CLAIM_PROBE`, clicked via `driver.click` (not Enter — unlike 一括受取, this button has no keybind shown on screen) with a click-then-verify retry loop matching this project's established discipline. **Confirmed live**: gem balance went from 12,384 → 12,404 (exactly the reward's stated +20), clean return to the true home screen, no warnings. | | Gem shop daily free package | `module/collect_daily_free_power.py` | `implement`, `to_purchase_pyroxenes_menu`, `to_purchase_type`, `detect_free_power_availability`, `collect_daily_free_power`, `return_to_main_page` | `ba_auto/tasks/gem_shop.py` | Reference detects every step via `core.picture.co_detect` + fixed-region OpenCV template matching (no OCR anywhere in this reference flow) — ported here as plain color probes instead, matching this project's own established equivalent for a simple flat-color state-A/state-B difference (see `cafe.py`'s `CLAIM_DISABLED_RGB`, `stamina.py`'s `MISSION_CLAIM_PROBE`) rather than building new template assets. The free card's own status bar reads flat dark navy (~(41,65,90)) when available vs flat dark red (~(144,38,47)) once claimed today — a clean, high-contrast signal pixel-scanned from real captures, no OCR of the "0"/"1" count text needed. A dedicated 4-point "is this dialog (or a nested confirm/notice card) showing" check (`GEM_SHOP_DIALOG_PROBES`) was needed because `navigation.is_on_subscreen`/`is_modal_open`'s default probes both proved unreliable on this dialog (it overlays the home screen directly rather than being a full subscreen, and its own opaque white card sits right on top of `is_modal_open`'s probe point) — same class of default-probe mismatch `story_sweep.py`/`bounty.py` already documented for their own wide modals, fixed the same multi-point-beats-single-point way `navigation.is_header_bar_visible` was fixed for cafe's rank-up cutscene the same day. | Done. Live-calibrated against nik-gpu 2026-07-15 by manually driving the real dialog end-to-end via raw xdotool/scrot (see `scratchpad/gem_shop_*.png`) — this **genuinely claimed the account's real free package for the day** (0 yen, confirmed +10 AP / +10,000 credits via before/after counter values: AP 64→74, credits 161,391,144→161,401,144), which calibrated both the "available" and "already claimed" visual states from real data. The actual `gem_shop.py` module was then live-tested for real against the resulting "already claimed today" state. The "available → claim" code path itself uses the same coordinates/logic already confirmed live via the manual walkthrough, but has not yet been exercised by the module itself end-to-end — worth a follow-up live check the next time the package resets and hasn't been claimed yet. Added to `DEFAULT_ORDER` (unlike most recent additions): it only ever reclaims a genuinely free, once-per-day resource with no choice to make, matching mailbox/cafe/stamina's own "reclaim something free" category rather than the "spends a resource, needs opt-in" category. | -| Normal/Hard story AP sweep | `module/explore_tasks/sweep_task.py`, `module/explore_tasks/task_utils.py` | `to_region` (ported: OCR region-number readout + delta-click), a scoped-down `swipe_search_target_str` (ported: OCR stage-row label matching), `start_sweep`'s named-outcome contract (ported via `navigation.wait_for_state`, this project's scoped `co_detect` port) | `ba_auto/tasks/story_sweep.py` | OCR region/stage-name matching, ported for real (Phase 10) — replaces Phase 9's "next-region arrow stops advancing, then random stage" heuristic; MAX click verified via `SWEEP_MINUS_BUTTON_PROBE` color check (reused, still correct), modal closed via its own X button (Escape doesn't close it; X-button position re-calibrated per stage-layout variant, see Phase 10) | Done (see `plan.md` Phase 10, supersedes Phase 9). Config-driven exact `(region, stage, count)` targets (`config.STORY_SWEEP_TARGETS`), not latest-region/random-stage. Opt-in only, not in default flow | +| Normal/Hard story AP sweep | `module/explore_tasks/sweep_task.py`, `module/explore_tasks/task_utils.py` | `to_region` (ported: OCR region-number readout + delta-click), a scoped-down `swipe_search_target_str` (ported: OCR stage-row label matching), `start_sweep`'s named-outcome contract (ported via `navigation.wait_for_state`, this project's scoped `co_detect` port) | `ba_auto/tasks/story_sweep.py` | OCR region/stage-name matching, ported for real (Phase 10) — replaces Phase 9's "next-region arrow stops advancing, then random stage" heuristic; modal closed via its own X button (Escape doesn't close it; X-button position re-calibrated per stage-layout variant, see Phase 10) | Done (see `plan.md` Phase 10, supersedes Phase 9; rewritten Phase 22 to match story_sweep_hard.py's Phase 21 fixes and add its own campaign guard). Config-driven exact `(region, stage, count)` targets (`config.STORY_SWEEP_TARGETS`), not latest-region/random-stage. Opt-in only, not in default flow. **2026-07-21 rewrite (Phase 22)**: per explicit user direction ("match the hard implementation on best practices"), ported story_sweep_hard.py's fixes back into this module — an explicit Normal-tab guard (`config.NORMAL_TAB`/`_open_normal_tab`, needed now that story_sweep_hard can run earlier in the same `q4h` preset and leave Hard selected), a loop-level self-heal (`_ensure_task_screen`) replacing the old per-path "Escape if on subscreen" cleanup that would have backed out of the region browser after every target (never triggered in production since this module only ever swept one rotation target per run, but a real latent bug), MAX/+ clicks no longer requiring a visible stepper raise as proof of success (same low-AP false-negative class Hard hit), `_click_plus` now resets to a known baseline via a new `SWEEP_MIN_BUTTON` before raising (closing a latent version of bounty.py's real overspend bug that this module's original `_click_plus_and_verify` never guarded against), a newly-added `_confirm_dialog_is_sweep` OCR gate against the two-stacked-button hazard (confirmed live 2026-07-21 that Normal's own tabbed modal has the identical gold 任務開始 button beneath 掃討開始 that config.py's own `SWEEP_MAX_BUTTON` comment already documented but this module never actually guarded against), the same `clicked_any`-gated `_watch_sweep_result` fix, and a `REGION_READ_RETRIES` OCR-read retry. Also added a genuinely new campaign-active guard (`config.TASK_CAMPAIGN_BADGE_RECT`, renamed from `HARD_CAMPAIGN_BADGE_RECT` once shared) — confirmed live to be pixel-identical rect/color to Hard's own banner, just reading "任務Normalで獲得できる報酬量が2倍(+100%)になっています" instead of "Hard". Confirmed live that Normal mode has no per-stage daily clear cap (no "残り回数" indicator anywhere), so Hard's gem-refill hazard does not apply here. `story_sweep_force` added as the override command. **Live-tested for real the same day**: a real MAX sweep of the day's rotation target (30-2) succeeded cleanly end-to-end on the first attempt — campaign guard passed, OCR stage-row match succeeded despite the known leading-digit misread ("20-2" read but the "2" suffix still matched), sweep confirmed and correctly detected as `"swept"` (not the old cosmetic misreport), clean return home. AP dropped from ~139 to 1 and credits rose by 13,966, confirming a real substantial MAX sweep. | | Event sweep | `module/sweep_activity.py`, `module/activities/activity_utils.py` | `activity_sweep` (main flow), `to_activity` (nav to the event's Story/Mission/Challenge tabs), `check_sweep_availability`/`color.check_sweep_availability` (SSS gate), `start_sweep`'s named-outcome contract (shared with story_sweep's, ported the same way via `navigation.wait_for_state`) | `ba_auto/tasks/event_sweep.py`, `ba_auto/navigation.py` (`return_to_home`) | `to_activity`'s bottom-nav-icon entry -> this client's home-screen event badge (`config.EVENT_BADGE_ICON`, confirmed live to be a rotating carousel -- see Status); the reference's config-string sweep-list parsing (arbitrary stage lists, per-stage float/fraction counts via `preprocess_activity_region`/`preprocess_activity_sweep_times`) -> a single date-ordinal-modulo rotation target over a fixed 9-12 sub-range, mirroring `story_sweep.py`'s own rotation, per explicit user direction; stage-number OCR (`detector.read_int`) replaces the reference's `swipe_search_target_str` template-button search, since this client's stage list only ever needs its bottom scroll extreme for the 9-12 target range | Done, with a live-reported bug fixed (see `plan.md`'s Phase 14 follow-up). Live-calibrated against nik-gpu 2026-07-10 against the currently-running "鉄道爆走事件" event (12 stages) -- zero real AP spent during calibration (every confirm dialog reached was cancelled via Escape, verified by the AP counter). The stage-info modal is structurally identical to story_sweep's (MIN/-/+/MAX stepper, same AP-confirm dialog reusing `SWEEP_CONFIRM_*`/`SWEEP_RESULT_BUTTON_REGION`), but simpler: no region navigation, one fixed modal layout confirmed across two different stages (09 and 12), and the modal closes on Escape (story_sweep's doesn't). The reference's SSS-availability gate for a never-cleared stage was never exercised (every stage 9-12 on this account was already 3-starred) -- `event_sweep.py` handles that gate the same defensive way story_sweep handles an unavailable target: if the MAX-button count-raise can't be verified, it aborts without spending AP rather than guessing. **Bug found on a real run**: the home-screen event badge turned out to be a rotating carousel (cycles between the current event's countdown and other notices, e.g. a finished event's leftover reward-claim reminder), so a click could land on a stale event's page instead. Fixed via a new shared `navigation.return_to_home` primitive (bounded press-back-until-home loop, built generically so other tasks can reuse it, per explicit user request) plus wrong-page detection in `_find_stage_row` (zero stage-row numbers OCR'd at all -> retry via `return_to_home`, up to 3 attempts). **Second bug found on the next real run** (after the above fix correctly recovered and correctly OCR'd the target row): the row's own 入場 (enter) button click had no retry, unlike every other click-then-confirm step in this module -- missed once, aborted the whole run. Fixed via `_open_stage_modal`, the same click-then-verify-then-retry pattern already used everywhere else in the file. **Third bug found on a third real run**: `_find_stage_row`'s wrong-page detection false-positived twice (a fresh navigation's list hadn't finished rendering on the first OCR pass) before self-correcting on the 3rd attempt, wasting expensive return-home retries on what was really just a timing race -- fixed with a cheap in-place rescan before concluding "wrong page." Also, once past that, the 掃討開始 (start sweep) click turned out to be the last bare, unretried click in the file -- fixed via `_click_sweep_start_and_verify`. **Fourth round, self-driven live iteration per explicit user direction (fix/deploy/run/screenshot/diagnose in a loop, no stopping to report)**: found two more root causes and reached the first confirmed real live sweep. (1) The badge carousel's auto-rotate timer is far slower than the retry window -- a run hit "wrong page" on all 3 attempts genuinely, confirmed by screenshot; fixed by discovering and clicking the carousel's own pagination dots directly (`config.EVENT_BADGE_DOT_X`) instead of hoping the ambiguous badge shows the right item. (2) A genuine cold-start settle delay (up to ~20s for the Quest tab's stage list to populate after a fresh navigation, most likely a one-time server round-trip), not a flaky race -- confirmed via standalone probe scripts polling the real OCR pipeline once/second for a minute; fixed by widening `STAGE_ROW_SCAN_ATTEMPTS`/`_RETRY_WAIT` to match. With both fixes, a real run completed end-to-end: 200 AP spent (10x MAX sweep), credits gained, clean return home, confirmed by screenshot -- see `plan.md`'s Phase 14 follow-up #4 for the full writeup. **Fifth round, reported by the user a day later**: the exact same wrong-page-looping symptom recurred. Per the user's explicit request, added a DIRECT check for the finished event's own "イベント期間が終了しました" text (Japanese OCR, newly installed on nik-gpu since this project previously only needed digit/English reads) instead of the indirect "zero valid rows" heuristic -- `config.EVENT_FINISHED_TEXT_RECT`/`_is_finished_event_page`, live-calibrated and confirmed both ways (exact phrase match on a real finished page, no false positive on the correct page). This immediately proved the badge carousel's dot-clicking (follow-up #4's fix) is NOT reliably controllable after all -- sometimes worked, sometimes didn't, on the identical badge state -- while simply waiting longer let the carousel's own auto-rotate timer land on the correct item independently. Fixed by widening `WRONG_PAGE_RETRIES`/`_WAIT` (3x3s -> 6x12s) to give the timer real room to cycle, keeping dot-clicking as a harmless supplementary nudge. Two more real sweeps confirmed this fully working (11x MAX sweep, 219 AP spent, credits gained, clean home return, confirmed by screenshot -- three real confirmed sweeps total across this whole investigation). Also found and fixed the real root cause of the lingering `"unrecognized_state"` cosmetic bug (not a budget issue after all): `_watch_sweep_result`'s "swept" condition required the stage modal to still be open, but this event's flow can auto-return all the way to the Quest list instead, a terminal state it never anticipated -- fixed with a second `ends` condition gated on having clicked at least one result button first. Not yet re-verified live (AP exhausted by the successful sweep). See `plan.md`'s Phase 14 follow-up #5 for the full writeup. **Sixth round (2026-07-11)**: the daily rotation picked stage 9 for the first time, hitting a previously-flagged-but-never-exercised gap -- rows 366/538 ("08"/"09") consistently misread by OCR ("2" and empty/None) on every psm mode, even though the crop looked completely clean by eye; confirmed NOT a navigation/timing bug since rows 710/883/1055 ("10"/"11"/"12") read fine in the same run. Root cause (`scratchpad/probe_ocr_fix_08_09.py`): tesseract's segmentation fails on this tight edge-to-edge crop (no whitespace margin) for a leading-zero digit pair specifically -- adding a plain white border around the upscaled crop before OCR fixed both digits exactly, at every psm mode, without affecting "10". Fixed via new `detector.read_int_bordered`, now used for all `EVENT_STAGE_ROW_Y` reads. **Confirmed live**: stage 9 found, sweep executed for real (AP 233->14, credits +5,892, screenshot-confirmed safe return home) -- the originally reported bug is fixed. The same run again logged `unrecognized_state`, proving follow-up #5's `clicked_any`-gate fix addressed a real but secondary issue, not the full story. Diagnosed at zero AP cost (`scratchpad/probe_result_button_fp.py`, checks `_find_result_button` against the plain Quest list with no sweep running): the shared `SWEEP_RESULT_BUTTON_REGION` (borrowed from `story_sweep.py`) reaches into this event's own character-art panel and false-positive-matches `SWEEP_CONFIRM_CYAN` there with no dialog showing at all, confirmed on both a wrong event page and the correct one's own plain list -- so `_watch_sweep_result` kept "finding" a result button after the real one was already dismissed, and its "modal closed, no result button" end condition could never match. Fixed with a new event_sweep-only `config.EVENT_SWEEP_RESULT_BUTTON_REGION` (x narrowed to exclude the character-art panel, still comfortably covering the real buttons), confirmed live against the actual false-positive condition (now returns `None` where it previously didn't) -- not yet re-confirmed via a fresh full sweep since AP was too low that day. See `plan.md`'s Phase 14 follow-up #6. | | Circle (Group/Club) daily check-in | `module/group.py` | `implement`, `to_group` | `ba_auto/tasks/circle.py` | Reference's `picture.co_detect` polling against fixed screen positions + `rgb_possible`/`img_possible` template states → this client's own 2-click nav (home → bottom-nav ソーシャル icon → サークル card) verified with the existing shared `navigation.is_modal_open`/`is_on_subscreen` (no new probe needed — pixel-confirmed both real "reached the circle screen" states, with or without the reward modal, satisfy that combined check) | Done. Live-calibrated 2026-07-15 on nik-gpu by driving the real flow end-to-end via raw xdotool/scrot — this **genuinely claimed the account's real circle check-in reward for the day** (+10 AP, confirmed via the real reward dialog "今日のサークルへの参加報酬... AP x10... 報酬はメールボックスから受け取ることができます"), which calibrated both real states from real data: the first-entry reward modal, and (by re-entering immediately after) the already-checked-in straight-to-chat state with no modal. Also live-confirmed a real `XIGNCODE` anti-cheat overlay stole a `BACK_BUTTON`-coordinate click mid-calibration (same recurring gotcha documented elsewhere in this project) — recovered via the standard `windowactivate`+`windowraise` escalation. Per explicit user direction, the task presses Escape directly to return home (confirmed live: a single Escape from the サークル screen returns straight to true home, skipping back through the intermediate ソーシャル hub page) rather than clicking `navigation.BACK_BUTTON`. The reference's `group_join-club` ("not in a circle") outcome is deliberately not ported — this account is already a member, and the task's scope (per explicit user direction) is entry only, no mailbox claim. The actual `circle.py` module was then live-tested for real via `./ba_dailies.sh circle`, correctly reading the "already checked in today" state left over from calibration and returning cleanly home. **Not yet live-tested**: the "first entry → claim reward" code path itself, since the account was already checked in for today by the time the module existed — same disclosed gap shape as gem_shop's own "available → claim" path. Added to `DEFAULT_ORDER` alongside mailbox/cafe/stamina/gem_shop, since it's a pure free reclaim with no decision to make. **A `reference-parity-reviewer` pass the same day caught and fixed three real gaps**: a too-generic terminal-state check that could false-positive "already checked in" from a stuck non-home starting state (fixed via `navigation.return_to_home(driver)` at the top of `run()`, matching arena.py/bounty.py precedent); an unverified reward-dismiss Enter press and closing Escape (fixed via `_dismiss_reward`/`_leave_circle`, bounded retry-until-verified loops directly porting `gem_shop.py`'s `_claim_free_package`/`_close_gem_shop`); and a missing `driver.focus_game()` escalation in the entry retry loop for the `XIGNCODE` overlay (which had already struck live during this task's own calibration) — fixed to match `navigation.click_back`'s own escalation convention. Re-confirmed live via `./ba_dailies.sh circle` after all three fixes. See `plan.md`'s Phase 17 for the full writeup. | | Bounty | `module/rewarded_task.py` | `implement` (main flow, `get_task_count`/`purchase_bounty_ticket`/per-area `rewarded_task_status` loop, not ported -- see Status), `to_bounty`/`to_choose_bounty` (nav), `get_los`/`one_detect`/`bounty_common_operation` (per-row SSS-color scan + sweep) | `ba_auto/tasks/bounty.py` | `to_bounty`'s bottom-nav "bus" icon -> this client's Work-hub `指名手配` card (`config.BOUNTY_CARD`), landing directly on Location Select with no separate bus-icon sub-navigation step; `get_los`/`one_detect`'s per-row `color.check_sweep_availability` SSS-color scan across however many rows are visible -> a fixed bottom-most row click (`config.BOUNTY_LATEST_STAGE_ROW_Y`), since this client's 3 areas each have exactly 10 stages (confirmed live, scrolling past the 10th is a no-op) all already SSS-cleared, making "scroll to the bottom extreme, click the last row" equivalent to "the latest stage available" by construction; the reference's config-string per-area sweep-count list (`rewarded_task_times`, `get_task_count`) and its loop across all 3 areas -> a single date-ordinal-modulo rotation choosing ONE area per run, mirroring story_sweep.py/event_sweep.py's own rotation, per explicit user direction (2026-07-11); `purchase_bounty_ticket` (buying more tickets with real currency) -> not ported, matching plan.md's "OCR optional for coin balance/refresh logic, can skip for first version" | Done, live-tested for real -- see `plan.md` Phase 15. Live-calibrated against nik-gpu 2026-07-11 with **zero real tickets spent** during calibration (every ticket-usage confirm dialog reached was cancelled via Escape, verified by the ticket counter (6/6) unchanged before/after, across all 3 areas). All 3 areas (ハイウェイ/砂漠の線路/校舎, matching the reference's OVERPASS/DESSERT RAILWAY/CLASSROOM groupings) confirmed to share an identical layout: same area-row/stage-row/modal-button coordinates, same 10-stage-per-area structure. The 任務情報 modal's MAX stepper, minus-button raised-color check, and ticket-usage confirm dialog are pixel-identical to event_sweep.py's own stage modal / shared `SWEEP_CONFIRM_*` dialog -- confirmed live -- and reused directly. The modal-open probe could NOT be reused from `EVENT_STAGE_MODAL_PROBE`: that corner point reads dark on this screen regardless of modal state (different background art), so a fresh `BOUNTY_STAGE_MODAL_PROBE` was calibrated that does discriminate both states. **2 real sweeps confirmed live** (credits +180,000 then +36,000, clean automatic return home both times), which surfaced and fixed two real bugs: (1) `_set_sweep_count`'s count==1 path wrongly assumed the modal defaults to count=1 on open -- it actually remembers the last-used count -- causing an unintended 5-ticket spend instead of the intended 1; fixed via a new `_click_min_and_verify` that always forces a known baseline first. (2) `BOUNTY_SWEEP_RESULT_BUTTON_REGION`'s first-guess copy of event_sweep's own region overlapped `BOUNTY_SWEEP_START_BUTTON`'s real cyan pixels, causing `_find_result_button` to re-click it once tickets hit 0 -- which surfaced a real Pyroxene ticket-purchase prompt (no gem actually spent, confirmed by an unchanged balance, but a real near-miss). Fixed three ways: the region was corrected to the real pixel-scanned OK-button bbox; a new `min_pixels` parameter on `detector.find_color_centroid` (plus `config.BOUNTY_RESULT_BUTTON_MIN_PIXELS`) filters out a second, smaller contamination source (stray cyan-range pixels in the modal's own reward-icon artwork); and `_watch_sweep_result` now has an explicit `_is_ticket_purchase_prompt` guard as a named `ends` condition. **Not yet re-confirmed live**: a fresh sweep with the fixes deployed (account at 0/6 tickets as of session end) and any bulk/MAX-count sweep's result-screen flow (only count=1 was ever tested; a bulk sweep may show a SKIP-then-OK sequence like story_sweep/event_sweep instead of the single-OK dialog confirmed here). **Real hazard found and fixed via a real unattended `daily` cron run (2026-07-17)**: reported live by the user -- a real "Battle Complete" screen (a live ~3-minute combat timer) was found on the account instead of an instant sweep, with `_watch_sweep_result` having reported "swept" cleanly and no warnings anywhere in the log; every task that ran for the rest of that `daily` invocation failed to open its own screen. Manually reproducing the flow live (reaching the real confirm dialog and cancelling before confirming, the same safe-calibration pattern the original live-testing used) found the actual hazard: the 任務情報 modal has TWO separate action buttons stacked vertically -- the intended cyan 掃討開始 (start sweep, instant) and a separate gold 任務開始 (start mission, a REAL manual battle) directly below it, both showing an identical ticket-cost preview. Every check along the commit path (`_count_raised_above_one`/`_is_sweep_usage_confirm`/`_watch_sweep_result`'s own "swept" conditions) is a generic color/position probe with no verification of what's actually showing. Fixed with `_confirm_dialog_is_sweep`, an OCR text check (`config.BOUNTY_SWEEP_CONFIRM_TEXT_RECT`, captured live from the real dialog: "指名手配チケットをN使用して、掃討をN回行いますか?") gating the one irreversible click in this flow -- cancels rather than confirms if the dialog doesn't read as a sweep. The exact mechanism that let a real run reach 任務開始 instead of 掃討開始 was not fully reproduced live (would mean deliberately repeating a real battle); the fix closes the hazard regardless of the exact upstream cause. Not yet re-verified live against a fresh real sweep. | diff --git a/ba_auto/tasks/story_sweep.py b/ba_auto/tasks/story_sweep.py index 46fe20f..f8b0d11 100644 --- a/ba_auto/tasks/story_sweep.py +++ b/ba_auto/tasks/story_sweep.py @@ -27,16 +27,65 @@ constants. The MAX-button click-then-verify and the modal's own X-button close (both calibrated and confirmed live in Phase 9) are reused unchanged -- see plan.md Phase 9/10. + +**2026-07-21 rewrite**: this module predates story_sweep_hard.py's own live +debugging (plan.md Phase 21), which found several real bugs in patterns this +module had originally established and story_sweep_hard.py had copied +verbatim. Per explicit user direction ("match the hard implementation on +best practices"), the same fixes are applied here too, even though most were +never actually triggered in production (this module's own +config.STORY_SWEEP_TARGETS is empty by default, so historically only one +rotation target ever ran per invocation -- never enough iterations to expose +a multi-target bug): + +- An explicit Normal-tab guard (`_open_normal_tab`, config.NORMAL_TAB) and a + loop-level self-heal (`_ensure_task_screen`), replacing the removed + per-path "if on subscreen, press Escape" cleanup that used to fire after + *every* target (success or abort) and would back all the way out of the + region browser -- confirmed live as a real bug in story_sweep_hard.py, + same copied code here. The Normal-tab guard is new, not just a bugfix: + story_sweep_hard.py can now run earlier in the same `q4h` preset sequence + and leave the Hard tab selected, which this module never had to account + for before both tasks coexisted. +- `_click_max`/`_click_plus` no longer require the sweep count to visibly + rise above 1 as proof a click landed -- low AP can legitimately cap the + count at its floor, which is visually indistinguishable from "the click + didn't register." `_click_plus` also now forces a known baseline via + `_click_min` before applying "+" raises, porting bounty.py's own + `_click_min_and_verify` fix for a stepper that remembers its last-used + value across opens (see CLAUDE.md/plan.md Phase 15) -- this module's + original `_click_plus_and_verify` never did that, a latent version of the + exact overspend risk bounty.py hit for real. +- `_confirm_dialog_is_sweep` OCR-verifies the confirm dialog's own text + before the one irreversible click, closing the same two-stacked-button + hazard (cyan 掃討開始 directly above a gold 任務開始 real-battle button) + config.py's own SWEEP_MAX_BUTTON comment already documented for this + exact tabbed modal layout but this module never actually guarded against. +- `_watch_sweep_result` gained the same `clicked_any`-gated second `ends` + condition event_sweep.py/story_sweep_hard.py needed: the post-sweep flow + can land back on the bare region browser instead of leaving the modal + open, which the original "swept" condition required. +- `_read_current_region` retries a None OCR read a couple of times before + giving up, matching story_sweep_hard.py's own REGION_READ_RETRIES fix for + a transient render/settle race confirmed live on the same region browser. +- A campaign-active guard, genuinely new (no reference equivalent -- see + story_sweep_hard.py's own docstring for why): refuses to spend AP unless + the pink "キャンペーン中" reward-campaign banner is showing for the Normal + tab specifically (config.TASK_CAMPAIGN_BADGE_RECT, shared with + story_sweep_hard.py's own check -- pixel-identical rect/color, confirmed + live 2026-07-21, only the tab name in the banner text differs). Pass + force=True (wired to the `story_sweep_force` CLI command) to bypass it. """ import datetime from ba_auto import detector, navigation OPEN_RETRIES = 3 +NORMAL_TAB_RETRIES = 3 POST_SWEEP_DISMISS_ROUNDS = 6 STAGE_MODAL_DIM_MAX_CHANNEL = 150 -MAX_BUTTON_RETRIES = 3 MODAL_CLOSE_RETRIES = 3 +REGION_READ_RETRIES = 3 def _is_stage_modal_open(driver, config): @@ -65,6 +114,25 @@ def _is_ap_purchase_prompt(driver, config): return _color_in_range(driver.color_at(*config.SWEEP_CONFIRM_BUTTON), config.SWEEP_CONFIRM_GOLD) +def _confirm_dialog_is_sweep(driver, config): + """OCR-verify the confirm dialog reached after clicking 掃討開始 is + genuinely the sweep-usage confirm ("APをN使用して、掃討をN回行いますか?"), + not some other cyan-styled confirmation that happens to satisfy + _is_sweep_usage_confirm's color-only check -- ports bounty.py's own + _confirm_dialog_is_sweep fix (see that module and story_sweep_hard.py for + the hazard class this guards against: this exact tabbed modal has a gold + 任務開始 "start mission" button directly beneath the cyan 掃討開始 one, + both showing an identical AP-cost preview -- see config.py's + SWEEP_MAX_BUTTON comment). Reuses config.BOUNTY_SWEEP_CONFIRM_TEXT_RECT + directly, confirmed live to crop this modal's confirm text correctly too + since it's the same shared "通知" dialog component. Substring match on + "掃討" rather than an exact match, matching this project's own + established dialog-classification convention. + """ + text = detector.read_text(config.BOUNTY_SWEEP_CONFIRM_TEXT_RECT, psm=6, lang="jpn") + return "掃討" in text + + def _find_result_button(driver, config): # The "掃討完了" (sweep complete) results screen shows a SKIP button # (skips the reward-reveal animation) and then, once settled, a final @@ -74,15 +142,6 @@ def _find_result_button(driver, config): return detector.find_color_centroid(config.SWEEP_RESULT_BUTTON_REGION, *config.SWEEP_CONFIRM_CYAN) -def _count_raised_above_one(driver, config): - # The "-" stepper button is flat grey while count == 1 (its default, - # disabled at the minimum) and turns vivid orange once raised -- cheap - # way to confirm a MAX/"+" click actually registered without needing OCR - # on the count itself. - r, g, b = driver.color_at(*config.SWEEP_MINUS_BUTTON_PROBE) - return r > 200 and g < 180 and b < 100 - - def _open_task_screen(driver, config): for attempt in range(1, OPEN_RETRIES + 1): driver.click(*config.WORK_ICON) @@ -102,8 +161,49 @@ def _open_task_screen(driver, config): return False +def _open_normal_tab(driver, config): + for attempt in range(1, NORMAL_TAB_RETRIES + 1): + driver.click(*config.NORMAL_TAB) + driver.wait(1) + if _color_in_range(driver.color_at(*config.NORMAL_TAB_ACTIVE_PROBE), config.NORMAL_TAB_ACTIVE_RGB): + return True + print(f"[story_sweep] Normal tab not confirmed active (attempt {attempt}/{NORMAL_TAB_RETRIES})") + return False + + +def _campaign_active(driver, config): + return detector.region_contains_color(config.TASK_CAMPAIGN_BADGE_RECT, *config.TASK_CAMPAIGN_BADGE_RGB) + + +def _ensure_task_screen(driver, config): + """Re-verify we're still on the task screen's Normal tab before each + target, self-healing from any navigation drift between targets the same + way ba_daily.py's own _ensure_home does before each task, and the same + way story_sweep_hard.py's own _ensure_hard_screen does between its + targets -- see that function's docstring for the real incident this + pattern was built to fix (a leftover per-path Escape press that backed + out of the region browser after every single target). + """ + if _color_in_range(driver.color_at(*config.NORMAL_TAB_ACTIVE_PROBE), config.NORMAL_TAB_ACTIVE_RGB): + return True + print("[story_sweep] task screen (Normal tab) not confirmed before target -- re-opening") + return _open_task_screen(driver, config) and _open_normal_tab(driver, config) + + def _read_current_region(driver, config): - return detector.read_int(config.REGION_NUMBER_OCR_RECT) + """OCR the current region number, retrying a None read a couple of times + before giving up -- matching story_sweep_hard.py's own fix for a + transient render/settle race confirmed live on this same region browser + (a screenshot that read as None was, moments later, confirmed by eye to + be a perfectly clean and legible digit). + """ + for attempt in range(1, REGION_READ_RETRIES + 1): + cur = detector.read_int(config.REGION_NUMBER_OCR_RECT) + if cur is not None: + return cur + if attempt < REGION_READ_RETRIES: + driver.wait(1) + return None def _region_arrow_visible(driver, config, center): @@ -206,31 +306,51 @@ def _find_stage_row(driver, config, region, stage): return None -def _click_max_and_verify(driver, config): - for attempt in range(1, MAX_BUTTON_RETRIES + 1): - driver.click(*config.SWEEP_MAX_BUTTON) - driver.wait(0.8) - if _count_raised_above_one(driver, config): - return True - print(f"[story_sweep] MAX click not detected (attempt {attempt}/{MAX_BUTTON_RETRIES})") - return False +def _click_min(driver, config): + driver.click(*config.SWEEP_MIN_BUTTON) + driver.wait(0.8) -def _click_plus_and_verify(driver, config, count): - for attempt in range(1, MAX_BUTTON_RETRIES + 1): - for _ in range(count - 1): - driver.click(*config.SWEEP_PLUS_BUTTON) - driver.wait(0.8) - if _count_raised_above_one(driver, config): - return True - print(f"[story_sweep] count-raise via '+' not detected (attempt {attempt}/{MAX_BUTTON_RETRIES})") - return False +def _click_max(driver, config): + """Click the MAX button once. Deliberately does NOT require the sweep + count to visibly rise above 1 as proof the click landed -- see + story_sweep_hard.py's own _click_max for the real incident this fixes + (low AP can legitimately cap the count at its floor, indistinguishable + from "the click didn't register"; 11 real, affordable targets were + wrongly skipped by the old verify-and-abort design in that module before + this fix). The downstream confirm-dialog checks (_is_ap_purchase_prompt/ + _is_sweep_usage_confirm/_confirm_dialog_is_sweep) are the real safety net + regardless of what the stepper visually showed here. + """ + driver.click(*config.SWEEP_MAX_BUTTON) + driver.wait(0.8) + + +def _click_plus(driver, config, count): + """Raise the sweep count to an exact configured value via "+". + + Forces a known baseline of 1 via _click_min first -- the stepper + remembers its last-used value across opens (confirmed live for real via + bounty.py's own overspend incident, plan.md Phase 15/CLAUDE.md), so + clicking "+" count-1 times on top of an unknown starting value could + under- or over-shoot the intended count. This module's original + _click_plus_and_verify never reset to a baseline first -- a latent + version of the exact bug bounty.py hit for real, never triggered here + only because config.STORY_SWEEP_TARGETS has always been empty in + practice. Same as _click_max, does not require a visible post-click + raise as proof of success. + """ + _click_min(driver, config) + for _ in range(count - 1): + driver.click(*config.SWEEP_PLUS_BUTTON) + driver.wait(0.8) def _set_sweep_count(driver, config, count): if count == "max": - return _click_max_and_verify(driver, config) - return _click_plus_and_verify(driver, config, count) + _click_max(driver, config) + else: + _click_plus(driver, config, count) def _close_stage_modal(driver, config): @@ -246,25 +366,28 @@ def _close_stage_modal(driver, config): def _watch_sweep_result(driver, config): - # The reference's start_sweep returns one of "inadequate_ap", - # "charge_challenge_counts", or "sweep_complete" so its caller reacts - # appropriately -- this ports that same named-outcome contract via - # navigation.wait_for_state instead of the old single generic "Done". - # - # Called only after the usage-confirm dialog is already accepted (see - # _sweep_target), so from here it's purely "click through the - # 掃討完了 SKIP/OK screens until the bare stage-info modal reappears." - # Clicking the found button by color (not a keypress) means this never - # risks landing on the underlying "任務開始" button the way a blind - # Enter-press loop would. + """Confirmed live 2026-07-20/21 (story_sweep_hard.py, plan.md Phase 21) + that this shared modal's post-sweep flow can land all the way back on + the bare region browser instead of leaving the 任務情報 modal open -- + the same terminal-state gap event_sweep.py hit historically. Ports + event_sweep.py's/story_sweep_hard.py's own `clicked_any`-gated second + `ends` condition: only treat "no modal, no result button" as "swept" + once we've actually clicked through at least one result-screen button, + so an immediate read on the very first check (before any SKIP/OK + sequence has started) still can't be mistaken for a genuine completion. + """ + clicked_any = {"value": False} + def click_result_button(d): pos = _find_result_button(d, config) if pos: d.click(*pos) + clicked_any["value"] = True d.wait(1.5) ends = { (lambda d, c: _is_stage_modal_open(d, c) and _find_result_button(d, c) is None): "swept", + (lambda d, c: clicked_any["value"] and not _is_stage_modal_open(d, c) and _find_result_button(d, c) is None): "swept", } reactions = { (lambda d, c: _find_result_button(d, c) is not None): click_result_button, @@ -292,18 +415,9 @@ def _sweep_target(driver, config, region, stage, count): if not _is_stage_modal_open(driver, config): print("[story_sweep] stage info panel not detected, aborting") - if navigation.is_on_subscreen(driver): - driver.keypress("Escape") - driver.wait(1.5) return "unrecognized_state" - if not _set_sweep_count(driver, config, count): - print("[story_sweep] could not confirm sweep count was raised, aborting without spending AP") - _close_stage_modal(driver, config) - if navigation.is_on_subscreen(driver): - driver.keypress("Escape") - driver.wait(1.5) - return "unrecognized_state" + _set_sweep_count(driver, config, count) driver.click(*config.SWEEP_START_BUTTON) driver.wait(1.5) @@ -313,17 +427,22 @@ def _sweep_target(driver, config, region, stage, count): driver.click(*config.SWEEP_CONFIRM_CANCEL_BUTTON) driver.wait(1) _close_stage_modal(driver, config) - if navigation.is_on_subscreen(driver): - driver.keypress("Escape") - driver.wait(1.5) return "inadequate_ap" if not _is_sweep_usage_confirm(driver, config): print("[story_sweep] sweep-usage confirmation not detected, aborting without further input") _close_stage_modal(driver, config) - if navigation.is_on_subscreen(driver): - driver.keypress("Escape") - driver.wait(1.5) + return "unrecognized_state" + + # Hard safety gate before the one irreversible click in this whole flow + # -- see module docstring / bounty.py's own real incident. Color already + # matched above (_is_sweep_usage_confirm); verify the actual dialog text + # too before committing rather than trusting color alone. + if not _confirm_dialog_is_sweep(driver, config): + print("[story_sweep] confirm dialog text did not read as a sweep confirmation -- cancelling without confirming") + driver.click(*config.SWEEP_CONFIRM_CANCEL_BUTTON) + driver.wait(1) + _close_stage_modal(driver, config) return "unrecognized_state" driver.click(*config.SWEEP_CONFIRM_BUTTON) @@ -334,11 +453,6 @@ def _sweep_target(driver, config, region, stage, count): if not _close_stage_modal(driver, config): print("[story_sweep] warning: could not confirm stage info modal closed -- leaving it open rather than pressing further keys blindly") - return outcome - - if navigation.is_on_subscreen(driver): - driver.keypress("Escape") - driver.wait(1.5) return outcome @@ -351,7 +465,7 @@ def _rotation_target(config): return (region, stage, config.STORY_SWEEP_ROTATION_COUNT) -def run(driver, config): +def run(driver, config, force=False): driver.focus_game() targets = list(config.STORY_SWEEP_TARGETS) @@ -368,7 +482,22 @@ def run(driver, config): print("[story_sweep] could not confirm task screen is open, aborting without pressing further keys") return + if not _open_normal_tab(driver, config): + print("[story_sweep] could not confirm Normal tab is selected, aborting without pressing further keys") + return + + if force: + print("[story_sweep] force=True -- skipping campaign check") + else: + if not _campaign_active(driver, config): + print("[story_sweep] no active Normal-task reward campaign detected -- skipping sweep (run story_sweep_force to override)") + return + print("[story_sweep] campaign confirmed active, proceeding") + for region, stage, count in targets: + if not _ensure_task_screen(driver, config): + print(f"[story_sweep] could not confirm/recover the task screen before target {region}-{stage} -- stopping") + break outcome = _sweep_target(driver, config, region, stage, count) if outcome == "inadequate_ap": print("[story_sweep] insufficient AP -- stopping, not attempting remaining targets") diff --git a/ba_auto/tasks/story_sweep_hard.py b/ba_auto/tasks/story_sweep_hard.py index 883402b..2340f11 100644 --- a/ba_auto/tasks/story_sweep_hard.py +++ b/ba_auto/tasks/story_sweep_hard.py @@ -17,9 +17,11 @@ always-exactly-3 fixed stage rows with no scrolling/OCR-label search needed campaign-active guard with no reference equivalent at all (baas-reference has no concept of a drop-rate campaign) -- per explicit user direction, this checks for the pink "キャンペーン中" reward-campaign banner (config. -HARD_CAMPAIGN_BADGE_RECT) before spending any AP, since Hard sweep is only -worth running while Hard-task rewards are boosted. Pass force=True (wired to -the `story_sweep_hard_force` CLI command) to bypass that guard. +TASK_CAMPAIGN_BADGE_RECT, shared with story_sweep.py's own Normal-mode +campaign check added 2026-07-21 -- pixel-identical rect/color, confirmed +live) before spending any AP, since Hard sweep is only worth running while +Hard-task rewards are boosted. Pass force=True (wired to the +`story_sweep_hard_force` CLI command) to bypass that guard. Also carries forward a hard lesson from bounty.py's own real incident (see CLAUDE.md/plan.md): the stage-info modal has TWO stacked action buttons -- @@ -158,7 +160,7 @@ def _open_hard_tab(driver, config): def _campaign_active(driver, config): - return detector.region_contains_color(config.HARD_CAMPAIGN_BADGE_RECT, *config.HARD_CAMPAIGN_BADGE_RGB) + return detector.region_contains_color(config.TASK_CAMPAIGN_BADGE_RECT, *config.TASK_CAMPAIGN_BADGE_RGB) def _ensure_hard_screen(driver, config): diff --git a/ba_daily.py b/ba_daily.py index b69f33e..b101630 100644 --- a/ba_daily.py +++ b/ba_daily.py @@ -13,9 +13,14 @@ TASKS = { "gem_shop": gem_shop.run, "circle": circle.run, "story_sweep": story_sweep.run, + # Bypasses story_sweep's campaign-active guard -- see that module's + # docstring and config.TASK_CAMPAIGN_BADGE_RECT for why the guard exists + # (no reference equivalent; per explicit user direction, added 2026-07-21 + # to match story_sweep_hard's own guard). + "story_sweep_force": lambda d, c: story_sweep.run(d, c, force=True), "story_sweep_hard": story_sweep_hard.run, # Bypasses story_sweep_hard's campaign-active guard -- see that module's - # docstring and config.HARD_CAMPAIGN_BADGE_RECT for why the guard exists + # docstring and config.TASK_CAMPAIGN_BADGE_RECT for why the guard exists # (no reference equivalent; per explicit user direction, Hard sweep # normally refuses to spend AP without an active reward campaign). "story_sweep_hard_force": lambda d, c: story_sweep_hard.run(d, c, force=True), @@ -27,15 +32,16 @@ TASKS = { "bounty": bounty.run, "exit_game": exit_game.run, } -# story_sweep, story_sweep_hard (and its story_sweep_hard_force variant), -# event_sweep, both shop tasks, lesson, arena, and bounty are opt-in only -# (not in the default flow): they spend AP/credits/tactical coin/lesson -# tickets/an arena ticket/a bounty ticket on an automated choice rather than -# reclaiming something free, which is a real resource decision the default -# unattended run shouldn't make blindly. story_sweep_hard additionally has -# its own campaign-active guard on top of being opt-in (see that module's -# docstring) -- story_sweep_hard_force is a second, separate opt-in command -# for bypassing that guard, not something DEFAULT_ORDER/PRESETS should ever +# story_sweep (and its story_sweep_force variant), story_sweep_hard (and its +# story_sweep_hard_force variant), event_sweep, both shop tasks, lesson, +# arena, and bounty are opt-in only (not in the default flow): they spend +# AP/credits/tactical coin/lesson tickets/an arena ticket/a bounty ticket on +# an automated choice rather than reclaiming something free, which is a real +# resource decision the default unattended run shouldn't make blindly. Both +# story_sweep and story_sweep_hard additionally have their own campaign-active +# guard on top of being opt-in (see each module's own docstring) -- +# story_sweep_force/story_sweep_hard_force are separate opt-in commands for +# bypassing those guards, not something DEFAULT_ORDER/PRESETS should ever # invoke unattended. Arena specifically # fights a real ranked PvP battle each run -- see ba_auto/tasks/arena.py's # module docstring. gem_shop and circle are the opposite case -- like @@ -77,7 +83,8 @@ PRESETS = { "gem_shop", "mailbox", "stamina", "event_sweep", "exit_game", ], "q4h": [ - "login", "cafe", "mailbox", "stamina", "story_sweep_hard", "event_sweep", "exit_game" + "login", "cafe", "mailbox", "stamina", "story_sweep_hard", + "event_sweep", "story_sweep", "exit_game" ] } diff --git a/plan.md b/plan.md index 66c6f23..51741c2 100644 --- a/plan.md +++ b/plan.md @@ -1003,6 +1003,33 @@ Implemented `ba_auto/tasks/story_sweep_hard.py` and `config.HARD_STORY_SWEEP_TAR **Net real result across the session**: multiple genuine Hard sweeps confirmed for real -- H18-3 and H30-3 both fully exhausted their daily 3/3 clears (60 AP each), and six more targets (H28-3, H17-3, H13-3, H23-2, H16-1, H20-3) swept for real in the final run before AP ran low. Zero AP was wasted once the false-negative MAX-click bug was fixed. Zero gems were ever spent despite deliberately reproducing the real-money hazard twice for calibration. By the final run, all eight already-exhausted-today targets (H18-3, H30-3, H17-2, H15-2, H14-3, H10-3, H14-2, H7-1) correctly self-skipped via the new pre-check without ever reaching the gem-refill dialog. +### Phase 22: story_sweep.py rewrite to match story_sweep_hard.py's Phase 21 fixes, plus its own campaign guard (2026-07-21) + +Direct follow-up to Phase 21, per explicit user direction: "story_sweep was implemented long time ago so it might have old/buggy code. Try to match the hard implementation on best practices." This module predates story_sweep_hard.py's own live debugging and had several of the same latent bugs, since story_sweep_hard.py had copied its patterns verbatim in the first place. + +The user also asked for a campaign-active guard on Normal mode, matching Hard's -- Normal mode was confirmed to be under its own real 2x reward campaign at the time. + +Live investigation on nik-gpu (2026-07-21) before making any change confirmed: + +- The reward-campaign banner is pixel-identical (same rect, same color) between the Normal and Hard tabs -- only the tab name in the text differs ("任務Normalで獲得できる報酬量が2倍(+100%)になっています" vs Hard's own "任務Hard..."). `config.HARD_CAMPAIGN_BADGE_RECT`/`_RGB` were renamed to the generic `config.TASK_CAMPAIGN_BADGE_RECT`/`_RGB` and are now shared by both modules rather than duplicated. +- Normal mode's own tabbed stage-info modal has the identical two-stacked-action-button hazard (gold 任務開始 directly beneath cyan 掃討開始) that `config.py`'s own `SWEEP_MAX_BUTTON` comment already documented for this exact layout, but `story_sweep.py` had never actually added an OCR gate against it (unlike bounty.py/story_sweep_hard.py, both of which have `_confirm_dialog_is_sweep`). +- Normal mode has no per-stage daily clear-count cap at all -- no "残り回数" indicator anywhere in its modal, confirming the Hard-specific gem-refill hazard (Phase 21, item 3) genuinely does not apply here; nothing else needed on that front. +- The Normal/Hard toggle tab needed its own explicit guard for a new reason Hard never had to consider: with `story_sweep_hard` now able to run earlier in the same `q4h` preset sequence (the user's own recent edit), it can leave the Hard tab selected, and `story_sweep.py` had always implicitly assumed Normal was already active. + +Rewrote `ba_auto/tasks/story_sweep.py`, porting every fix from Phase 21 even though most had never actually been triggered in production (this module's own `config.STORY_SWEEP_TARGETS` has always been empty, so historically only one rotation target ever ran per invocation -- never enough iterations for a multi-target bug to surface): + +- `_open_normal_tab`/`_ensure_task_screen` (new): explicit Normal-tab click-and-verify, plus a loop-level self-heal check before each target, replacing the removed per-path "if on subscreen, press Escape" cleanup -- the same latent bug Phase 21 found and fixed in story_sweep_hard.py, copied here originally, never triggered here only because of the single-target-per-run history above. +- `_click_max`/`_click_plus` (renamed from `_click_max_and_verify`/`_click_plus_and_verify`): dropped the "must visibly raise above the floor" abort gate, same false-negative class Phase 21 found under low AP. +- `_click_plus` also gained a `_click_min` reset-to-baseline step (new `config.SWEEP_MIN_BUTTON`) before applying "+" raises -- porting bounty.py's own real fix (plan.md Phase 15) for a stepper that remembers its last-used value across opens. This module's original `_click_plus_and_verify` never did this; a latent version of the exact bug bounty.py hit for real, just never exercised since no configured target has ever used a non-"max" count in practice. +- `_confirm_dialog_is_sweep` (new): OCR gate against the two-stacked-button hazard, reusing `config.BOUNTY_SWEEP_CONFIRM_TEXT_RECT` directly (confirmed live to crop this modal's own confirm text correctly too). +- `_watch_sweep_result` gained the same `clicked_any`-gated second `ends` condition. +- `_read_current_region` gained the same `REGION_READ_RETRIES` retry-on-`None` wrapper. +- `_campaign_active` (new): the actual feature request, using the shared `config.TASK_CAMPAIGN_BADGE_RECT`/`_RGB`. `run()` gained a `force=False` parameter; `story_sweep_force` was added to `ba_daily.py`'s `TASKS` as the override command, mirroring `story_sweep_hard_force`. + +`config.py` additions: `NORMAL_TAB`/`NORMAL_TAB_ACTIVE_PROBE`/`NORMAL_TAB_ACTIVE_RGB` (live-calibrated 2026-07-21 -- Normal's own active-tab color is dark navy, a different accent than Hard's red), `SWEEP_MIN_BUTTON` (live-calibrated against the same MIN/-/+/MAX row as the existing MAX/PLUS/MINUS-probe constants). + +**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. + ## Prerequisites ### OCR