From 509fb27fd8daf6326a021302852ce2bc04933b8f Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Sat, 1 Aug 2026 22:49:09 +0900 Subject: [PATCH] Refactor Common Shop item selection to use OCR-based name matching - Updated `COMMON_SHOP_TARGETS` to use item names and expected prices instead of fixed grid positions. - Introduced `SHOP_NAME_OCR_OFFSET` for accurate name recognition in the shop grid. - Implemented `select_targets_by_name` function to handle item selection based on OCR'd names and prices, addressing issues with item position drift due to sold-out items. - Enhanced the purchasable button check to rely on blue tint rather than brightness, ensuring accurate detection of available items. - Adjusted `run_shop_tab` to utilize the new selection function for Common Shop tasks. - Documented changes and rationale in `plan.md`, including live testing results confirming successful item purchases. --- CLAUDE.md | 6 +- ba_auto/config.py | 96 ++++++++++++++++++------ ba_auto/reference_notes/mapping.md | 2 +- ba_auto/tasks/shop_common.py | 1 + ba_auto/tasks/shop_utils.py | 114 ++++++++++++++++++++++++++++- plan.md | 26 +++++++ 6 files changed, 215 insertions(+), 30 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 99e4e0c..e5c3ef2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -701,9 +701,9 @@ Current project state: mailbox, cafe, stamina, story_sweep, story_sweep_hard, ev - `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 -- `ba_auto/tasks/shop_common.py` and `ba_auto/tasks/shop_tactical.py` share a checkbox-grid-then-bulk-buy flow (`ba_auto/tasks/shop_utils.py`) against config-driven `(row, col, name, expected_price)` targets (`config.COMMON_SHOP_TARGETS` / `config.TACTICAL_SHOP_TARGETS`) -- item identification is by fixed grid position, not per-item OCR — the reference's own `get_item_position` indexes an external static price table this repo doesn't have, so a locally pixel-scanned position table is the faithful port, not an OCR-avoidance shortcut; price-digit OCR is layered on top as an extra catalog-drift safety check the reference doesn't even do per-item -- both shops were live-tested with real purchases (see `plan.md` Phase 11), which also surfaced a real, previously-unknown per-refresh-cycle purchase cap on these items (not shown as a visible counter) — the task correctly detected the now-unselectable items and safely declined rather than misfiring +- `ba_auto/tasks/shop_common.py` and `ba_auto/tasks/shop_tactical.py` share a checkbox-grid-then-bulk-buy flow (`ba_auto/tasks/shop_utils.py`); Tactical Shop still targets fixed `(row, col, name, expected_price)` positions (`config.TACTICAL_SHOP_TARGETS`) since its one-row, two-item grid has never been observed to reorder +- Common Shop (`config.COMMON_SHOP_TARGETS`, now `(name, expected_price)` pairs) originally used the same fixed-position design — the reference's own `get_item_position` indexes an external static price table this repo doesn't have, so a locally pixel-scanned position table was the faithful port, not an OCR-avoidance shortcut — but **that broke live** (2026-08-01): this shop sinks sold-out items to the bottom of its (scrollable, ~24-slot) grid and shifts everything after them up, silently invalidating any fixed `(row, col)` mapping the moment anything sells out. Fixed with `shop_utils.select_targets_by_name`, which scans/scrolls the grid and identifies each target by OCR'd item name instead, verified against an exact price match (so an item's escalated repeat-purchase-tier price, confirmed live at exactly 2x its base price once that sells out, is deliberately never matched) plus a purchasable-button color check. See `plan.md` Phase 25 follow-up #2 for the full incident, including a second live bug in the purchasable check itself — the buy button's fill genuinely shimmers/animates, so a brightness-based check was fooled; fixed by checking blue color *tint* (`B-R`) instead, which stays constant across the animation +- both shops were live-tested with real purchases (see `plan.md` Phase 11), which also surfaced a real, previously-unknown per-refresh-cycle purchase cap on these items (not shown as a visible counter) — the task correctly detected the now-unselectable items and safely declined rather than misfiring; Common Shop's name-based rewrite above was re-verified live post-fix (real purchase of the one item actually available, others correctly logged as unavailable rather than misdiagnosed) - `ba_auto/tasks/lesson.py` sweeps every unlocked region's schedule grid (a scrollable list of 12 named regions, each opening a grid modal of up to 9 location cards), picking the highest-affection available lesson each time until lesson tickets or lessons run out - per-cell affection is read via `detector.read_int_on_heart_badge`, a dedicated OCR path for the pink/magenta heart-shaped badge — the project's normal grayscale-threshold OCR (`read_int`) misreads it, because the badge's own outline stroke survives the same threshold as the digit glyph; a "done today" portrait keeps its number and gets a green checkmark added alongside it rather than losing the number, so done-ness is checked via that checkmark's color, not inferred from a failed OCR read - lesson was live-tested with real tickets spent (see `plan.md` Phase 12), which surfaced two real bugs from that assumption gap plus an OCR contamination issue — both fixed; see Phase 12 for the full writeup diff --git a/ba_auto/config.py b/ba_auto/config.py index 56e0c3f..9c0a878 100644 --- a/ba_auto/config.py +++ b/ba_auto/config.py @@ -772,19 +772,61 @@ SHOP_CHECKED_RGB = ((60, 130, 80), (220, 245, 115)) # for the largest configured price without bleeding into the neighboring # column's card. SHOP_PRICE_OCR_OFFSET = (48, 166, 145, 195) -# A real live run (script_error.md, 2026-07-31) misread '中級レポート's -# price as 1250060 instead of 125000 -- a stray extra digit, not a -# systematically wrong crop (every other target, including three other -# 6-digit prices in this same list, read correctly in that same run, and -# re-summing the run's own "bought N item(s) for TOTAL credits" log against -# every OTHER target's price confirms it). One-off tesseract flake, same -# class of transient misread this project already retries elsewhere (see -# driver.read_screenshot's decode-retry doc) rather than a calibration -# problem -- so shop_utils.select_targets re-reads the price up to this -# many times, taking the first read that matches expected_price, before -# concluding the catalog actually changed. +# A real live run (script_error.md, 2026-07-31) misread '中級レポート's price +# as 1250060 instead of 125000, and this recurred identically every day for +# several days afterward -- initially (mis)diagnosed as a one-off tesseract +# flake and "fixed" with the retry loop below, but the real cause (found by +# live screenshot inspection, 2026-08-01) is that this shop sinks any +# sold-out item to the bottom of the grid and shifts everything after it +# up by one slot -- '初級レポート' (originally col 0) sold out and sank, +# sliding '中級レポート' from col 1 into col 0, so the fixed-position config +# was reading whatever backfilled col 1 instead. Retries alone can't fix a +# deterministically-wrong position, only a genuinely flaky single read -- +# kept for that narrower case (and still used by the position-based +# select_targets, i.e. the Tactical Shop path below), but Common Shop's own +# targets are now matched by OCR'd item name instead (see +# shop_utils.select_targets_by_name), immune to this reordering. SHOP_PRICE_OCR_RETRIES = 3 +# Item-name-text crop, as an (x1, y1, x2, y2) offset added to a cell's own +# (col_x, row_y) -- used by shop_utils.select_targets_by_name to identify +# an item by its name rather than assuming a fixed grid position (see +# SHOP_PRICE_OCR_RETRIES's comment above for why position alone isn't +# reliable). Pixel-scanned and OCR-tested live against every current +# Common Shop target name (single-line names only -- two-line-wrapping +# names like the bundle/material items sit differently and aren't covered, +# but none of the configured targets below wrap). +SHOP_NAME_OCR_OFFSET = (-75, -105, 160, -45) +# A point inside each cell's own buy-button bar. First calibrated against +# two downloaded screenshots as a plain brightness check (purchasable +# bright ~(235,241,241) vs. sold-out/greyed ~172-174 flat) -- but a live +# same-session A/B (2026-08-01, after the button-color check found nothing +# purchasable on its first real run) showed the button's fill genuinely +# shimmers/animates: the SAME real purchasable button read anywhere from a +# dark navy (45,70,99) to a bright cyan (126,222,253) across different +# moments, sometimes dimmer than the sold-out reference. What stays +# constant regardless of animation phase is the blue TINT, not brightness: +# purchasable consistently reads B-R of +120 to +127 (any phase sampled), +# sold-out/greyed consistently reads B-R within +/-2 (flat R==G==B, no +# tint at all, confirmed across all 8 grid positions in two separate +# screenshots). SHOP_BUTTON_PURCHASABLE_MIN_BLUE_TINT is the (B-R) +# threshold, set well clear of both clusters. +SHOP_BUTTON_PROBE_OFFSET = (90, 210) +SHOP_BUTTON_PURCHASABLE_MIN_BLUE_TINT = 40 +# One driver.scroll(..., clicks=N) call at this many clicks reliably +# advances the grid by exactly one row (confirmed live: the item at +# row_y[1] before the scroll reappears at row_y[0] after it, same name and +# price) -- so select_targets_by_name can scan two full rows per +# screenshot and scroll by exactly one row's worth between reads, without +# skipping or double-reading a row. +SHOP_SCROLL_STEP_CLICKS = 3 +SHOP_SCROLL_POINT = (1310, 485) +# Hard cap on scroll steps for select_targets_by_name, independent of its +# own "screenshot unchanged after scrolling" bottom-of-list detection -- +# the live catalog was ~6 rows deep (24 slots) when this was built, so this +# leaves headroom without scrolling forever if that detection ever misses. +SHOP_NAME_SCAN_MAX_STEPS = 12 + SHOP_BUY_BUTTON = (1751, 1112) SHOP_CANCEL_BUTTON = (1525, 1112) # A corner point that both the purchase-confirm dialog and the post-purchase @@ -809,19 +851,27 @@ CREDIT_BALANCE_OCR_RECT = (1040, 15, 1290, 55) # the item grid on that tab specifically (not in the top status bar). TACTICAL_COIN_OCR_RECT = (1090, 100, 1290, 150) -# (row, col, item name (log/debug only -- identification is by grid -# position, see shop_utils.py's module docstring for why), expected -# credit-point price). Buy-list confirmed with the user; all 8 are visible -# without scrolling. +# (item name, expected credit-point price) -- matched by OCR'd name +# wherever it currently sits in the (scrollable) grid, not by fixed +# position; see SHOP_PRICE_OCR_OFFSET's comment above for why position +# alone broke. Buy-list confirmed with the user, 2026-08-01, after the +# catalog turned out to have grown well beyond these original 8 items +# (bundle packs, crafting-material items) -- those new items were +# deliberately left out of auto-buy for now, user's own call. Each of the +# 4 "強化珠" tiers also now has a second, escalated-price repeat-purchase +# slot once its base slot sells out for the cycle (confirmed live: exactly +# 2x the price below) -- select_targets_by_name only ever matches the +# exact price below, so it deliberately never chases that escalated tier, +# also the user's own call (skip skip, don't overspend chasing it). COMMON_SHOP_TARGETS = [ - (0, 0, "初級レポート", 12500), - (0, 1, "中級レポート", 125000), - (0, 2, "上級レポート", 300000), - (0, 3, "最上級レポート", 500000), - (1, 0, "初級強化珠", 10000), - (1, 1, "中級強化珠", 40000), - (1, 2, "上級強化珠", 96000), - (1, 3, "最上級強化珠", 128000), + ("初級レポート", 12500), + ("中級レポート", 125000), + ("上級レポート", 300000), + ("最上級レポート", 500000), + ("初級強化珠", 10000), + ("中級強化珠", 40000), + ("上級強化珠", 96000), + ("最上級強化珠", 128000), ] # (row, col, item name, expected tactical-coin price). Buy-list confirmed diff --git a/ba_auto/reference_notes/mapping.md b/ba_auto/reference_notes/mapping.md index 7307fd0..23bf0c1 100644 --- a/ba_auto/reference_notes/mapping.md +++ b/ba_auto/reference_notes/mapping.md @@ -17,7 +17,7 @@ Maps each local feature to the corresponding `~/repo/baas-reference/module/...` | Commissions | `module/clear_special_task_power.py` | Need to inspect | `ba_auto/tasks/commission.py` | sweep/color adaptation | Not started | | Exit game | None -- searched `~/repo/baas-reference/` thoroughly, no counterpart exists. The closest match, `core/Baas_thread.py`'s `shutdown()`/`start_shutdown()` (line ~984), is an optional full Windows OS shutdown (`subprocess.run(["shutdown", "-s", "-t", "60"])`) gated by a user toggle, not a game-client exit -- out of scope (this project's game and X display run on `nik-gpu`, shutting the host down would kill the SSH session too, nothing like what was requested). The only other app-closing call, `connection.py`'s `close_current_app` (line 381, plain ADB `app_stop`), exists purely for `Baas_thread.py`'s own error-recovery restarts (`deal_with_package_incorrect`/`deal_with_func_call_timeout`) inside a persistent background scheduler that's designed to keep running indefinitely -- it never deliberately exits once daily tasks finish. This feature is new, desktop-specific convenience logic motivated by this project's different architecture (cron launches a fresh one-shot process per preset, so closing the game after a run has real value the reference's always-on scheduler never needed), not a port of anything. | `ba_auto/tasks/exit_game.py` | No new driver/detector primitives needed -- reuses `navigation.return_to_home` (must-be-true-home gate), `navigation.is_modal_open` (verifies the Escape-triggered "exit the game?" dialog actually opened, the same generic dim-probe reused across bounty/gem_shop/circle's own confirm dialogs), `driver.keypress("Escape"/"Return")`, and `driver.window_exists()` (verifies the game process/window is actually gone afterward, not just that Enter was sent) | Done. `navigation.return_to_home`'s own docstring already documents the mechanism this relies on: an Escape press on the confirmed true home screen (nowhere else) raises this exact dialog -- every other task treats that as a hazard to avoid triggering by accident; this is the one task that wants it, gated the same way (only fires from a verified true-home state). No force-kill fallback if the graceful Escape/Enter path doesn't verify -- matches this project's established "abort cleanly on unknown state rather than guess" convention (gem_shop/bounty), rather than reaching for `driver.kill_game()`. Opt-in only, appended to the end of the `daily`/`q4h` presets (not `DEFAULT_ORDER`), since it deliberately ends the session. **Confirmed live (2026-07-18)** via a standalone `./ba_dailies.sh exit_game` run, user-reported "works well" -- `is_modal_open`'s generic dim-probe correctly read the exit-confirmation dialog after Escape, and the game closed cleanly after Enter. Not yet exercised as the tail end of a full `daily`/`q4h` preset run (only standalone so far), though nothing in its own logic depends on which task ran before it. | | Arena | `module/arena.py` | `implement` (main flow), `to_tactical_challenge` (nav from main page), `get_tickets` (ticket-count OCR), `choose_enemy` (self/opponent level OCR + bounded refresh-reroll loop), `check_skip_button` (skip-toggle color probe), `fight` (click fight, wait for win/lose), `collect_tactical_challenge_reward` (two reward-slot color probes) | `ba_auto/tasks/arena.py` | Ticket count/self level/opponent level/rank are plain digit OCR (`detector.read_int`/`read_text`, plus a new `read_int_white_on_dark` for the profile card's bright-on-dark level text). Skip-toggle state and the two reward-slot claimed-vs-claimable colors are plain pixel-color probes (`driver.color_at`/`_color_in_range`). The post-fight WIN/LOSE result modal and an unrelated list-refresh-expired notice are NOT detected by precisely locating a button (a color-region search proved unreliable — see Status); they're dismissed via a bounded blind-Enter-press loop matching `lesson.py`'s own `_run_one_schedule` pattern, gated by a hard safety check against the one modal where Enter is dangerous (opponent-info's own attack-formation button, checked via its fixed-position gold button). `choose_enemy`'s refresh-reroll loop is direct Python control flow, bounded by `maxArenaRefreshTimes`. Config knobs carried over from reference defaults: `ArenaComponentNumber`=1, `ArenaLevelDiff`=0, `maxArenaRefreshTimes`=10, `ArenaStopFightWhenRank1`=False | Done. Live-tested for real across all 5 of the account's daily tickets (2 WIN, 1 LOSE, 2 spent debugging the result-modal detection — see `plan.md` Phase 13 for the full writeup). Real navigation differences confirmed live: Tactical Challenge is a Work-hub card, not a bottom-nav icon; the reference's separate opponent-info and formation-edit screens are merged into one modal here with a live ticket-preview; `navigation.is_modal_open`'s shared probe reads *inverted* on this screen (own `_is_modal_open` via `config.ARENA_MODAL_PROBE`). Three real bugs fixed: a level-OCR crop too small for tesseract despite looking legible (fixed by widening the crop, not the pipeline); level text being bright-on-dark unlike every other OCR read in this project (fixed via `read_int_white_on_dark`); and the result-modal detection cycling through two failed color-based designs (fixed by switching to bounded blind-Enter dismissal with a hard safety gate — a real near-miss of the same "mistimed keypress" hazard class `CLAUDE.md` already documents from story_sweep). Deliberately opt-in only, never in `DEFAULT_ORDER` — unlike every other opt-in task so far (which spend a known-safe resource on a config-driven target list), this one fights a real ranked PvP battle that can win or lose and moves the account's actual arena rank. Per explicit user decision: fights exactly one battle per invocation, matching the reference's own per-call pacing (its `next_time = 55` background-thread rescheduling has no equivalent in this project's one-shot CLI). Not yet exercised live: the "no ticket" mid-flow race, an actual reroll click (every opponent offered was already an acceptable level), and `ArenaStopFightWhenRank1`'s rank-1 stop condition — all implemented per the reference's logic, just not yet hit by real game state. `detector.find_template`/`template_visible` (generalized named-template matcher, built during scaffolding) ended up unused — state detection stayed OCR/color-probe-driven throughout, like every other task in this project. Follow-up (2026-07-11): live bug report — this task never returned home at the end, so a second consecutive invocation starting from wherever the first left the game (the Tactical Challenge screen itself) sent `_open_tactical_challenge`'s home-relative clicks to the wrong place and failed all 3 retries. Fixed by calling the shared `navigation.return_to_home(driver)` at the very start of `run()`, reusing the generic Escape-based recovery primitive built for `event_sweep.py`'s own wrong-page recovery. Confirmed live (2026-07-11): reproduced the stuck-on-arena-screen scenario manually, then a real `arena` invocation recovered and completed a full fight normally (rank 14位's opponent-list entry moved 10位→9位, ticket 2→1, credits +1,080), with no repeat of the original failure. Follow-up #2 (2026-07-12), per explicit user request: reversed the original "exactly one battle per invocation" decision — `_fight_one` now holds the single-battle logic and `run()` loops it (`while tickets > 0 and fights < config.ARENA_MAX_FIGHTS_PER_RUN`), re-reading the OCR'd ticket count after each fight and waiting `config.ARENA_POST_BATTLE_COOLDOWN` (30s, per the user's own info about the real in-game lockout between fights) before continuing. `ArenaStopFightWhenRank1` is now re-checked before every fight in the loop, not just once. See `plan.md`'s Phase 13 follow-up #2 — not yet live-tested (spends multiple real tickets, needs the user's go-ahead first). **This DID subsequently run live via cron and surfaced a real hazard (2026-07-17)**: the log showed "battle skip already on" for all 5 fights and a clean "fought 5 battle(s) this run", but the account was left showing a real "Battle Complete" screen with a ~3-minute combat timer -- battles had run in full, not skipped -- and every task for the rest of that `daily` invocation failed to open its own screen. Two compounding bugs: (1) `_ensure_skip_on`'s "on" detection was never actually verified to reject a real "off" state (`ARENA_SKIP_ON_RGB` was only ever confirmed against a session where skip happened to already be on); (2) `_wait_for_result` had no early-exit signal and always ran its full fixed 15s budget before unconditionally declaring success -- far too short for a real battle -- and `_fight_one` discarded `_wait_for_result`'s return value entirely, so even a correctly detected failure never stopped the fight loop. Fixed by widening `RESULT_MODAL_MAX_POLLS` substantially (tolerating a real battle's full duration regardless of whether the skip-mode detection gets fully root-caused) plus a new `_back_on_challenge_list` early exit (so the normal fast case doesn't slow down), and by making `_fight_one` actually stop on a `_wait_for_result` failure. The skip-toggle's own "off" detection was not independently re-verified (would need a real ticket at a moment the account had none left) -- deliberately structured so the fix is safe even if that root cause isn't fully resolved. | -| Common Shop | `module/shop/common_shop.py`, `module/shop/shop_utils.py` | `implement`, `to_common_shop`, `get_item_position`/`ensure_choose`/`buy` (shared, see Tactical Shop row) | `ba_auto/tasks/shop_common.py`, `ba_auto/tasks/shop_utils.py` | `get_item_position`'s color+template item-state scan → fixed grid-position targets (`config.COMMON_SHOP_TARGETS`) + price-digit OCR verify, since the reference's own item-identification here indexes an external static price table (`self.static_config.common_shop_price_list`, fetched from a remote resource) this repo doesn't have — not per-item OCR, so this isn't an OCR-avoidance shortcut. Purchase-confirm dialog + reward-acquired banner handled via a single overlay-darkness probe (`config.SHOP_OVERLAY_PROBE`) instead of tracking each dialog's own layout | Done. Live-tested with real purchases (all 8 configured targets bought, cost matched exactly). Discovered live: these items have a per-refresh-cycle purchase cap not shown as a visible counter (unlike the 青輝石 tab's "あと1回購入可能" labels) — confirmed by re-running the task after purchase and observing it correctly detect the now-unselectable items (checkbox + individual 購入 button both unresponsive) and safely decline rather than guess. A fresh, everything-available run hasn't been re-verified since the account had already exhausted this cycle's purchases via that same test | +| Common Shop | `module/shop/common_shop.py`, `module/shop/shop_utils.py` | `implement`, `to_common_shop`, `get_item_position`/`ensure_choose`/`buy` (shared, see Tactical Shop row) | `ba_auto/tasks/shop_common.py`, `ba_auto/tasks/shop_utils.py` | Originally ported as fixed grid-position targets (`config.COMMON_SHOP_TARGETS`) + price-digit OCR verify, matching the reference's own external-price-list-index design as closely as this repo's missing external table allows. **Revised 2026-08-01**: fixed position turned out not to hold live -- this shop sinks sold-out items to the bottom of its grid and shifts everything after them up, so `(row, col)` drifted the moment anything sold out (see plan.md Phase 25 follow-up #2). `select_targets_by_name` now identifies items by OCR'd name instead, scanning/scrolling the full (scrollable, ~24-slot) grid and matching on name + exact price + a purchasable-button color check -- arguably *closer* to the reference's item-identity-based design intent than the original position table was, just using a local OCR read in place of the reference's external price-list index. Tactical Shop stays on the original position-based path (never showed this symptom). Purchase-confirm dialog + reward-acquired banner still handled via a single overlay-darkness probe (`config.SHOP_OVERLAY_PROBE`) | Done, re-verified live post-rewrite: real run correctly bought the one target that was actually available (`中級レポート`, 125,000 credits, matching a real credit-balance delta) and correctly logged the other 7 as unavailable this cycle (sold out, or only available at an escalated repeat-purchase price the user chose not to chase) rather than misdiagnosing them as a catalog change. Per-refresh-cycle purchase cap (discovered in the original live test below) is now expected/handled behavior, not a surprise: sold-out items report as "not found available" and the grid position they vacate is exactly what the rewrite is designed to handle. Earlier finding, still true: no visible purchase-count indicator (unlike the 青輝石 tab's "あと1回購入可能" labels) | | Tactical Shop | `module/shop/tactical_challenge_shop.py`, `module/shop/shop_utils.py` | `implement`, `goto_shop_by_name`, shared `get_item_position`/`ensure_choose`/`buy` | `ba_auto/tasks/shop_tactical.py`, `ba_auto/tasks/shop_utils.py` | `goto_shop_by_name`'s OCR swipe-search over the shop-tab list → fixed click (`config.SHOP_TAB_TACTICAL`): this account's tab list is only 7 entries and fits on screen with no scroll needed, confirmed live, so there's nothing to search for — not an OCR-avoidance shortcut. Same grid-position + price-OCR-verify + overlay-probe design as Common Shop, sharing `shop_utils.run_shop_tab` | Done. Live-tested with real purchases (both configured AP-recovery drinks bought; AP and tactical-coin balance changes matched exactly) | | Lesson/Schedule | `module/lesson.py` | `implement`, `to_lesson_location_select`/`to_select_location`/`to_all_locations` (nav state machine), `get_lesson_region_num`/`switch_lesson_region_page`/`to_lesson_region` (paged region nav), `get_lesson_each_region_status`+`check_region_availability` (per-cell status via isometric-parallelogram pixel scan), `get_lesson_relationship_counts` (per-cell affection pip count via color count), `choose_lesson` (selection policy), `execute_lesson`/`to_location_info`/`start_lesson` (click cell -> info panel -> start -> result) | `ba_auto/tasks/lesson.py` | `picture.co_detect` -> `navigation.wait_for_state`-style bounded Enter-press loop (see below); the reference's paged-arrow region nav (needing OCR to know current position) -> this client renders the 12 regions as a scrollable list instead, which only ever settles at two scroll positions (`config.LESSON_REGION_ROW_Y`), so navigation is direct index-based clicking with nothing to OCR-locate; the reference's isometric `Parallelogram`/`Triangle` per-cell scan (tuned to the reference's own screen layout) -> reading each portrait's heart-shaped affection badge via a dedicated OCR path (`detector.read_int_on_heart_badge`) needs no isometric geometry at all | Done. Config-driven scope only in the sense of the *policy* (affection-first selection, sweep every unlocked region until tickets/lessons run out, no ticket purchasing, no favor-student targeting) -- unlike shop, no user-specific target list was needed since the reference's own `lesson_region_name.JP` (embedded directly in its `default_config.py`, not externally fetched) already names all 12 regions, used here only for logging. Live-tested for real: 5 real tickets spent across 3 regions with correct outcomes (ticket count, cleanup navigation, home-screen return all verified). Two real bugs were found and fixed from that run -- see below and `plan.md`'s Lesson phase. **Regression fix (Phase 12 follow-up)**: `LESSON_TICKET_OCR_RECT`'s left edge clipped in a stray katakana fragment next to the first digit, making tesseract drop the whole leading digit (`"7/7"` -> `"/7"`) and aborting every run outright; fixed by tightening the rect, re-validated with a full real run (7 tickets spent, correct count re-read after every schedule, clean stop and home-screen return). **Regression fix (Phase 12 follow-up #2)**: clicking the schedule icon doesn't always land on the Location Select list -- the game can resume directly on whichever region's per-region isometric map was last open (confirmed live: a previous run's Ctrl-C interruption left it stuck there, breaking `_open_region_grid` for every region in the next run identically). Fixed via a new `lesson._ensure_location_select_list` recovery check (detects the per-region map's own "すべてのスケジュール" button already showing before any row's been clicked, and returns via the back button if so); validated by deliberately reproducing the stuck state and confirming a real run recovered, spent the account's real remaining ticket, and finished cleanly. **Selection-policy rewrite (Phase 12 follow-up #3, 2026-07-13)**: per explicit user direction, replaced "always pick the single highest affection value" with a tiered min/max-farming priority -- any 3-student cell anywhere first, then any 2-student cell anywhere, then single-student cells sorted lowest-affection-first. This needs the whole board's state before deciding, not just the current region's, so the flow is now scan-all-then-execute (`_scan_all_regions`/`_build_priority_queue`/`_run_queue`) rather than the old per-region sweep-and-pick-best loop (`_find_best_cell`/`_sweep_region`, both removed). Verified offline against a synthetic board (correct tier ordering) and live against the real board via a zero-cost scan-only call (69 real schedulable cells found across 12 regions -- 7 triples, 30 doubles, 32 singles -- correctly bucketed and the singles tail exactly ascending by real affection value), with zero tickets spent since the account was fully out that day. Real ticket-spending execution (`_run_queue` actually running schedules) is not yet live-tested -- deferred to the user once tickets regenerate. | | Hard story AP sweep | `module/explore_tasks/sweep_task.py`, `module/explore_tasks/task_utils.py` | `sweep_hard_task` (main flow, 20 AP/hit, 3x cap), `to_hard_event`/`to_region` (nav), `start_sweep`'s named-outcome contract (shared with story_sweep.py's, ported the same way via `navigation.wait_for_state`) | `ba_auto/tasks/story_sweep_hard.py` | Reference's `unfinished_hard_tasks` persisted config list -> a fixed, user-supplied priority-ordered list (`config.HARD_STORY_SWEEP_TARGETS`: 18-3, 30-3, 27-3, 28-3, 17-3, 13-3, 23-2, 16-1, 20-3, 17-2, 15-2, 14-3, 10-3, 14-2, 7-1, 5-3, 4-1), swept in that fixed order every run rather than popped/persisted across runs; each target uses MAX (capped at 3x by the game itself), matching `config.STORY_SWEEP_TARGETS`'s own MAX convention. Reuses story_sweep.py's region-nav/stage-row-OCR/modal/result-watching machinery where the Hard screen's layout matches (region browser, stage list, sweep modal) rather than re-deriving it. New: a campaign-active guard with no reference equivalent at all (not present anywhere in baas-reference) -- per explicit user direction, OCRs for the pink "キャンペーン中" text (confirmed live on both the home screen and the Hard region browser) before spending any AP, skipping the whole run if no campaign is active unless an explicit override is passed | Implemented, not yet live-tested. Live investigation on nik-gpu (2026-07-20) confirmed: the Hard tab (`config.HARD_TAB`) sits atop the same region-browser screen story_sweep.py already opens, and stays selected across region navigation (no re-click needed between regions, unlike the reference's own per-move to_hard_event re-assertion); Hard's stage list always shows exactly 3 fixed rows (missions 1-3, no "-A" bonus, no scrolling -- `config.HARD_STAGE_ROWS_Y`, replacing story_sweep's scroll+OCR row search entirely); the stage-info modal is pixel-identical to story_sweep's own (all of `SWEEP_MAX_BUTTON`/`SWEEP_START_BUTTON`/`STAGE_MODAL_CLOSE_BUTTON`/confirm-dialog constants reused directly, confirmed via a real zero-AP-spent dry run: opened the confirm dialog, screenshotted it, cancelled). The in-panel "キャンペーン中" campaign banner (`config.HARD_CAMPAIGN_BADGE_RECT`) was confirmed live on two different regions (30 and 29) with identical text ("任務Hardで獲得できる報酬量が2倍(+100%)になっています"), read as account-wide rather than per-region -- chosen over the home screen's own ambiguous "キャンペーン中" badge (which can point at unrelated concurrent campaigns) as the actual gate signal. **Also confirmed live**: this modal has the exact same two-stacked-action-button hazard bounty.py hit for real (cyan 掃討開始 "start sweep" directly above gold 任務開始 "start mission", a real manual battle, both showing an identical AP-cost preview) -- ported bounty.py's own `_confirm_dialog_is_sweep` OCR text-gate proactively here rather than waiting to discover the same hazard live a second time. **Live-tested for real across three rounds (2026-07-20/21) -- see `plan.md` Phase 21 for the full writeup.** Multiple genuine Hard sweeps confirmed for real (H18-3/H30-3 both fully exhausted their daily 3/3 clears; six more targets swept for real in the final run). Five real bugs found and fixed: (1) a navigation-cascade bug where every exit path's leftover "if on subscreen, press Escape" cleanup backed out of the region browser entirely after every single target, breaking all subsequent targets -- fixed via a new `_ensure_hard_screen` self-heal check per loop iteration, replacing the removed Escape presses; (2) a false-negative MAX-click verification that wrongly aborted 11 real, affordable targets once AP dropped below 40 (MAX legitimately caps at 1x there, indistinguishable from "click failed") -- fixed by dropping the verification gate and trusting the downstream confirm-dialog checks instead; (3) a genuinely new real-money hazard found live: a stage already at 0/3 remaining today still shows a clickable 入場 button, and sweeping it raises a real "spend 40 gems to refill today's clears?" dialog -- fixed with a two-layer guard (`_remaining_sweeps_today`'s pre-check OCR on the modal's own count field, `_is_challenge_count_alert`'s OCR fallback on the dialog text, both declining via Escape, never a positional click; deliberately reproduced twice with zero gems spent, confirmed by screenshot); (4) a cosmetic `"unrecognized_state"` misreport on every real sweep, root-caused to the same terminal-state gap event_sweep.py hit historically (post-sweep flow can return to the bare region browser instead of leaving the modal open) -- fixed by porting event_sweep.py's own `clicked_any`-gated second `ends` condition; (5) a still-not-fully-root-caused region-navigation flakiness (30->27 failed identically across two runs) mitigated with a short OCR-read retry (`REGION_READ_RETRIES`), not yet independently re-confirmed live since account AP was exhausted (down to 6) by that point in the session. | diff --git a/ba_auto/tasks/shop_common.py b/ba_auto/tasks/shop_common.py index a593a8d..eaa70e8 100644 --- a/ba_auto/tasks/shop_common.py +++ b/ba_auto/tasks/shop_common.py @@ -20,6 +20,7 @@ def run(driver, config): targets=config.COMMON_SHOP_TARGETS, balance_rect=config.CREDIT_BALANCE_OCR_RECT, currency_label="credits", + select_fn=shop_utils.select_targets_by_name, ) # navigation.return_to_home over a bare click -- verified + carries the diff --git a/ba_auto/tasks/shop_utils.py b/ba_auto/tasks/shop_utils.py index 1d9604f..0f6b0f4 100644 --- a/ba_auto/tasks/shop_utils.py +++ b/ba_auto/tasks/shop_utils.py @@ -13,6 +13,20 @@ the faithful local equivalent, not a shortcut around OCR the reference uses. Price-digit OCR -- something the reference doesn't even do per-item -- is layered on top as an extra safety net against catalog drift, consistent with this project's verify-before-spend pattern elsewhere (mailbox/cafe/story_sweep). + +**Position turned out not to be fixed** (found live 2026-08-01, see +config.SHOP_PRICE_OCR_OFFSET's comment and script_error.md): Common Shop +sinks a sold-out item to the bottom of its (scrollable) grid and shifts +everything after it up, so a fixed (row, col) -> item mapping silently +drifts the moment anything sells out. `select_targets_by_name` below +replaces position-based identification with OCR'd item-name matching for +Common Shop specifically -- genuinely closer to the reference's own +item-identity-based (not position-based) design intent, just using a local +OCR read instead of the reference's external price-list index, for the +same "don't have that external table" reason the module docstring above +already gives. Tactical Shop (`select_targets`, unchanged) has never shown +this symptom -- one row, two items, no observed reordering -- so it stays +on the simpler position-based path rather than migrating speculatively. """ from ba_auto import detector, navigation @@ -32,11 +46,30 @@ def _price_rect(config, row, col): return (x + x1, y + y1, x + x2, y + y2) +def _name_rect(config, row, col): + x, y = _checkbox_center(config, row, col) + x1, y1, x2, y2 = config.SHOP_NAME_OCR_OFFSET + return (x + x1, y + y1, x + x2, y + y2) + + def _is_checked(driver, config, row, col): lo, hi = config.SHOP_CHECKED_RGB return detector.region_contains_color(_checkbox_region(config, row, col), lo, hi) +def _is_purchasable(driver, config, row, col): + """A sold-out/greyed buy button reads flat grey (B-R within +/-2 of 0, + no color tint at all); a live/purchasable one carries a strong blue + tint (B-R of +120 to +127) -- regardless of the button's own shimmer + animation, which swings its raw brightness a lot but not its tint (see + config.SHOP_BUTTON_PROBE_OFFSET's own comment for the live A/B that + found this; an earlier brightness-only check was fooled by it).""" + x, y = _checkbox_center(config, row, col) + dx, dy = config.SHOP_BUTTON_PROBE_OFFSET + r, g, b = driver.color_at(x + dx, y + dy) + return (b - r) > config.SHOP_BUTTON_PURCHASABLE_MIN_BLUE_TINT + + def select_targets(driver, config, targets): """OCR-verify each target's price, then click its checkbox and confirm it registers as checked. Returns (selected, skipped); a price mismatch @@ -76,6 +109,75 @@ def select_targets(driver, config, targets): return selected, skipped +def select_targets_by_name(driver, config, targets): + """Name-OCR-based sibling of select_targets, for grids where sold-out + items sink to the bottom and shift everything else's position (see this + module's own docstring and config.SHOP_PRICE_OCR_OFFSET's comment for + the live incident this was built from). `targets` is a list of (name, + expected_price) pairs, no row/col. + + Scans from the top of the (scrollable) grid, two rows per screenshot, + OCR-reading each cell's item name. A cell is bought only if its name + matches a not-yet-found target AND its price OCR matches that target's + exact expected_price -- so an escalated repeat-purchase tier of the + same item (confirmed live: exactly 2x price once the base tier sells + out) is deliberately never matched, same "skip rather than overspend" + call the user made for this project's other targets -- AND its buy + button reads purchasable (not sold out). Scrolls by exactly one row + (config.SHOP_SCROLL_STEP_CLICKS) between reads and stops once every + target is found, the grid stops producing new content (real bottom), + or config.SHOP_NAME_SCAN_MAX_STEPS is hit, whichever comes first. + + Matches by substring either direction rather than exact equality, same + tolerance-for-a-stray-character margin story_sweep's own OCR text + matching uses (`"掃討" in text`) -- a clean short UI label is usually + exact, but this doesn't hard-fail on one stray/missing character. + """ + remaining = {name: price for name, price in targets} + selected = [] + n_rows = len(config.SHOP_ITEM_ROW_Y) + n_cols = len(config.SHOP_ITEM_COL_X) + prev_shot = None + for _ in range(config.SHOP_NAME_SCAN_MAX_STEPS): + if not remaining: + break + shot = driver.read_screenshot(detector.OCR_SHOT_PATH) + if prev_shot is not None and (shot == prev_shot).all(): + print("[shop] grid stopped scrolling -- reached the real bottom of the list") + break + prev_shot = shot + for row in range(n_rows): + if not remaining: + break + for col in range(n_cols): + if not remaining: + break + name_text = detector.read_text(_name_rect(config, row, col), lang="jpn") + match = next((n for n in remaining if n in name_text or (name_text and name_text in n)), None) + if match is None: + continue + price = detector.read_int(_price_rect(config, row, col)) + if price != remaining[match]: + continue + if not _is_purchasable(driver, config, row, col): + continue + x, y = _checkbox_center(config, row, col) + driver.click(x, y) + driver.wait(0.3) + if not _is_checked(driver, config, row, col): + print(f"[shop] '{match}' checkbox did not register as checked -- skipping") + continue + selected.append((match, remaining.pop(match))) + if remaining: + driver.scroll(*config.SHOP_SCROLL_POINT, "down", clicks=config.SHOP_SCROLL_STEP_CLICKS) + driver.wait(0.3) + skipped = [] + for name in remaining: + print(f"[shop] '{name}' not found available at its expected price {remaining[name]} -- skipping (sold out this cycle, or catalog changed)") + skipped.append((name, "not_available")) + return selected, skipped + + def confirm_purchase(driver, config): """Click the bulk Buy button, then click through the purchase-confirm dialog and the reward-acquired banner. Both dim @@ -97,12 +199,18 @@ def confirm_purchase(driver, config): return False -def run_shop_tab(driver, config, *, tab_button, targets, balance_rect, currency_label): +def run_shop_tab(driver, config, *, tab_button, targets, balance_rect, currency_label, select_fn=select_targets): """Shared control flow for both shop tabs: open the tab, verify enough currency for everything configured, select+buy, report the outcome. Ported from the reference's common_shop.py/tactical_challenge_shop.py implement() -- both are this same shape (read assets, calculate cost, buy, verify), differing only in tab entry point and currency. + + `select_fn` defaults to the position-based select_targets (Tactical + Shop's own (row, col, name, price) targets); shop_common.py passes + select_targets_by_name instead, for its (name, price) targets -- see + that function's docstring for why Common Shop needs name-based + identification specifically. """ if not targets: print(f"[shop] no targets configured for {currency_label}, nothing to do") @@ -119,12 +227,12 @@ def run_shop_tab(driver, config, *, tab_button, targets, balance_rect, currency_ print(f"[shop] could not read {currency_label} balance, aborting without spending") return "balance_unreadable" - total_cost = sum(price for _, _, _, price in targets) + total_cost = sum(target[-1] for target in targets) if balance < total_cost: print(f"[shop] insufficient {currency_label}: have {balance}, need {total_cost} -- stopping, nothing bought") return "inadequate_assets" - selected, skipped = select_targets(driver, config, targets) + selected, skipped = select_fn(driver, config, targets) if not selected: print("[shop] nothing selected (all targets skipped), cancelling") return "nothing_selected" diff --git a/plan.md b/plan.md index 3998771..d71fba6 100644 --- a/plan.md +++ b/plan.md @@ -1090,6 +1090,32 @@ Pure reclaim (no AP/credit/ticket spend, no choice to make) -- same category as **Status: Done.** Both claim paths (mission points, pass level) confirmed live with real reward claims during calibration; the actual `./ba_dailies.sh battle_pass` CLI dispatch itself confirmed live across two real runs (the second, post-fix, run clean). +### Phase 25 follow-up: silent stuck-on-pass-menu bug found from a real `daily` preset run, plus a shop price-OCR flake (2026-07-31) + +Both bugs were reported together via `script_error.md` from a real `daily` cron/preset run, debugged and fixed without live-testing (not yet re-confirmed live). + +**battle_pass left the game stuck off-home with zero warning, breaking every task after it in the same run.** The log showed `[battle_pass] claiming reward` twice then `[battle_pass] Done.`, immediately followed by `event_sweep` failing "event screen not detected after click" three times in a row with no warning printed in between -- meaning battle_pass's own exit loop AND `ba_daily.py`'s generic post-task `navigation.return_to_home` cleanup both silently agreed the game was home when it almost certainly wasn't. Root cause: the exit loop's only termination check, `_inside_battle_pass(driver, config)` going False, is a NEGATIVE condition (proves the pass-menu's own dark footer band is gone) with no confirmation that true home was actually reached, and it had no failure print at all if it ran out of `EXIT_MAX_ROUNDS`. The generic fallback can't help either, since (per this phase's own writeup above) `navigation.is_on_subscreen`/`is_modal_open` both read false/false on battle pass screens same as home. Leading suspect for what actually happened: a level-up bundling more than one reward item into separate stacked "報酬獲得!" cards, which `_claim_all`'s single hardcoded extra dismiss Enter wouldn't fully clear -- a leftover card covering the footer probe just enough to misread as "left", and also covering the claim button enough to make the NEXT round's `_claim_enabled` check misread "still blocked" as "nothing left to claim." + +Fixed two ways: +- Promoted login.py's `_home_nav_bar_visible` (the bottom nav bar's own flat near-white band, the only POSITIVE "genuinely home" signal in this project, built for the identical class of false-positive during Phase 18) to `navigation.is_home_nav_bar_visible`/`HOME_NAV_BAR_*`, shared rather than duplicated. battle_pass's exit loop (`_exit_to_home`/`_confirmed_home`) now requires both `_inside_battle_pass` False AND `is_home_nav_bar_visible` True before stopping, and prints a clear warning if it still can't confirm after `EXIT_MAX_ROUNDS` -- a stuck run now shows up in logs instead of masquerading as "Done." login.py itself was only refactored to call the shared function (identical logic, no behavior change) to avoid leaving two copies. +- `_claim_all`'s dismiss step now presses the extra dismiss Enter `DISMISS_EXTRA_PRESSES` (2) times instead of 1, still the same "harmless no-op if nothing is showing" convention as before, just wider -- speculative (the multi-card theory isn't directly confirmed) but low-risk and directly targets the leading suspect. + +**Not yet re-verified live** -- would need a real level-up (or another naturally-occurring stuck state) to confirm the fix actually self-heals, not just that it fails loudly instead of silently. Next `battle_pass`/`daily` run worth watching for the new warning line specifically. + +**Shop price OCR misread one item's price by a stray digit** (`'中級レポート' price read as 1250060, expected 125000`), skipping a real, affordable purchase. Confirmed a one-off flake, not a systematic crop problem: re-summing the same run's own "bought 7 item(s) for 1086500 credits, skipped 1" log against `COMMON_SHOP_TARGETS`' real prices shows every OTHER target -- including three other 6-digit prices in the same list -- read correctly in that same run. `shop_utils.select_targets` previously read the price exactly once per target with no retry, unlike this project's usual "retry a transient bad frame, don't give up on one read" convention (e.g. `driver.read_screenshot`'s decode retry). Fixed: re-reads the price up to `config.SHOP_PRICE_OCR_RETRIES` (3) times, taking the first read that matches `expected_price`; a genuine catalog change still reads the same (wrong) value on every retry and gets skipped as before. Not yet re-verified live (would need a real purchase run to confirm the previously-skipped item now buys correctly). + +### Phase 25 follow-up #2: the shop "price OCR flake" above was misdiagnosed -- real cause was position drift, fixed with OCR name-matching (2026-08-01) + +The retry fix above shipped, but the user reported the exact same `'中級レポート'` skip recurring identically every day afterward -- not a flake at all, since retries can't fix a deterministically-wrong read. Opened Common Shop live and screenshotted the grid: `中級レポート` had moved from its configured col 1 into col 0. Root cause, confirmed by scrolling through the full (now ~6-row/24-slot, much bigger than the original 8-item design assumed) catalog: this shop sinks any sold-out item to the bottom of its list and shifts everything after it up a slot, so a fixed `(row, col)` -> item mapping silently drifts the moment anything sells out -- `初級レポート` (originally col 0) had sold out and sunk, sliding `中級レポート` into its place, while `COMMON_SHOP_TARGETS` still pointed at col 1. + +Also newly discovered live: each of the 4 `強化珠` (enhancement bead) tiers gets a second, escalated-price repeat-purchase slot (confirmed exactly 2x) once its base-price slot sells out for the cycle, and the catalog now includes 4 bundle packs and several crafting-material items (マンドレイクの種, アンティキティラ装置の欠片, ヴォイニッチ手稿のコピー variants, 壊れたニムルドレンズ) not in the original design at all. User's call, 2026-08-01: leave the new bundle/material items out of auto-buy for now, and never chase the escalated repeat-purchase tier (only ever match the original expected_price). + +Fixed with `shop_utils.select_targets_by_name`, a name-OCR-based sibling of `select_targets` used by `shop_common.py` only (`shop_tactical.py` stays on the position-based path -- one row, two items, never observed reordering). Scans two grid rows per screenshot, OCR-reading each cell's item name (`config.SHOP_NAME_OCR_OFFSET`), matching against not-yet-found targets by substring either direction (same tolerance-for-a-stray-character margin as story_sweep's own OCR text matching), then verifying BOTH exact price match (so the escalated tier is never touched) AND a purchasable buy-button check before clicking. Scrolls by exactly one row between reads (`config.SHOP_SCROLL_STEP_CLICKS`, confirmed live) and stops once every target is found or the grid stops producing new content. + +Building the purchasable check itself hit a real live bug worth remembering: an initial brightness-based check (calibrated from two downloaded screenshots, purchasable ~(235,241,241) vs. sold-out flat grey ~172-174) found NOTHING purchasable on the very first real run, including the just-confirmed-available `中級レポート`. A same-session live A/B (fresh screenshot vs. a scrolled-down sold-out row, same session) showed why: the buy button's fill genuinely shimmers/animates, reading anywhere from a dark navy `(45,70,99)` to a bright cyan `(126,222,253)` moment to moment -- sometimes dimmer than the sold-out reference itself, so brightness alone can't tell them apart. What stays constant regardless of animation phase is blue *tint*, not brightness: purchasable consistently reads `B-R` of +120 to +127 at every phase sampled, sold-out consistently reads `B-R` within +/-2 (flat, no tint). Switched `_is_purchasable` to a `B-R` threshold (`config.SHOP_BUTTON_PURCHASABLE_MIN_BLUE_TINT`, 40) instead. + +**Live-tested successfully**: real run bought `中級レポート` for 125,000 credits (the exact item that had been failing for days) and correctly logged the other 7 targets as unavailable this cycle rather than misdiagnosing them as a catalog change -- verified against the shop screen directly afterward (item gone from its slot, credit balance down exactly 125,000) and confirmed the task still returned cleanly to home. + ## Prerequisites ### OCR