diff --git a/ba_auto/config.py b/ba_auto/config.py index 9c0a878..3a34499 100644 --- a/ba_auto/config.py +++ b/ba_auto/config.py @@ -1638,3 +1638,128 @@ BATTLE_PASS_MISSION_TAB_MIN_CHANNEL = 245 # screen, same as MISSION_CLAIM_PROBE, so battle_pass.py uses a keypress # here rather than a coordinate click. BATTLE_PASS_CLAIM_PROBE = (1720, 1080) + +# Scrimmage / 学園交流会 (module/scrimmage.py). Live-recon'd against nik-gpu +# 2026-08-02, zero real tickets spent -- the one ticket-usage confirm dialog +# reached during recon was cancelled via Escape, ticket count (30/6) +# confirmed unchanged before/after. Per explicit user direction: 3 areas +# (トリニティ/ゲヘナ/ミレニアム -- matching the reference's own +# Trinity/Gehenna/Millennium), choose one via the same date-ordinal-modulo +# rotation story_sweep.py/event_sweep.py/bounty.py already use, and always +# sweep that area's hardest (D, last-lettered) stage -- unlike bounty, NOT +# "whichever stage happens to be at the scroll extreme": each area's stage +# list is a small fixed 4-row grid (A-D) with no scrolling at all, confirmed +# live across two different areas (Trinity, Gehenna) sharing an identical +# layout, closer to story_sweep_hard.py's fixed 3-row Hard-mode list than to +# bounty's scroll-to-extreme design. Also per explicit user direction: the +# account has a real monthly pass making every scrimmage sweep cost 0 AP +# (ticket-only) -- scrimmage.py's own AP guard (see +# SCRIMMAGE_SWEEP_CONFIRM_TEXT_RECT below) refuses to confirm any sweep +# where the confirm dialog's own OCR'd AP cost isn't exactly 0, rather than +# assuming the pass is always active. + +# Work-hub card entry point (WORK_ICON, defined above -> this card). +SCRIMMAGE_CARD = (1100, 995) + +# Academy Select's 3 fixed area rows -- all 3 fit on screen with no +# scrolling needed, confirmed live identical row/button layout across +# Trinity and Gehenna (Millennium not independently opened during recon, +# assumed identical per bounty.py's own precedent of all 3 areas sharing +# one layout). +SCRIMMAGE_AREA_ROW_Y = (293, 458, 621) +SCRIMMAGE_AREA_ROW_X = 1400 +SCRIMMAGE_AREA_NAMES = ("トリニティ", "ゲヘナ", "ミレニアム") + +# Each area's own stage list -- a small fixed 4-row grid (01 A / 02 B / 03 C +# / 04 D), no scrolling, confirmed live identical between Trinity and +# Gehenna (same 入場 (enter) button x/y per row, same "already 3-starred" +# state on every row). Row index 3 (D) is always the hardest/last-lettered +# stage, per explicit user direction to always target it regardless of what +# the game currently calls the top difficulty tier. +SCRIMMAGE_STAGE_ROW_Y = (287, 429, 571, 713) +SCRIMMAGE_STAGE_ENTER_X = 1685 +SCRIMMAGE_HARDEST_STAGE_INDEX = 3 +SCRIMMAGE_STAGE_LETTERS = ("A", "B", "C", "D") + +# 任務情報 (task info) modal. Pixel-identical position/color to bounty.py's +# own BOUNTY_SWEEP_MIN_BUTTON/MAX_BUTTON/PLUS_BUTTON/MINUS_BUTTON_PROBE/ +# START_BUTTON (confirmed live, including the exact same raised-vs-default +# minus-button colors, (171,172,171) vs (251,173,152)) -- kept as separate +# SCRIMMAGE_-prefixed constants anyway, matching this file's own established +# reasoning for BOUNTY_SWEEP_MIN_BUTTON et al (avoid coupling separate +# tasks' config together even when the underlying shared UI component is +# pixel-identical). +SCRIMMAGE_SWEEP_MIN_BUTTON = (1178, 511) +SCRIMMAGE_SWEEP_MAX_BUTTON = (1631, 511) +SCRIMMAGE_SWEEP_PLUS_BUTTON = (1517, 511) +SCRIMMAGE_SWEEP_MINUS_BUTTON_PROBE = (1281, 511) +SCRIMMAGE_SWEEP_START_BUTTON = (1400, 668) + +# Same real hazard bounty.py's own BOUNTY_SWEEP_CONFIRM_TEXT_RECT comment +# documents at length: this modal has a SECOND action button, a gold +# 任務開始 (start mission, a REAL manual battle) directly below the intended +# cyan 掃討開始 (start sweep), confirmed live at (1400, 868) -- never +# clicked during recon, positional-avoidance only (this task never clicks +# anywhere near it). +SCRIMMAGE_MISSION_START_BUTTON_DO_NOT_CLICK = (1400, 868) + +# Modal-open probe -- NOT reused from BOUNTY_STAGE_MODAL_PROBE despite the +# same (1850, 1150) coordinate working here too (confirmed live: this +# screen's own classroom-corner art reads (127-178) all channels with the +# modal closed vs (20-55) with it open/a dialog stacked on top, comfortably +# separated by the same style of threshold bounty.py uses) -- kept as its +# own constant per this file's decoupling convention. +SCRIMMAGE_STAGE_MODAL_PROBE = (1850, 1150) +SCRIMMAGE_STAGE_MODAL_DIM_MAX_CHANNEL = 100 +# Modal close via its own X button -- confirmed live. Escape was not +# independently verified against the bare stage-info modal during recon +# (unlike bounty's, which confirmed Escape works there too) since the only +# dialog actually dismissed via Escape during recon was the ticket-usage +# CONFIRM dialog stacked on top of it, not the bare modal itself -- kept as +# the primary close mechanism here rather than assumed. +SCRIMMAGE_STAGE_MODAL_CLOSE_BUTTON = (1691, 271) + +# The ticket-usage confirm dialog is the exact same shared "通知" dialog +# component SWEEP_CONFIRM_BUTTON/SWEEP_CONFIRM_CANCEL_BUTTON/ +# SWEEP_CONFIRM_CYAN/SWEEP_CONFIRM_GOLD (defined above under story_sweep) +# already cover -- confirmed live pixel-identical OK/Cancel button +# positions to bounty's own dialog -- reused directly, no new button/color +# constants needed. +# +# Its own message text, however, is genuinely new and load-bearing here: +# unlike every other sweep-style task in this project, this dialog spells +# out the exact AP cost in plain text -- "学園交流会チケットをN、APをM使用し +# て、掃討をN回行いますか?" (captured live 2026-08-02, cancelled via Escape +# before confirming) -- which is exactly the "only run when AP consumption +# is 0" guard the user asked for: OCR this rect and refuse to confirm unless +# the OCR'd M reads exactly 0, cancelling instead (same defensive shape as +# bounty.py's own _confirm_dialog_is_sweep OCR gate against the identical +# two-stacked-buttons hazard documented above). Same rect bounty.py's own +# BOUNTY_SWEEP_CONFIRM_TEXT_RECT uses (605, 505, 1320, 615) -- confirmed +# live via a real cropped screenshot that this exact rect cleanly captures +# both lines of scrimmage's own dialog text too (same shared dialog +# component, just different message content) -- kept as its own constant +# rather than reused directly, matching this file's decoupling convention. +SCRIMMAGE_SWEEP_CONFIRM_TEXT_RECT = (605, 505, 1320, 615) + +# Post-sweep result screen region -- NOT independently live-confirmed +# (recon deliberately cancelled every dialog before confirming a real +# sweep, to spend zero real tickets). Seeded from bounty.py's own ALREADY- +# fixed BOUNTY_SWEEP_RESULT_BUTTON_REGION/BOUNTY_RESULT_BUTTON_MIN_PIXELS +# rather than the generic SWEEP_RESULT_BUTTON_REGION story_sweep/ +# event_sweep use, since bounty.py's own history is a direct, documented +# warning against that generic region on this exact modal family: its +# first live test used a naive copy of it, which overlapped +# SCRIMMAGE_SWEEP_START_BUTTON's own real cyan pixels (same (1400, 668) +# position/y-range as bounty's 掃討開始) and re-clicked it once tickets hit +# 0, surfacing a real Pyroxene ticket-purchase prompt. This value is +# bounty's own post-fix region, comfortably clear of that y-range by the +# same geometry -- treat as a reasoned starting point, not a confirmed +# calibration, and re-verify against this module's own real result screen +# at first live test. +SCRIMMAGE_SWEEP_RESULT_BUTTON_REGION = (750, 850, 1160, 1010) +SCRIMMAGE_RESULT_BUTTON_MIN_PIXELS = 3000 + +# MAX, per explicit user direction (2026-08-02): spend every held ticket in +# one run, same default bounty.py's own BOUNTY_SWEEP_COUNT uses. +SCRIMMAGE_SWEEP_COUNT = "max" diff --git a/ba_auto/reference_notes/mapping.md b/ba_auto/reference_notes/mapping.md index 23bf0c1..498d0e9 100644 --- a/ba_auto/reference_notes/mapping.md +++ b/ba_auto/reference_notes/mapping.md @@ -22,4 +22,6 @@ Maps each local feature to the corresponding `~/repo/baas-reference/module/...` | 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. | +| Scrimmage (学園交流会) | `module/scrimmage.py` | `implement` (main flow, `get_task_count`/per-area `scrimmage_task_status` loop, not ported -- see Status), `to_scrimmage`/`to_choose_scrimmage` (nav), `get_los`/`scrimmage_common_operation` (per-row SSS-color scan + sweep) | `ba_auto/tasks/scrimmage.py` | `to_scrimmage`'s per-area nav -> this client's Work-hub `学園交流会` card (`config.SCRIMMAGE_CARD`) landing directly on Academy Select; `get_los`/`scrimmage_common_operation`'s per-row `color.check_sweep_availability` SSS-color scan across a scrollable list -> this client's stage list is a small FIXED 4-row grid per area (no scrolling at all, confirmed live identical between Trinity/Gehenna), so the hardest (D, last) row is clicked directly (`config.SCRIMMAGE_HARDEST_STAGE_INDEX`) with no scan needed, per explicit user direction to always target the hardest tier; the reference's config-string per-area sweep-count list (`get_task_count`) and its loop across all 3 areas -> a single date-ordinal-modulo rotation choosing ONE area per run, mirroring bounty.py/story_sweep.py/event_sweep.py's own rotation, per explicit user direction (2026-08-02) | Implemented, not yet live-tested with a real ticket spend. Live-recon'd against nik-gpu 2026-08-02 with **zero real tickets spent** (the one ticket-usage confirm dialog reached was cancelled via Escape, ticket count 30/6 confirmed unchanged before/after). Confirmed live: `トリニティ`/`ゲヘナ`/`ミレニアム` (matching the reference's own Trinity/Gehenna/Millennium `scrimmage_area_name`) share an identical Academy Select row layout and an identical 4-row (A/B/C/D) stage-list layout, all already 3-starred; the 任務情報 stage-info modal is **pixel-identical** to bounty.py's own (same MIN/-/+/MAX stepper coordinates, same (171,172,171)/(251,173,152) default/raised minus-button colors, same cyan 掃討開始 button position) and carries the exact same two-stacked-action-button hazard bounty.py documents at length (a gold 任務開始 real-battle button directly below the intended sweep button) -- guarded the same way via an OCR text check (`_confirm_dialog_is_sweep`) before the one irreversible click, plus pure positional avoidance of `config.SCRIMMAGE_MISSION_START_BUTTON_DO_NOT_CLICK`. **New guard with no reference equivalent, per explicit user direction**: the account has a real monthly pass making every scrimmage sweep cost 0 AP (ticket-only) -- rather than assume the pass is always active, the ticket-usage confirm dialog's own message text was found live to state its AP cost in plain digits ("学園交流会チケットをN、**APをM**使用して、掃討をN回行いますか?", captured live 2026-08-02), so `_confirm_dialog_ap_cost` OCRs that figure and cancels rather than confirms unless it reads exactly 0 -- both a genuinely nonzero cost and an unrecognized OCR result are treated as "don't spend AP," fail-closed. Confirmed live via a real MAX-count (30) dry run that the AP delta stays 0→0 even at the account's full held-ticket count, validating the pass covers bulk sweeps too, not just count=1. `config.SCRIMMAGE_SWEEP_RESULT_BUTTON_REGION`/`SCRIMMAGE_RESULT_BUTTON_MIN_PIXELS` are seeded from bounty.py's own already-hardened post-sweep-detection region rather than the generic `SWEEP_RESULT_BUTTON_REGION`, since bounty's own history is a direct, live-documented warning against that generic region on this exact modal family (a false-positive re-click of the sweep-start button surfaced a real Pyroxene purchase prompt) -- **not yet independently confirmed against scrimmage's own real result screen**, since every confirm dialog reached during recon was cancelled before actually completing a sweep. `SCRIMMAGE_SWEEP_COUNT = "max"`, per explicit user direction. Added to the `daily` preset (not `q4h`) and to `TASKS` as a standalone opt-in `scrimmage` command; deliberately NOT added to `DEFAULT_ORDER` (spends a real ticket resource) and NOT given a campaign-active guard (per explicit user direction, unlike story_sweep/story_sweep_hard -- this task runs every invocation regardless of whether the area's own "キャンペーン中" reward-campaign badge is showing). **Confirmed live the same day (2026-08-02), first real invocation**: `./ba_dailies.sh scrimmage` ran end-to-end cleanly -- rotation picked area 0 (トリニティ), targeted stage D, AP cost OCR'd and verified exactly 0 before confirming, MAX (30 tickets) swept, `_watch_sweep_result` correctly reported `"swept"` (validating `SCRIMMAGE_SWEEP_RESULT_BUTTON_REGION`'s bounty-seeded value against a REAL result screen for the first time -- no false-positive/near-miss like bounty's own first live test hit), and `navigation.return_to_home` confirmed true home with no warnings. Verified via a real screenshot: credits rose 151,173,756 -> 152,253,756 (+1,080,000, consistent with the account's own active +200% campaign multiplier on a 30x sweep) while AP moved only 21->22 (one natural regen tick, not a sweep-caused drop) -- direct confirmation the AP==0 guard is not just reading the confirm dialog's preview text correctly but that the real spend genuinely cost 0 AP. | + Do not implement a feature without filling at least the relevant row. diff --git a/ba_auto/tasks/scrimmage.py b/ba_auto/tasks/scrimmage.py new file mode 100644 index 0000000..ab7a3cf --- /dev/null +++ b/ba_auto/tasks/scrimmage.py @@ -0,0 +1,404 @@ +"""Scrimmage / 学園交流会. Reference: baas-reference/module/scrimmage.py. + +Ported per explicit user direction (2026-08-02): 3 areas (トリニティ/ゲヘナ/ +ミレニアム -- this client's rendering of the reference's own +Trinity/Gehenna/Millennium scrimmage_area_name list), choose one via the +same date-ordinal-modulo rotation story_sweep.py/event_sweep.py/bounty.py +already use, and always sweep that area's hardest (D, last-lettered) stage. + +Differs from the reference's own control flow (module/scrimmage.py's +get_los/scrimmage_common_operation, which scans however many stage rows are +currently visible for the first one whose color.check_sweep_availability +reads "sss") the same way bounty.py already differs from its own reference +(module/rewarded_task.py's analogous get_los/one_detect scan): rather than a +color-based per-row SSS-availability scan across a scrollable list, this +client's Academy Select stage list is a small FIXED 4-row grid (01 A / 02 B +/ 03 C / 04 D) with no scrolling at all -- confirmed live identical between +Trinity and Gehenna, all 4 rows already 3-starred/SSS-cleared -- closer to +story_sweep_hard.py's fixed 3-row Hard-mode list than to bounty's +scroll-to-extreme design. Per explicit user direction, the hardest +(last-lettered) row is always targeted directly (config. +SCRIMMAGE_HARDEST_STAGE_INDEX), with no availability scan needed. + +Genuinely new guard, no reference equivalent: the account has a real +monthly pass making every scrimmage sweep cost 0 AP (ticket-only), per +explicit user direction. Rather than assume the pass is always active, +_sweep_hardest_stage OCR-reads the AP cost the ticket-usage confirm dialog +itself states in plain text ("学園交流会チケットをN、APをM使用して、掃討を +N回行いますか?") and refuses to click the final confirm button unless that +reads exactly 0, cancelling instead -- see config. +SCRIMMAGE_SWEEP_CONFIRM_TEXT_RECT for the live-captured dialog text this +was calibrated against. + +Live-recon'd against nik-gpu 2026-08-02, zero real tickets spent (the one +ticket-usage confirm dialog reached during recon was cancelled via Escape, +ticket count 30/6 confirmed unchanged before/after): + +- Entry: WORK_ICON -> config.SCRIMMAGE_CARD (学園交流会 card, third card in + the Work hub's left column, below 指名手配/特別依頼) lands on Academy + Select. Same card-based entry pattern as bounty's 指名手配 card / arena's + Tactical Challenge card / story_sweep's Task card. +- Academy Select's 3 area rows are a fixed list, not scrollable -- + config.SCRIMMAGE_AREA_ROW_Y indexes directly by rotation, same pattern as + config.BOUNTY_AREA_ROW_Y. +- Each area's own stage list (reached by clicking an area row) is a fixed + 4-row grid, confirmed live identical layout between Trinity and Gehenna + (same 入場 (enter) button x/y per row). config.SCRIMMAGE_STAGE_ROW_Y + indexes directly by config.SCRIMMAGE_HARDEST_STAGE_INDEX (3, i.e. row 04, + stage D) -- no OCR search needed, unlike story_sweep/event_sweep's + longer-than-visible lists. +- The 任務情報 (task info) modal reached by clicking a stage's 入場 button + is PIXEL-IDENTICAL in position/color to bounty.py's own stage-info modal + (same MIN/-/+/MAX stepper coordinates, same + (171,172,171)-vs-(251,173,152) raised/default minus-button colors, same + cyan 掃討開始 button position) -- confirmed live. It has the SAME real + hazard bounty.py's own config.BOUNTY_SWEEP_CONFIRM_TEXT_RECT comment + documents at length: a second, gold 任務開始 (start mission, a REAL + manual battle) button directly below the intended cyan one. This module + never clicks anywhere near config. + SCRIMMAGE_MISSION_START_BUTTON_DO_NOT_CLICK, and additionally gates the + final confirm click on an OCR text check (_confirm_dialog_is_sweep, + mirroring bounty.py's own) before ever committing. +- The ticket-usage confirm dialog reached after 掃討開始 is the same shared + "通知" component SWEEP_CONFIRM_BUTTON/SWEEP_CONFIRM_CANCEL_BUTTON/ + SWEEP_CONFIRM_CYAN/SWEEP_CONFIRM_GOLD already cover (confirmed live + pixel-identical OK/Cancel button positions to bounty's own dialog) -- + reused directly. Its own message text is genuinely new here: unlike any + other sweep-style dialog in this project, it states its own AP cost in + plain digits, which is exactly what the AP==0 guard above reads. +- The post-sweep result screen was NOT reached live (every dialog was + cancelled before confirming, to spend zero real tickets during recon) -- + config.SCRIMMAGE_SWEEP_RESULT_BUTTON_REGION is seeded from bounty.py's + own already-hardened region rather than the generic + SWEEP_RESULT_BUTTON_REGION story_sweep/event_sweep use, since bounty's + own history is a direct, documented warning against that generic region + on this exact modal family (see that config constant's own comment) -- + NOT yet independently confirmed against this module's own real result + screen. + +NOT yet live-tested with a real ticket spend -- config.SCRIMMAGE_SWEEP_COUNT +is "max" (spend every held ticket in one run) per explicit user direction, +same default bounty.py's own BOUNTY_SWEEP_COUNT uses. Needs the user's +go-ahead before the first real run, matching this project's own convention +for newly-added resource-spending tasks (arena, bounty). +""" +import datetime +import re + +from ba_auto import detector, navigation + +OPEN_RETRIES = 3 +AREA_RETRIES = 3 +STAGE_ENTER_RETRIES = 3 +MAX_BUTTON_RETRIES = 3 +MODAL_CLOSE_RETRIES = 3 +SWEEP_START_RETRIES = 3 +POST_SWEEP_DISMISS_ROUNDS = 14 + +_AP_COST_PATTERN = re.compile(r"AP\D{0,3}(\d+)") + + +def _is_stage_modal_open(driver, config): + r, g, b = driver.color_at(*config.SCRIMMAGE_STAGE_MODAL_PROBE) + return r < config.SCRIMMAGE_STAGE_MODAL_DIM_MAX_CHANNEL and g < config.SCRIMMAGE_STAGE_MODAL_DIM_MAX_CHANNEL and b < config.SCRIMMAGE_STAGE_MODAL_DIM_MAX_CHANNEL + + +def _color_in_range(rgb, rgb_range): + lo, hi = rgb_range + r, g, b = rgb + return lo[0] <= r <= hi[0] and lo[1] <= g <= hi[1] and lo[2] <= b <= hi[2] + + +def _is_sweep_usage_confirm(driver, config): + return _color_in_range(driver.color_at(*config.SWEEP_CONFIRM_BUTTON), config.SWEEP_CONFIRM_CYAN) + + +def _is_ticket_purchase_prompt(driver, config): + return _color_in_range(driver.color_at(*config.SWEEP_CONFIRM_BUTTON), config.SWEEP_CONFIRM_GOLD) + + +def _read_confirm_dialog_text(config): + return detector.read_text(config.SCRIMMAGE_SWEEP_CONFIRM_TEXT_RECT, psm=6, lang="jpn") + + +def _confirm_dialog_is_sweep(driver, config): + """OCR-verify the confirm dialog is genuinely the sweep-usage confirm, + not some other cyan-styled confirmation -- mirrors bounty.py's own + _confirm_dialog_is_sweep against the identical two-stacked-buttons + hazard documented in config.py's SCRIMMAGE_MISSION_START_BUTTON_DO_NOT_ + CLICK comment. Substring match on "掃討", matching this project's own + established dialog-classification convention. + """ + return "掃討" in _read_confirm_dialog_text(config) + + +def _confirm_dialog_ap_cost(config): + """Extract the AP cost this dialog states in plain text + ("...APをM使用して..."). Returns None if no recognizable AP-cost figure + is found -- treated as "unknown", not "zero", by the caller (fail + closed rather than assume the monthly pass is active). + """ + match = _AP_COST_PATTERN.search(_read_confirm_dialog_text(config)) + return int(match.group(1)) if match else None + + +def _find_result_button(driver, config): + return detector.find_color_centroid( + config.SCRIMMAGE_SWEEP_RESULT_BUTTON_REGION, *config.SWEEP_CONFIRM_CYAN, + min_pixels=config.SCRIMMAGE_RESULT_BUTTON_MIN_PIXELS, + ) + + +def _count_raised_above_one(driver, config): + # Same coral/orange color-spread check as bounty.py's own + # _count_raised_above_one -- confirmed live to read the exact same + # colors here, (171,172,171) default vs (251,173,152) raised. + r, g, b = driver.color_at(*config.SCRIMMAGE_SWEEP_MINUS_BUTTON_PROBE) + return (max(r, g, b) - min(r, g, b)) > 40 + + +def _open_scrimmage_screen(driver, config): + for attempt in range(1, OPEN_RETRIES + 1): + driver.click(*config.WORK_ICON) + driver.wait(2) + if navigation.is_on_subscreen(driver): + break + print(f"[scrimmage] work hub not detected after click (attempt {attempt}/{OPEN_RETRIES})") + else: + return False + + for attempt in range(1, OPEN_RETRIES + 1): + driver.click(*config.SCRIMMAGE_CARD) + driver.wait(2) + if navigation.is_on_subscreen(driver): + return True + print(f"[scrimmage] academy select not detected after click (attempt {attempt}/{OPEN_RETRIES})") + return False + + +def _open_area(driver, config, area_index): + row_y = config.SCRIMMAGE_AREA_ROW_Y[area_index] + for attempt in range(1, AREA_RETRIES + 1): + driver.click(config.SCRIMMAGE_AREA_ROW_X, row_y) + driver.wait(2) + if navigation.is_on_subscreen(driver): + return True + print(f"[scrimmage] stage list not detected after clicking area {area_index} (attempt {attempt}/{AREA_RETRIES})") + return False + + +def _open_hardest_stage_modal(driver, config): + index = config.SCRIMMAGE_HARDEST_STAGE_INDEX + row_y = config.SCRIMMAGE_STAGE_ROW_Y[index] + for attempt in range(1, STAGE_ENTER_RETRIES + 1): + driver.click(config.SCRIMMAGE_STAGE_ENTER_X, row_y) + driver.wait(2) + if _is_stage_modal_open(driver, config): + return True + print(f"[scrimmage] stage info panel not detected after click (attempt {attempt}/{STAGE_ENTER_RETRIES})") + return False + + +def _click_max_and_verify(driver, config): + for attempt in range(1, MAX_BUTTON_RETRIES + 1): + driver.click(*config.SCRIMMAGE_SWEEP_MAX_BUTTON) + driver.wait(0.8) + if _count_raised_above_one(driver, config): + return True + print(f"[scrimmage] MAX click not detected (attempt {attempt}/{MAX_BUTTON_RETRIES})") + return False + + +def _click_min_and_verify(driver, config): + # Force a known baseline before applying "+" clicks -- the stepper + # remembers its last-used value across opens (confirmed live via + # bounty.py's own real overspend incident, see that module's docstring) + # so this can't be skipped even for count == 1. + for attempt in range(1, MAX_BUTTON_RETRIES + 1): + driver.click(*config.SCRIMMAGE_SWEEP_MIN_BUTTON) + driver.wait(0.8) + if not _count_raised_above_one(driver, config): + return True + print(f"[scrimmage] MIN click not detected (attempt {attempt}/{MAX_BUTTON_RETRIES})") + return False + + +def _click_plus_and_verify(driver, config, count): + if not _click_min_and_verify(driver, config): + print("[scrimmage] could not confirm count was reset to the minimum before raising it -- aborting rather than risk an unverified starting count") + return False + if count == 1: + return True + for attempt in range(1, MAX_BUTTON_RETRIES + 1): + for _ in range(count - 1): + driver.click(*config.SCRIMMAGE_SWEEP_PLUS_BUTTON) + driver.wait(0.8) + if _count_raised_above_one(driver, config): + return True + print(f"[scrimmage] count-raise via '+' not detected (attempt {attempt}/{MAX_BUTTON_RETRIES})") + return False + + +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) + + +def _close_stage_modal(driver, config): + # Confirmed live the X button closes this modal. Escape is tried as a + # fallback (not independently confirmed against the bare modal during + # recon -- see config.SCRIMMAGE_STAGE_MODAL_CLOSE_BUTTON's comment). + for _ in range(MODAL_CLOSE_RETRIES): + if not _is_stage_modal_open(driver, config): + return True + driver.click(*config.SCRIMMAGE_STAGE_MODAL_CLOSE_BUTTON) + driver.wait(1) + if not _is_stage_modal_open(driver, config): + return True + driver.keypress("Escape") + driver.wait(1) + return not _is_stage_modal_open(driver, config) + + +def _watch_sweep_result(driver, config): + # Same clicked_any-gated two-ends-condition pattern bounty.py's own + # _watch_sweep_result uses, since this modal shares the same underlying + # "may auto-return past the bare stage modal all the way to the list" + # risk. + 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_ticket_purchase_prompt(d, c)): "prompted_to_purchase", + (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, + } + outcome = navigation.wait_for_state( + driver, config, reactions, ends, + max_iterations=POST_SWEEP_DISMISS_ROUNDS, poll_interval=1.5, + ) + return outcome or "unrecognized_state" + + +def _click_sweep_start_and_verify(driver, config): + for attempt in range(1, SWEEP_START_RETRIES + 1): + driver.click(*config.SCRIMMAGE_SWEEP_START_BUTTON) + driver.wait(1.5) + if _is_sweep_usage_confirm(driver, config) or _is_ticket_purchase_prompt(driver, config): + return True + print(f"[scrimmage] sweep confirm/ticket-purchase dialog not detected after 掃討開始 click (attempt {attempt}/{SWEEP_START_RETRIES})") + return False + + +def _sweep_hardest_stage(driver, config, area_index, count): + area_name = config.SCRIMMAGE_AREA_NAMES[area_index] + stage_letter = config.SCRIMMAGE_STAGE_LETTERS[config.SCRIMMAGE_HARDEST_STAGE_INDEX] + print(f"[scrimmage] --- area {area_index} ({area_name}), stage {stage_letter} x {count} ---") + + if not _open_area(driver, config, area_index): + return "area_unavailable" + + if not _open_hardest_stage_modal(driver, config): + print("[scrimmage] stage info panel not detected, aborting") + return "unrecognized_state" + + if not _set_sweep_count(driver, config, count): + print("[scrimmage] could not confirm sweep count was raised -- aborting without spending a ticket") + _close_stage_modal(driver, config) + return "not_sweepable" + + if not _click_sweep_start_and_verify(driver, config): + print("[scrimmage] sweep confirm/ticket-purchase dialog not detected, aborting without further input") + _close_stage_modal(driver, config) + return "unrecognized_state" + + if _is_ticket_purchase_prompt(driver, config): + print("[scrimmage] insufficient scrimmage tickets for this sweep -- cancelling without purchasing") + driver.click(*config.SWEEP_CONFIRM_CANCEL_BUTTON) + driver.wait(1) + _close_stage_modal(driver, config) + return "inadequate_ticket" + + # Hard safety gate before the one irreversible click in this whole + # flow -- see this module's docstring and config.py's + # SCRIMMAGE_MISSION_START_BUTTON_DO_NOT_CLICK for the hazard this + # closes (a real manual-battle button stacked directly below the + # intended sweep button). _is_sweep_usage_confirm already passed (a + # color match), but that alone isn't proof this is really the sweep + # dialog -- verify the actual text before committing. + if not _confirm_dialog_is_sweep(driver, config): + print("[scrimmage] 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" + + # The AP==0 guard the user actually asked for: this dialog states its + # own AP cost in plain text ("APをM使用して"). Only confirm if that + # reads exactly 0 (the real monthly pass making every scrimmage sweep + # ticket-only) -- cancel otherwise, whether the misread cost is a real + # nonzero value (pass expired/not covering this stage) or an + # unrecognized OCR result (None), rather than ever risk spending real + # AP unexpectedly. + ap_cost = _confirm_dialog_ap_cost(config) + if ap_cost != 0: + print(f"[scrimmage] confirm dialog's own AP cost read as {ap_cost!r} (expected exactly 0 via the monthly pass) -- cancelling without spending AP") + driver.click(*config.SWEEP_CONFIRM_CANCEL_BUTTON) + driver.wait(1) + _close_stage_modal(driver, config) + return "ap_cost_not_zero" + + driver.click(*config.SWEEP_CONFIRM_BUTTON) + driver.wait(1.5) + print("[scrimmage] sweep confirmed (AP cost verified 0), waiting for results") + outcome = _watch_sweep_result(driver, config) + print(f"[scrimmage] result: {outcome}") + + if outcome == "prompted_to_purchase": + print("[scrimmage] ticket-purchase prompt detected after the sweep -- cancelling without purchasing") + driver.click(*config.SWEEP_CONFIRM_CANCEL_BUTTON) + driver.wait(1) + + if not _close_stage_modal(driver, config): + print("[scrimmage] warning: could not confirm stage info modal closed -- leaving it open rather than pressing further keys blindly") + return outcome + + +def _rotation_area(config): + area_count = len(config.SCRIMMAGE_AREA_NAMES) + return datetime.date.today().toordinal() % area_count + + +def run(driver, config): + driver.focus_game() + # Reused from bounty.py's own fix for the same class of bug: WORK_ICON's + # click is home-relative, so a prior run left mid-navigation would send + # it to the wrong place. + navigation.return_to_home(driver) + + area_index = _rotation_area(config) + print(f"[scrimmage] today's rotation target: area {area_index} ({config.SCRIMMAGE_AREA_NAMES[area_index]})") + + if not _open_scrimmage_screen(driver, config): + print("[scrimmage] could not confirm scrimmage academy-select screen is open, aborting without pressing further keys") + navigation.return_to_home(driver) + return + + outcome = _sweep_hardest_stage(driver, config, area_index, config.SCRIMMAGE_SWEEP_COUNT) + if outcome != "swept": + print(f"[scrimmage] area {area_index} ended in '{outcome}'") + + if not navigation.return_to_home(driver): + print("[scrimmage] warning: could not confirm return to home screen") + + print("[scrimmage] Done.") diff --git a/ba_daily.py b/ba_daily.py index 1b04167..1420520 100644 --- a/ba_daily.py +++ b/ba_daily.py @@ -4,7 +4,7 @@ import os import sys from ba_auto import config, driver, navigation -from ba_auto.tasks import arena, battle_pass, bounty, cafe, circle, event_sweep, exit_game, gem_shop, lesson, login, mailbox, shop_common, shop_tactical, stamina, story_sweep, story_sweep_hard +from ba_auto.tasks import arena, battle_pass, bounty, cafe, circle, event_sweep, exit_game, gem_shop, lesson, login, mailbox, scrimmage, shop_common, shop_tactical, stamina, story_sweep, story_sweep_hard TASKS = { "login": login.run, @@ -32,14 +32,20 @@ TASKS = { "lesson": lesson.run, "arena": arena.run, "bounty": bounty.run, + "scrimmage": scrimmage.run, "exit_game": exit_game.run, } # 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 +# arena, bounty, and scrimmage are opt-in only (not in the default flow): +# they spend AP/credits/tactical coin/lesson tickets/an arena ticket/a +# bounty ticket/a scrimmage ticket on an automated choice rather than +# reclaiming something free, which is a real resource decision the default +# unattended run shouldn't make blindly. scrimmage additionally refuses to +# confirm any sweep whose own confirm dialog doesn't read an OCR'd AP cost +# of exactly 0 (the account's monthly pass is expected to make every +# scrimmage sweep ticket-only) -- see ba_auto/tasks/scrimmage.py's module +# docstring. 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 @@ -84,7 +90,7 @@ PRESETS = { "daily": [ "login", "event_sweep", "cafe", "event_sweep", "circle", "lesson", "arena", "shop_common", "shop_tactical", "event_sweep", "bounty", - "gem_shop", "mailbox", "stamina", "battle_pass", "event_sweep", "exit_game", + "scrimmage", "gem_shop", "mailbox", "stamina", "battle_pass", "event_sweep", "exit_game", ], # "q4h": [ # "login", "cafe", "mailbox", "stamina", "event_sweep", diff --git a/plan.md b/plan.md index 42bc15c..f70eba3 100644 --- a/plan.md +++ b/plan.md @@ -1215,6 +1215,28 @@ All test `.env` files and the throwaway `smoke_test_alert_bogus.log` were delete **Not yet done**: the user still needs to run `cp .env.example .env` on nik-gpu and fill in the real `ALERT_BRIDGE_API_KEY` themselves (steps 7 above) -- this session deliberately did not create, request, or guess that value. Once that's in place, steps 9-10 (a real `cafe` invocation confirming an actual Discord message arrives, then an unattended real `daily`/`q4h` cron fire doing the same) are the only remaining open items for this phase. +### Phase 27: Scrimmage / 学園交流会 (2026-08-02) + +User request: automate clearing Scrimmage (学園交流会, `module/scrimmage.py`) -- 3 areas, each with 4 fixed-difficulty stages (A-D), area chosen randomly by date, always sweep the hardest (D) stage. The account has a real monthly pass making every scrimmage sweep cost 0 AP (ticket-only); the user explicitly asked for a guard that only runs the sweep when the AP consumption is verified to be 0. + +Read `module/scrimmage.py` first, per the reference-first rule: `implement` loops all 3 areas by a configured per-area sweep count (`get_task_count`/`rewarded_task_times`-style, no local equivalent), `get_los`/`scrimmage_common_operation` scans a scrollable stage list top-to-bottom via `color.check_sweep_availability`'s per-row SSS-color probe (`src/rgb_feature/JP.json`'s `sideTaskSSS`/`sideTaskNoPass` 3-point gold-vs-gray check) for the first already-cleared/sweepable row. `bounty.py` (this project's own port of the near-identical `module/rewarded_task.py`) was the closer local analog to build from: same 3-area date-rotation pattern, same shared `SWEEP_CONFIRM_*`/stage-info-modal design already established. + +Live-recon'd against nik-gpu (real account, AP 19/240 at the start) rather than guessing coordinates -- see the mapping.md row and `ba_auto/tasks/scrimmage.py`'s own docstring for the full findings; summarized here: + +- Entry: Work-hub (`WORK_ICON`) -> new 学園交流会 card (`config.SCRIMMAGE_CARD`) -> Academy Select, 3 fixed area rows (トリニティ/ゲヘナ/ミレニアム, confirmed live identical layout for Trinity and Gehenna). +- Each area's stage list is a small FIXED 4-row grid (01 A / 02 B / 03 C / 04 D), no scrolling at all -- unlike bounty's scroll-to-extreme 10-stage list, this needed no scan logic at all: the hardest (D, row index 3) is just clicked directly every time, per explicit user direction. +- The 任務情報 stage-info modal turned out to be **pixel-identical** to bounty's own (same MIN/-/+/MAX stepper coordinates and colors, same cyan 掃討開始/gold 任務開始 two-stacked-button hazard bounty.py already documents at length) -- confirmed live via direct pixel comparison, not assumed. +- The AP guard the user asked for turned out to have a clean, direct implementation: the ticket-usage confirm dialog's own message text states its AP cost in plain digits ("学園交流会チケットをN、**APをM**使用して、掃討をN回行いますか?", captured live). `_confirm_dialog_ap_cost` OCRs that figure (regex on the dialog text, same rect bounty's own `_confirm_dialog_is_sweep` OCR-reads) and cancels unless it reads exactly `0` -- a nonzero read AND an unrecognized/failed OCR read both fail closed (cancel), never assumed safe. +- Confirmed live via a real MAX-count (30) dry run, cancelled before confirming, that the AP delta preview stays `19→19` even at the full held-ticket count -- validating the pass covers bulk sweeps, not just count=1, before ever risking a real spend. + +Implementation: `ba_auto/config.py` (new `SCRIMMAGE_*` section, kept fully decoupled from `BOUNTY_*` per this file's own established anti-coupling convention even though several stepper-button coordinates are pixel-identical -- see that section's own comment), `ba_auto/tasks/scrimmage.py` (new, closely modeled on `bounty.py`'s structure), `ba_daily.py` (registered as `scrimmage` in `TASKS`, added to the `daily` preset per explicit user choice -- not `q4h`, not `DEFAULT_ORDER`), `ba_auto/reference_notes/mapping.md` (new row). Per explicit user direction: `SCRIMMAGE_SWEEP_COUNT = "max"` (spend every held ticket per run), and no campaign-active guard (unlike story_sweep/story_sweep_hard -- runs every invocation regardless of the area's own キャンペーン中 badge state). + +`bash -n`/`py_compile` passed locally and on nik-gpu's own venv; `python3 -c "import ba_daily"` succeeded on nik-gpu after deploying via the standard `rsync` push. + +**Live-tested for real the same day, first invocation, with the user's explicit go-ahead** (`./ba_dailies.sh scrimmage` on nik-gpu): ran end-to-end cleanly -- rotation picked area 0 (トリニティ), targeted stage D directly, AP cost OCR'd and verified exactly 0 before the irreversible confirm click, MAX (30 tickets) swept, `_watch_sweep_result` correctly reported `"swept"` on the very first real attempt (validating `SCRIMMAGE_SWEEP_RESULT_BUTTON_REGION`, seeded from bounty's own hard-won post-fix region rather than the generic story_sweep/event_sweep one specifically to avoid repeating bounty's own first-live-test near-miss -- worked with no false positive this time), and returned cleanly to a confirmed true home screen. Verified via a real screenshot: credits rose 151,173,756 -> 152,253,756 (+1,080,000, consistent with the account's own active +200% reward campaign on a 30x sweep) while AP moved only 21->22 (one natural regen tick, not a sweep-caused drop) -- direct, real confirmation the AP==0 guard reflects an actually-zero real spend, not just a correctly-read preview number. Zero real tickets were spent during the recon phase itself (every dialog reached before the final live test was cancelled via Escape); scratchpad probes/screenshots from this session cleaned up afterward (locally and on nik-gpu) via `clean_scratchpad.sh`. + +**Not yet exercised**: a day where the date-rotation picks ゲヘナ or ミレニアム specifically (only トリニティ was live-tested, since that's what today's rotation picked), an `inadequate_ticket` outcome (the account had 30 tickets, comfortably more than one MAX sweep needed), and a day where the AP guard actually fires and cancels (the monthly pass was active throughout this session, so `ap_cost != 0` was never really hit) -- all implemented per the same defensive shape confirmed working elsewhere in this project (bounty.py's own `_is_ticket_purchase_prompt`/fail-closed conventions), just not yet observed against real divergent game state. + ## Prerequisites ### OCR @@ -1444,18 +1466,6 @@ Suggested first version (as originally scoped, and what shipped): ## Low priority backlog -### Scrimmage - -Reference: - -``` -~/repo/baas-reference/module/scrimmage.py -``` - -Local target: `ba_auto/tasks/scrimmage.py` - -Similar shape to Bounty/Commissions. - ### Crafting Reference: