diff --git a/.gitignore b/.gitignore index f74acfeb..97c53c66 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ datasets/uma_unity_cup/* !datasets/uma_unity_cup/raw datasets/uma_unity_cup/raw/* !datasets/uma_unity_cup/raw/gitkeep.txt +datasets/_scrape_out/ README.ai.md README.train.md @@ -70,4 +71,4 @@ change.patch # Personal convenience scripts run.sh -start_uma.sh +start_uma.sh \ No newline at end of file diff --git a/capture_debug.py b/capture_debug.py index ef139dbd..6bed531f 100644 --- a/capture_debug.py +++ b/capture_debug.py @@ -68,7 +68,7 @@ def main(): ) parser.add_argument( "--mode", - choices=["steam", "bluestack", "scrcpy"], + choices=["steam", "bluestack", "scrcpy", "adb"], default=Settings.MODE, help="Capture mode / target window type", ) diff --git a/core/actions/career_loop_agent.py b/core/actions/career_loop_agent.py new file mode 100644 index 00000000..f3006ccc --- /dev/null +++ b/core/actions/career_loop_agent.py @@ -0,0 +1,1320 @@ +""" +Career Loop Agent for Career Automation Loop. + +This module orchestrates the complete career farming loop: +- Navigate from main menu to career mode +- Handle all setup screens +- Select optimal support card +- Launch the training agent (AgentScenario) +- Loop back to start a new career upon completion +""" + +from __future__ import annotations + +import time +from typing import Optional + +from core.actions.career_loop_types import CareerLoopState, CareerStep +from core.actions.career_nav_flow import CareerNavFlow +from core.actions.support_select_flow import SupportSelectFlow +from core.agent_scenario import AgentScenario +from core.controllers.base import IController +from core.perception.ocr.interface import OCRInterface +from core.perception.yolo.interface import IDetector +from core.utils.abort import abort_requested, request_abort +from core.utils.geometry import crop_pil +from core.utils.logger import logger_uma +from core.utils.waiter import Waiter + + +class CareerLoopAgent: + """ + Top-level orchestrator that manages the complete career farming loop. + + This class coordinates the entire career automation workflow: + - Initializes navigation and support selection flows + - Executes career setup sequence + - Launches and monitors AgentScenario + - Detects career completion + - Loops back to start new career + - Handles errors and recovery + + Attributes: + ctrl: Controller for input and screen capture + ocr: OCR engine for text recognition + yolo_engine: YOLO detection engine for UI elements + waiter: Synchronization utility for UI state transitions + agent_scenario: The training agent to run for each career + career_nav: Navigation flow for menu and setup screens + support_select: Support card selection flow + state: Career loop state tracking + preferred_support: Name of the preferred support card + preferred_level: Desired support card level + max_careers: Maximum number of careers to run (None = infinite) + error_threshold: Stop after this many consecutive errors + """ + + def __init__( + self, + ctrl: IController, + ocr: Optional[OCRInterface], + yolo_engine: IDetector, + waiter: Waiter, + agent_scenario: AgentScenario, + *, + preferred_support: str = "Riko Kashimoto", + preferred_level: int = 50, + max_refresh_attempts: int = 3, + refresh_wait_seconds: float = 5.0, + max_careers: Optional[int] = None, + error_threshold: int = 5, + ): + """ + Initialize CareerLoopAgent with infrastructure components. + + Args: + ctrl: Controller for input and screen capture + ocr: OCR engine for text recognition (optional) + yolo_engine: YOLO detection engine + waiter: Waiter for UI synchronization + agent_scenario: The training agent to run for each career + preferred_support: Name of the preferred support card (default: "Riko Kashimoto") + preferred_level: Desired support card level (default: 50) + max_refresh_attempts: Maximum refresh attempts for support selection (default: 3) + refresh_wait_seconds: Wait time after refresh (default: 5.0s) + max_careers: Maximum number of careers to run, None for infinite (default: None) + error_threshold: Stop after this many consecutive errors (default: 5) + """ + self.ctrl = ctrl + self.ocr = ocr + self.yolo_engine = yolo_engine + self.waiter = waiter + self.agent_scenario = agent_scenario + + # Configuration + self.preferred_support = preferred_support + self.preferred_level = preferred_level + self.max_careers = max_careers + self.error_threshold = error_threshold + + # Initialize flows + self.career_nav = CareerNavFlow( + ctrl=ctrl, + ocr=ocr, + yolo_engine=yolo_engine, + waiter=waiter, + ) + + self.support_select = SupportSelectFlow( + ctrl=ctrl, + ocr=ocr, + yolo_engine=yolo_engine, + waiter=waiter, + preferred_support=preferred_support, + preferred_level=preferred_level, + max_refresh_attempts=max_refresh_attempts, + refresh_wait_seconds=refresh_wait_seconds, + ) + + # Initialize state tracking + self.state = CareerLoopState() + + logger_uma.info( + "[CareerLoopAgent] Initialized: support='%s' level=%d max_careers=%s error_threshold=%d", + preferred_support, + preferred_level, + max_careers if max_careers is not None else "infinite", + error_threshold, + ) + + def _confirm_career_start(self) -> bool: + """ + Confirm career start with double-click on "Start Career!" button. + + This method performs the following steps: + 1. Click button_green with "Start Career!" text using fuzzy search + 2. Check if "Restore" button appears (TP restoration needed) + 3. If restore needed, handle TP restoration flow + 4. Wait 3 seconds for UI transition + 5. Click button_green with "Start Career!" text again (double-click confirmation) + 6. Verify career started successfully + + Returns: + True if career start was confirmed successfully, False otherwise + """ + logger_uma.info("[CareerLoopAgent] Confirming career start") + + try: + # First click: Look for "Start Career!" button with fuzzy matching + logger_uma.debug("[CareerLoopAgent] First click on Start Career button") + clicked_first = self.waiter.click_when( + classes=["button_green"], + texts=["start", "career"], # Fuzzy search patterns + threshold=0.68, + timeout_s=5.0, + tag="career_start_confirm_1", + ) + + if not clicked_first: + logger_uma.warning("[CareerLoopAgent] Failed to click Start Career button (first attempt)") + return False + + logger_uma.debug("[CareerLoopAgent] First click successful, checking for TP restoration") + + # Wait a moment for potential restore dialog + time.sleep(1.5) + + # Check if "Restore" button appears (TP restoration needed) + img, _, dets = self.yolo_engine.recognize( + imgsz=832, + conf=0.51, + iou=0.45, + tag="career_start_restore_check", + agent="career_loop", + ) + + from core.utils.yolo_objects import filter_by_classes as det_filter + restore_buttons = det_filter(dets, ["button_green"]) + + # Check if any green button has "restore" text + restore_needed = False + if restore_buttons and self.ocr: + for det in restore_buttons: + roi = crop_pil(img, det["xyxy"]) + text = self.ocr.text(roi, min_conf=0.2).lower().strip() + if "restore" in text: + restore_needed = True + logger_uma.info("[CareerLoopAgent] TP restoration needed, handling restore flow") + break + + # Handle TP restoration if needed + if restore_needed: + if not self._handle_tp_restoration(): + logger_uma.warning("[CareerLoopAgent] TP restoration failed, continuing anyway") + # Continue even if restoration fails + + # Wait for UI transition + # Second click: Double-click confirmation + logger_uma.debug("[CareerLoopAgent] Second click on Start Career button") + clicked_second = self.waiter.click_when( + classes=["button_green"], + texts=["start", "career"], # Fuzzy search patterns + threshold=0.68, + timeout_s=5.0, + tag="career_start_confirm_2", + ) + + time.sleep(1) + clicked_second = self.waiter.click_when( + classes=["button_green"], + texts=["start", "career"], # Fuzzy search patterns + threshold=0.68, + timeout_s=5.0, + tag="career_start_confirm_2", + ) + + if not clicked_second: + logger_uma.warning("[CareerLoopAgent] Failed to click Start Career button (second attempt)") + return False + + logger_uma.info("[CareerLoopAgent] Career start confirmed successfully") + return True + + except Exception as e: + logger_uma.error( + "[CareerLoopAgent] Error confirming career start: %s", + str(e), + exc_info=True, + ) + return False + + def _handle_tp_restoration(self) -> bool: + """ + Handle TP (Training Points) restoration flow. + + This method performs the following steps: + 1. Click "Restore" button (green button with "restore" text) + 2. Wait for popup with ui_carat and ui_tp buttons + 3. Click "Use" button on the far right of ui_tp (prioritize TP over carat) + 4. Click "OK" button (green button) + 5. Wait 3-5 seconds + 6. Click "Close" button (white button) + + Returns: + True if TP restoration was successful, False otherwise + """ + logger_uma.info("[CareerLoopAgent] Handling TP restoration") + + try: + # Step 1: Click "Restore" button + logger_uma.debug("[CareerLoopAgent] Step 1: Clicking Restore button") + clicked_restore = self.waiter.click_when( + classes=["button_green"], + texts=["restore"], + threshold=0.68, + timeout_s=5.0, + tag="tp_restore_1", + ) + + if not clicked_restore: + logger_uma.warning("[CareerLoopAgent] Failed to click Restore button") + return False + + # Wait for popup to appear + time.sleep(1.5) + + # Step 2: Detect ui_tp and click "Use" button on the far right + logger_uma.debug("[CareerLoopAgent] Step 2: Looking for ui_tp and Use button") + img, _, dets = self.yolo_engine.recognize( + imgsz=832, + conf=0.51, + iou=0.45, + tag="tp_restore_popup", + agent="career_loop", + ) + + from core.utils.yolo_objects import filter_by_classes as det_filter + tp_dets = det_filter(dets, ["ui_tp"]) + white_buttons = det_filter(dets, ["button_white", "white_button"]) + use_carat = False + if not tp_dets: + logger_uma.warning("[CareerLoopAgent] ui_tp not found, trying to use carat instead") + # Fallback to carat if TP not available + carat_dets = det_filter(dets, ["ui_carat"]) + if carat_dets: + tp_dets = carat_dets + use_carat = True + else: + logger_uma.error("[CareerLoopAgent] Neither ui_tp nor ui_carat found") + return False + + # Find the "Use" button that's vertically aligned with ui_tp + # Based on detection data: + # - ui_tp is at y=(273.6, 371.7), center_y ≈ 322 + # - Use button for TP is at y=(152.0, 211.0), center_y ≈ 181 + # - ui_carat is at y=(135.0, 231.4), center_y ≈ 183 + # So we need to find the button that's vertically aligned (similar y-center) + if tp_dets and white_buttons: + tp_x1, tp_y1, tp_x2, tp_y2 = tp_dets[0]["xyxy"] # Use first TP detection + tp_center_y = (tp_y1 + tp_y2) / 2 # Center Y of TP icon + + logger_uma.debug( + "[CareerLoopAgent] TP icon at y-center=%.1f, looking for aligned Use button", + tp_center_y, + ) + + # Find white buttons to the right of TP icon and vertically aligned + use_button = None + best_match = None + min_y_diff = float('inf') + + for btn in white_buttons: + btn_x1, btn_y1, btn_x2, btn_y2 = btn["xyxy"] + btn_center_y = (btn_y1 + btn_y2) / 2 + y_diff = abs(btn_center_y - tp_center_y) + + # Button must be to the right of TP icon + if btn_x1 > tp_x2: + # Check vertical alignment (within 100 pixels tolerance) + if y_diff < 100: + if y_diff < min_y_diff: + min_y_diff = y_diff + best_match = btn + logger_uma.debug( + "[CareerLoopAgent] Found candidate Use button at y-center=%.1f (diff=%.1f)", + btn_center_y, + y_diff, + ) + + if best_match: + use_button = best_match + logger_uma.info( + "[CareerLoopAgent] Selected Use button with y-diff=%.1f (best vertical alignment)", + min_y_diff, + ) + + if use_button: + logger_uma.debug("[CareerLoopAgent] Clicking Use button for TP") + x1, y1, x2, y2 = use_button["xyxy"] + center_x = int((x1 + x2) / 2) + center_y = int((y1 + y2) / 2) + self.ctrl.click(center_x, center_y) + time.sleep(1.0) + else: + logger_uma.warning("[CareerLoopAgent] Use button not found by alignment, trying generic white button") + # Fallback: click any white button with "use" text + clicked_use = self.waiter.click_when( + classes=["button_white", "white_button"], + texts=["use"], + threshold=0.68, + timeout_s=3.0, + tag="tp_restore_use", + ) + if not clicked_use: + logger_uma.error("[CareerLoopAgent] Failed to click Use button") + return False + else: + logger_uma.error("[CareerLoopAgent] Could not find TP icon or white buttons") + return False + + if use_carat: + plus_button_clicked = self.waiter.click_when( + classes=["button_plus"], + threshold=0.68, + timeout_s=3.0, + tag="carat_restore_add" + ) + + if not plus_button_clicked: + logger_uma.error("[CareerLoopAgent] Failed to click Plus Button") + return False + + # Step 3: Click "OK" button (green button) + logger_uma.debug("[CareerLoopAgent] Step 3: Clicking OK button") + clicked_ok = self.waiter.click_when( + classes=["button_green"], + texts=["ok", "confirm"], + threshold=0.68, + timeout_s=5.0, + tag="tp_restore_ok", + ) + + if not clicked_ok: + logger_uma.warning("[CareerLoopAgent] Failed to click OK button") + return False + + # Step 4: Wait 3-5 seconds + logger_uma.debug("[CareerLoopAgent] Step 4: Waiting 4 seconds") + time.sleep(4.0) + + # Step 5: Click "Close" button (white button) + logger_uma.debug("[CareerLoopAgent] Step 5: Clicking Close button") + clicked_close = self.waiter.click_when( + classes=["button_white", "white_button"], + texts=["close"], + threshold=0.38, + timeout_s=5.0, + tag="tp_restore_close", + ) + + if not clicked_close: + logger_uma.warning("[CareerLoopAgent] Failed to click Close button") + return False + + logger_uma.info("[CareerLoopAgent] TP restoration completed successfully") + time.sleep(3.0) + return True + + except Exception as e: + logger_uma.error( + "[CareerLoopAgent] Error handling TP restoration: %s", + str(e), + exc_info=True, + ) + return False + + def _handle_career_start_skip(self) -> bool: + """ + Handle skip dialog at the beginning of a career. + + When starting a career from the career loop, the game shows a skip dialog. + This method performs the following steps: + 1. Click "button_skip" to open skip options + 2. Wait 1 second + 3. Click "no_skip" twice to decline skipping + 4. Continue with normal career flow + + Returns: + True if skip dialog was handled successfully, False otherwise + """ + logger_uma.info("[CareerLoopAgent] Handling career start skip dialog") + + try: + # Step 1: Click button_skip + logger_uma.debug("[CareerLoopAgent] Step 1: Clicking button_skip") + clicked_skip = self.waiter.click_when( + classes=["button_skip"], + timeout_s=10.0, + tag="career_start_skip_1", + ) + + if not clicked_skip: + logger_uma.debug("[CareerLoopAgent] button_skip not found, may not be needed") + return True # Not an error, skip dialog may not appear + + logger_uma.debug("[CareerLoopAgent] Clicked button_skip") + + # Step 2: Wait 1 second + time.sleep(1.0) + + # Step 3: Click no_skip twice + logger_uma.debug("[CareerLoopAgent] Step 3: Clicking no_skip (first time)") + clicked_no_skip_1 = self.waiter.click_when( + classes=["no_skip"], + timeout_s=5.0, + clicks=2, + tag="career_start_no_skip_1", + ) + time.sleep(1) + if not clicked_no_skip_1: + logger_uma.warning("[CareerLoopAgent] Failed to click no_skip (first time)") + clicked_no_skip_2 = self.waiter.click_when( + classes=["no_skip"], + timeout_s=5.0, + clicks=2, + tag="career_start_no_skip_2", + ) + + logger_uma.debug("[CareerLoopAgent] Clicked no_skip (first time), waiting briefly") + + time.sleep(1) + if self.waiter.click_when( + classes=["button_green"], + texts=["confirm"], + tag="career_start_confirm" + ): + logger_uma.info("[CareerLoopAgent] Skip dialog handled successfully") + return True + + except Exception as e: + logger_uma.error( + "[CareerLoopAgent] Error handling career start skip: %s", + str(e), + exc_info=True, + ) + return False + + def _reset_agent_state(self) -> None: + """ + Reset agent state for a new career. + + This clears date memory and other state that should not persist + between careers, ensuring each career starts fresh. + """ + try: + logger_uma.debug("[CareerLoopAgent] Resetting agent state") + + # Reset date tracking in lobby + if hasattr(self.agent_scenario, 'lobby'): + lobby = self.agent_scenario.lobby + + # Clear raced keys memory + if hasattr(lobby, '_raced_keys_recent'): + lobby._raced_keys_recent.clear() + logger_uma.debug("[CareerLoopAgent] Cleared raced keys memory") + + # Reset date state + if hasattr(lobby, 'state') and hasattr(lobby.state, 'date_info'): + lobby.state.date_info = None + logger_uma.debug("[CareerLoopAgent] Reset date info") + + # Reset last date key + if hasattr(lobby, '_last_date_key'): + lobby._last_date_key = None + logger_uma.debug("[CareerLoopAgent] Reset last date key") + + # Reset skip guard + if hasattr(lobby, '_skip_guard_key'): + lobby._skip_guard_key = None + logger_uma.debug("[CareerLoopAgent] Reset skip guard key") + + logger_uma.info("[CareerLoopAgent] Agent state reset complete") + + except Exception as e: + logger_uma.warning( + "[CareerLoopAgent] Error resetting agent state: %s (continuing anyway)", + str(e), + ) + + def _handle_career_completion(self) -> bool: + """ + Handle career completion flow and return to home screen. + + This method performs the following steps: + 1. Click "career_complete" button + 2. Click "finish" button + 3. Wait 5 seconds for results processing + 4. Click "To Home", "Close", "Next", "Cancel" until ui_home is found + (avoiding "Edit Team" button) + + Returns: + True if successfully returned to home screen, False otherwise + """ + logger_uma.info("[CareerLoopAgent] Handling career completion flow") + + try: + # Step 1: Click career_complete button + logger_uma.debug("[CareerLoopAgent] Step 1: Clicking career_complete button") + if not self.waiter.seen(classes=["career_complete"], tag="career_completion_check_career_complete"): + return False + + clicked_complete = self.waiter.click_when( + classes=["career_complete"], + timeout_s=10.0, + tag="career_completion_1", + ) + + if not clicked_complete: + logger_uma.warning("[CareerLoopAgent] Failed to click career_complete button") + # Try to continue anyway + return False + else: + logger_uma.debug("[CareerLoopAgent] Clicked career_complete button") + time.sleep(1.5) + + # Step 2: Click finish button + logger_uma.debug("[CareerLoopAgent] Step 2: Clicking finish button") + clicked_finish = self.waiter.click_when( + classes=["button_green", "button_blue"], + texts=["finish", "complete", "done"], + threshold=0.68, + timeout_s=10.0, + tag="career_completion_finish", + ) + + if not clicked_finish: + logger_uma.warning("[CareerLoopAgent] Failed to click finish button") + # Try to continue anyway + else: + logger_uma.debug("[CareerLoopAgent] Clicked finish button") + + # Step 3: Wait 5 seconds for results processing + logger_uma.debug("[CareerLoopAgent] Step 3: Waiting 5 seconds for results processing") + time.sleep(5.0) + + # Step 4: Click through dialogs until we reach ui_home + logger_uma.debug("[CareerLoopAgent] Step 4: Clicking through dialogs to reach home") + max_clicks = 20 # Safety limit + clicks_count = 0 + + while clicks_count < max_clicks: + clicks_count += 1 + + # Check if we're at home screen + img, _, dets = self.yolo_engine.recognize( + imgsz=832, + conf=0.51, + iou=0.45, + tag="career_completion_check", + agent="career_loop", + ) + + from core.utils.yolo_objects import filter_by_classes as det_filter + home_dets = det_filter(dets, ["ui_home"]) + + if home_dets: + logger_uma.info("[CareerLoopAgent] Successfully reached home screen") + return True + + # Try to click "To Home", "Close", "Next", "OK" buttons + clicked = self.waiter.click_when( + classes=["ui_home", "button_green", "button_white", "button_blue", "button_close"], + texts=["home", "close", "next", "ok", "confirm", "cancel"], + threshold=0.68, + timeout_s=3.0, + tag=f"career_completion_nav_{clicks_count}", + forbid_texts=["edit", "team"], + ) + + if not clicked: + logger_uma.debug( + "[CareerLoopAgent] No navigation button found (attempt %d/%d)", + clicks_count, + max_clicks, + ) + # Wait a moment and try again + time.sleep(1.0) + else: + logger_uma.debug( + "[CareerLoopAgent] Clicked navigation button (attempt %d/%d)", + clicks_count, + max_clicks, + ) + # Wait for screen transition + time.sleep(1.5) + + logger_uma.error( + "[CareerLoopAgent] Failed to reach home screen after %d clicks", + max_clicks, + ) + return False + + except Exception as e: + logger_uma.error( + "[CareerLoopAgent] Error handling career completion: %s", + str(e), + exc_info=True, + ) + return False + + def _handle_failed_career(self) -> bool: + """ + Check if white_button has "cancel" and green button has "try again" + Its mean the race is failed, + 1. Click on white_button "cancel" + """ + + logger_uma.info("[CareerLoopAgent] Checking for failed career state") + + try: + # Check for cancel and try again buttons + img, _, dets = self.yolo_engine.recognize( + imgsz=832, + conf=0.51, + iou=0.45, + tag="failed_career_check", + agent="career_loop", + ) + + from core.utils.yolo_objects import filter_by_classes as det_filter + cancel_button_dets = det_filter(dets, ["button_white"]) + try_again_button_dets = det_filter(dets, ["button_green"]) + + # If both buttons are present, we have a failed career + if cancel_button_dets and try_again_button_dets: + logger_uma.info("[CareerLoopAgent] Detected failed career state") + + # Click cancel button + self.waiter.click_when( + classes=["button_white"], + texts=["cancel"], + threshold=0.68, + timeout_s=2.0, + tag="failed_career_cancel", + ) + + self.waiter.click_when( + classes=["button_green"], + texts=["next"], + threshold=0.68, + timeout_s=2.0, + tag="failed_career_next", + ) + + self.waiter.click_when( + classes=["button_green"], + threshold=0.68, + timeout_s=5.0, + tag="failed_career_next_next", + ) + else: + logger_uma.debug("[CareerLoopAgent] Not in failed career state") + return False + + except Exception as e: + logger_uma.error( + "[CareerLoopAgent] Error checking for failed career: %s", + str(e), + exc_info=True, + ) + return True + def _handle_new_day(self) -> bool: + """Handle new day detection and skip if needed. + + This method checks if a new day has started and handles the skip logic + the bot will click the button_skip if it exist on the screen + Returns: + True if new day handled successfully or not detected, False if error occurs + """ + logger_uma.info("[CareerLoopAgent] Checking for new day") + + try: + + clicked_skip = self.waiter.click_when( + classes=["button_skip"], + timeout_s=2.0, + tag="career_start_skip_1", + ) + + if clicked_skip: + return True + else: + return False + + except Exception as e: + logger_uma.error( + "[CareerLoopAgent] Error handling new day: %s", + str(e), + exc_info=True, + ) + return False + + def _execute_career_cycle(self) -> bool: + """ + Execute one complete career cycle. + + This method performs the following steps: + 1. Navigate to career from main menu + 2. Loop through setup screens (scenario, trainee, legacy) + 3. Detect support formation screen + 4. Handle support card selection + 5. Confirm career start (double-click) + 6. Launch agent_scenario and wait for completion + + Returns: + True if the career cycle completed successfully, False otherwise + """ + logger_uma.info( + "[CareerLoopAgent] Starting career cycle %d", + self.state.total_careers_completed + 1, + ) + + # Record start time + self.state.current_career_start_time = time.time() + + try: + #Check skip button + if self._handle_new_day(): + logger_uma.info("[CareerLoopAgent] Pre-Step: New Day Checker - Clicked skip") + return True + self._handle_failed_career() + #Check if in career mode + if self._check_if_in_career(): + logger_uma.info("[CareerLoopAgent] Pre-Step: Career checker - Already in career") + return True + if self._handle_career_completion(): + logger_uma.info("[CareerLoopAgent] Career Complete") + return True + # Step 1: Navigate to career from main menu + logger_uma.info("[CareerLoopAgent] Step 1: Navigating to career from main menu") + if not self.career_nav.navigate_to_career_from_menu(): + logger_uma.error("[CareerLoopAgent] Failed to navigate to career") + return False + + logger_uma.debug("[CareerLoopAgent] Navigation to career successful") + + # Step 2: Loop through setup screens + logger_uma.info("[CareerLoopAgent] Step 2: Handling setup screens") + + # We need to handle multiple setup screens until we reach support formation + # The design specifies we should loop through setup screens + max_setup_screens = 10 # Safety limit to prevent infinite loops + setup_screen_count = 0 + + while setup_screen_count < max_setup_screens: + setup_screen_count += 1 + + logger_uma.debug( + "[CareerLoopAgent] Handling setup screen %d/%d", + setup_screen_count, + max_setup_screens, + ) + + # Check if we've reached support formation screen + # We'll try to detect it by checking for career_add_friend_support button + img, _, dets = self.yolo_engine.recognize( + imgsz=832, + conf=0.51, + iou=0.45, + tag="career_cycle_check", + agent="career_loop", + ) + + # Check for support formation screen indicator + from core.utils.yolo_objects import filter_by_classes as det_filter + support_button_dets = det_filter(dets, ["career_add_friend_support"]) + + if support_button_dets: + logger_uma.info("[CareerLoopAgent] Detected support formation screen") + break + + # Not on support formation yet, handle current setup screen + if not self.career_nav.handle_setup_screen(): + logger_uma.warning( + "[CareerLoopAgent] Failed to handle setup screen %d", + setup_screen_count, + ) + # Try to continue anyway + time.sleep(1.0) + else: + # Wait a moment for screen transition + time.sleep(1.0) + + if setup_screen_count >= max_setup_screens: + logger_uma.error( + "[CareerLoopAgent] Exceeded max setup screens (%d), may be stuck", + max_setup_screens, + ) + return False + + # Step 3: Handle support formation screen + logger_uma.info("[CareerLoopAgent] Step 3: Selecting optimal support card") + if not self.support_select.select_optimal_support(): + logger_uma.error("[CareerLoopAgent] Failed to select support card") + return False + + logger_uma.debug("[CareerLoopAgent] Support card selected successfully") + + # Step 4: Confirm career start + logger_uma.info("[CareerLoopAgent] Step 4: Confirming career start") + if not self._confirm_career_start(): + logger_uma.error("[CareerLoopAgent] Failed to confirm career start") + return False + + logger_uma.debug("[CareerLoopAgent] Career start confirmed") + + # Step 5: Launch agent_scenario + logger_uma.info("[CareerLoopAgent] Step 5: Launching training agent") + + # Wait a moment for career to fully start + time.sleep(3.0) + + # Step 5a: Handle skip dialog at career start + logger_uma.info("[CareerLoopAgent] Step 5a: Handling skip dialog") + if not self._handle_career_start_skip(): + logger_uma.warning("[CareerLoopAgent] Failed to handle skip dialog, continuing anyway") + # Continue even if skip handling fails + + # Step 5b: Reset date state for new career + logger_uma.info("[CareerLoopAgent] Step 5b: Resetting date state for new career") + self._reset_agent_state() + + # Run the agent scenario + # The agent will run until the career is complete + logger_uma.info("[CareerLoopAgent] Running agent scenario...") + self.agent_scenario.run() + + logger_uma.info( + "[CareerLoopAgent] Agent scenario completed for career %d", + self.state.total_careers_completed + 1, + ) + + self._handle_failed_career() + + # Step 6: Handle career completion and return to home + logger_uma.info("[CareerLoopAgent] Step 6: Handling career completion") + if not self._handle_career_completion(): + logger_uma.error("[CareerLoopAgent] Failed to handle career completion") + return False + + logger_uma.debug("[CareerLoopAgent] Career completion handled successfully") + + # Calculate cycle time + if self.state.current_career_start_time: + cycle_time = time.time() - self.state.current_career_start_time + logger_uma.info( + "[CareerLoopAgent] Career cycle completed in %.1f seconds (%.1f minutes)", + cycle_time, + cycle_time / 60.0, + ) + + return True + + except Exception as e: + logger_uma.error( + "[CareerLoopAgent] Error during career cycle: %s", + str(e), + exc_info=True, + ) + return False + + def _return_to_main_menu(self) -> bool: + """ + Return to main menu for error recovery. + + This method attempts to return to the main menu by clicking the + ui_home button. This is used for error recovery when something + goes wrong during the career cycle. + + Returns: + True if successfully returned to main menu, False otherwise + """ + logger_uma.info("[CareerLoopAgent] Attempting to return to main menu for recovery") + + try: + # Click ui_home button to return to main menu + clicked = self.waiter.click_when( + classes=["ui_home"], + timeout_s=5.0, + tag="career_loop_recovery", + ) + + if clicked: + logger_uma.info("[CareerLoopAgent] Successfully returned to main menu") + # Wait a moment for menu to stabilize + time.sleep(1.0) + return True + else: + logger_uma.warning("[CareerLoopAgent] Failed to click ui_home button for recovery") + return False + + except Exception as e: + logger_uma.error( + "[CareerLoopAgent] Error returning to main menu: %s", + str(e), + exc_info=True, + ) + return False + + def _execute_career_cycle_with_recovery(self) -> bool: + """ + Execute career cycle with error recovery wrapper. + + This method wraps _execute_career_cycle with retry logic and error handling: + - Retries up to 3 times on failure + - Handles different exception types appropriately + - Updates CareerLoopState on success/error + - Returns to main menu on errors + + Returns: + True if career cycle completed successfully, False otherwise + """ + max_retries = 3 + + for attempt in range(max_retries): + try: + logger_uma.debug( + "[CareerLoopAgent] Career cycle attempt %d/%d", + attempt + 1, + max_retries, + ) + + # Execute the career cycle + success = self._execute_career_cycle() + + if success: + # Record success and reset error tracking + self.state.record_success() + logger_uma.info( + "[CareerLoopAgent] Career cycle successful (total: %d)", + self.state.total_careers_completed, + ) + return True + else: + # Cycle failed, but no exception was raised + error_msg = f"Career cycle failed on attempt {attempt + 1}/{max_retries}" + logger_uma.warning("[CareerLoopAgent] %s", error_msg) + + # Try to return to main menu for recovery + if attempt < max_retries - 1: + logger_uma.info("[CareerLoopAgent] Attempting recovery...") + self._return_to_main_menu() + time.sleep(2.0) # Wait before retry + else: + # Last attempt failed + self.state.record_error(error_msg) + logger_uma.error( + "[CareerLoopAgent] All retry attempts exhausted (%d consecutive errors)", + self.state.consecutive_errors, + ) + return False + + except KeyboardInterrupt: + # User requested stop, propagate immediately + logger_uma.info("[CareerLoopAgent] Keyboard interrupt received") + raise + + except Exception as e: + error_msg = f"Exception during career cycle: {type(e).__name__}: {str(e)}" + logger_uma.error( + "[CareerLoopAgent] %s (attempt %d/%d)", + error_msg, + attempt + 1, + max_retries, + exc_info=True, + ) + + # Try to return to main menu for recovery + if attempt < max_retries - 1: + logger_uma.info("[CareerLoopAgent] Attempting recovery...") + try: + self._return_to_main_menu() + time.sleep(2.0) # Wait before retry + except Exception as recovery_error: + logger_uma.error( + "[CareerLoopAgent] Recovery failed: %s", + str(recovery_error), + ) + # Continue to next retry anyway + else: + # Last attempt failed + self.state.record_error(error_msg) + logger_uma.error( + "[CareerLoopAgent] All retry attempts exhausted (%d consecutive errors)", + self.state.consecutive_errors, + ) + return False + + # Should not reach here, but handle it anyway + self.state.record_error("Max retries exceeded") + return False + + def _check_if_in_career(self) -> bool: + """ + Check if we're already in an active career (lobby or training screen). + + This method detects if we're currently in a career by looking for + the career_step indicator and OCR'ing its text to confirm it says "Career". + + Returns: + True if we're in an active career, False otherwise + """ + try: + logger_uma.debug("[CareerLoopAgent] Checking if already in career...") + + # Capture current screen and detect career_step + img, _, dets = self.yolo_engine.recognize( + imgsz=832, + conf=0.51, + iou=0.45, + tag="career_check_in_career", + agent="career_loop", + ) + + from core.utils.yolo_objects import filter_by_classes as det_filter + career_step_dets = det_filter(dets, ["career_step"]) + + if not career_step_dets: + logger_uma.debug("[CareerLoopAgent] No career_step detected - not in career") + return False + + # OCR the career_step region to check if it says "Career" + if not self.ocr: + logger_uma.warning("[CareerLoopAgent] OCR not available, cannot verify career_step text") + return False + + # Use the first career_step detection + career_step_det = career_step_dets[0] + region = crop_pil(img, career_step_det["xyxy"], pad=0) + text = self.ocr.text(region, min_conf=0.2).strip().lower() + + logger_uma.debug( + "[CareerLoopAgent] career_step OCR text: '%s' (conf=%.3f)", + text, + career_step_det.get("conf", 0.0), + ) + + # Check if text contains "complete" - this means career is finished + if "complete" in text: + logger_uma.debug( + "[CareerLoopAgent] career_step text '%s' contains 'complete' - career is finished, not in active career", + text, + ) + return False + + # Check if text contains "career" or "training" + if "career" in text or "training" in text: + logger_uma.info(f"[CareerLoopAgent] Detected career_step with {text} text - already in career!") + + # Reset agent state before continuing the career + logger_uma.debug("[CareerLoopAgent] Resetting agent state before continuing career") + self._reset_agent_state() + + self.state.is_running = True + self.agent_scenario.run() + return True + else: + logger_uma.debug( + "[CareerLoopAgent] career_step text '%s' does not contain 'career' - not in career", + text, + ) + return False + + except Exception as e: + logger_uma.warning( + "[CareerLoopAgent] Error checking if in career: %s", + str(e), + ) + return False + + def run(self, max_loops: Optional[int] = None) -> None: + """ + Main loop: navigate → setup → launch agent → repeat. + + This method runs the career automation loop until one of the following occurs: + - F1 is pressed (abort signal) + - max_careers limit is reached + - error_threshold consecutive errors occur + + The loop executes career cycles with recovery, checks abort signals between + iterations, and maintains comprehensive statistics. + + Args: + max_loops: Override for max_careers (for testing purposes) + """ + # Use provided max_loops or fall back to configured max_careers + effective_max = max_loops if max_loops is not None else self.max_careers + + logger_uma.info( + "[CareerLoopAgent] Starting career loop: max_careers=%s error_threshold=%d", + effective_max if effective_max is not None else "infinite", + self.error_threshold, + ) + + # Check if we're already in an active career + if self._check_if_in_career(): + logger_uma.info( + "[CareerLoopAgent] Already in career - spawning agent directly without navigation" + ) + + # Set running flag + self.state.is_running = True + + try: + # Run the agent scenario directly + logger_uma.info("[CareerLoopAgent] Running agent scenario for existing career...") + self.agent_scenario.run() + + logger_uma.info("[CareerLoopAgent] Agent scenario completed") + + # Handle career completion and return to home + logger_uma.info("[CareerLoopAgent] Handling career completion") + if self._handle_career_completion(): + self.state.record_success() + logger_uma.info( + "[CareerLoopAgent] Career completed successfully (total: %d)", + self.state.total_careers_completed, + ) + else: + logger_uma.warning("[CareerLoopAgent] Failed to handle career completion") + self.state.record_error("Failed to handle career completion") + + except KeyboardInterrupt: + logger_uma.info("[CareerLoopAgent] Keyboard interrupt received") + raise + + except Exception as e: + error_msg = f"Exception during in-career agent run: {type(e).__name__}: {str(e)}" + logger_uma.error("[CareerLoopAgent] %s", error_msg, exc_info=True) + self.state.record_error(error_msg) + + # After handling the existing career, check if we should continue + if abort_requested(): + logger_uma.info("[CareerLoopAgent] Abort signal detected after existing career, stopping") + self.state.is_running = False + return + + logger_uma.info("[CareerLoopAgent] Existing career handled, continuing with normal loop") + + # Set running flag (or keep it if already set from above) + self.state.is_running = True + + # Initialize loop counter + loop_iteration = 0 + + try: + while self.state.is_running: + loop_iteration += 1 + + # Check abort signal before starting new career + if abort_requested(): + logger_uma.info( + "[CareerLoopAgent] Abort signal detected, stopping loop" + ) + break + + # Check if we've reached max careers limit + if effective_max is not None and self.state.total_careers_completed >= effective_max: + logger_uma.info( + "[CareerLoopAgent] Reached max careers limit (%d), stopping loop", + effective_max, + ) + break + + # Check if we've exceeded error threshold + if self.state.consecutive_errors >= self.error_threshold: + logger_uma.error( + "[CareerLoopAgent] Exceeded error threshold (%d consecutive errors), stopping loop", + self.state.consecutive_errors, + ) + break + + # Log loop statistics + logger_uma.info( + "[CareerLoopAgent] Loop iteration %d: careers_completed=%d consecutive_errors=%d", + loop_iteration, + self.state.total_careers_completed, + self.state.consecutive_errors, + ) + + # Execute career cycle with recovery + cycle_start_time = time.time() + success = self._execute_career_cycle_with_recovery() + cycle_duration = time.time() - cycle_start_time + + if success: + logger_uma.info( + "[CareerLoopAgent] Career cycle %d completed successfully in %.1f seconds", + loop_iteration, + cycle_duration, + ) + else: + logger_uma.warning( + "[CareerLoopAgent] Career cycle %d failed after %.1f seconds", + loop_iteration, + cycle_duration, + ) + + # Check abort signal after career cycle + if abort_requested(): + logger_uma.info( + "[CareerLoopAgent] Abort signal detected after career cycle, stopping loop" + ) + break + + # Brief pause between careers + if self.state.is_running: + time.sleep(1.0) + + except KeyboardInterrupt: + logger_uma.info("[CareerLoopAgent] Keyboard interrupt received, stopping loop") + + except Exception as e: + logger_uma.error( + "[CareerLoopAgent] Unexpected error in main loop: %s", + str(e), + exc_info=True, + ) + + finally: + # Set running flag to False + self.state.is_running = False + + # Log final statistics + logger_uma.info( + "[CareerLoopAgent] Career loop stopped: total_careers=%d consecutive_errors=%d last_error='%s'", + self.state.total_careers_completed, + self.state.consecutive_errors, + self.state.last_error or "none", + ) + + def emergency_stop(self) -> None: + """ + Emergency stop for immediate loop termination. + + This method provides a cooperative emergency stop mechanism: + - Sets is_running flag to False to stop the main loop + - Signals agent_scenario to stop using request_abort() + - Cleans up resources + - Logs the emergency stop event + + This is a best-effort immediate stop that allows the current + operation to complete gracefully before terminating. + """ + logger_uma.warning("[CareerLoopAgent] Emergency stop requested") + + try: + # Set running flag to False + self.state.is_running = False + + # Signal agent_scenario to stop + request_abort() + + # If agent_scenario has an emergency_stop method, call it + if hasattr(self.agent_scenario, 'emergency_stop'): + try: + self.agent_scenario.emergency_stop() + except Exception as e: + logger_uma.error( + "[CareerLoopAgent] Error calling agent_scenario.emergency_stop: %s", + str(e), + ) + + logger_uma.info("[CareerLoopAgent] Emergency stop completed") + + except Exception as e: + logger_uma.error( + "[CareerLoopAgent] Error during emergency stop: %s", + str(e), + exc_info=True, + ) diff --git a/core/actions/career_loop_types.py b/core/actions/career_loop_types.py new file mode 100644 index 00000000..9bada8c6 --- /dev/null +++ b/core/actions/career_loop_types.py @@ -0,0 +1,121 @@ +""" +Data models and enumerations for the Career Automation Loop. + +This module defines the core types used throughout the career loop workflow: +- CareerStep: Enumeration of career setup screens +- SupportCardInfo: Information extracted from support card containers +- CareerLoopState: State tracking for the automation loop +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional, Tuple + +from core.types import DetectionDict, XYXY + + +class CareerStep(str, Enum): + """ + Enumeration of career setup screens. + + These represent the different screens a player encounters when + starting a new career from the main menu. + """ + + SCENARIO_SELECT = "scenario_select" + TRAINEE_SELECT = "trainee_select" + LEGACY_SELECT = "legacy_select" + SUPPORT_FORMATION = "support_formation" + UNKNOWN = "unknown" + + +@dataclass(frozen=True) +class SupportCardInfo: + """ + Information extracted from a support card container. + + This dataclass holds all relevant information about a support card + that appears in the friend support selection popup. + + Attributes: + name: The support card character name (e.g., "Riko Kashimoto") + level: The support card level (1-50) + xyxy: Bounding box coordinates for clicking (x1, y1, x2, y2) + container_detection: Original YOLO detection dict for the container + """ + + name: str + level: int + xyxy: XYXY + container_detection: DetectionDict + + def matches_criteria( + self, + target_name: str, + target_level: int + ) -> bool: + """ + Check if this card matches target criteria. + + Uses case-insensitive name matching and exact level matching. + + Args: + target_name: The desired support card name + target_level: The desired support card level + + Returns: + True if both name and level match, False otherwise + """ + return ( + self.level == target_level and + self.name.lower() == target_name.lower() + ) + + +@dataclass +class CareerLoopState: + """ + Tracks state of career automation loop. + + This dataclass maintains runtime state for the career loop agent, + including success/error tracking and timing information. + + Attributes: + total_careers_completed: Count of successfully completed careers + current_career_start_time: Unix timestamp when current career started (None if not running) + last_error: Description of the most recent error (None if no errors) + consecutive_errors: Count of errors without a successful career in between + is_running: Whether the loop is currently active + """ + + total_careers_completed: int = 0 + current_career_start_time: Optional[float] = None + last_error: Optional[str] = None + consecutive_errors: int = 0 + is_running: bool = False + + def record_success(self) -> None: + """ + Record successful career completion. + + Increments the completion counter and resets error tracking. + """ + self.total_careers_completed += 1 + self.consecutive_errors = 0 + self.last_error = None + self.current_career_start_time = None + + def record_error(self, error: str) -> None: + """ + Record error during career cycle. + + Increments the consecutive error counter and stores the error message. + + Args: + error: Description of the error that occurred + """ + self.consecutive_errors += 1 + self.last_error = error + self.current_career_start_time = None diff --git a/core/actions/career_nav_flow.py b/core/actions/career_nav_flow.py new file mode 100644 index 00000000..26e08aa7 --- /dev/null +++ b/core/actions/career_nav_flow.py @@ -0,0 +1,267 @@ +""" +Career Navigation Flow for Career Automation Loop. + +This module handles navigation from the main menu through career setup screens, +including scenario selection, trainee selection, legacy selection, and support +card formation screens. +""" + +from __future__ import annotations + +from typing import Optional + +from core.actions.career_loop_types import CareerStep +from core.controllers.base import IController +from core.perception.ocr.interface import OCRInterface +from core.perception.yolo.interface import IDetector +from core.utils.logger import logger_uma +from core.utils.waiter import Waiter +from core.utils.yolo_objects import filter_by_classes as det_filter + + +class CareerNavFlow: + """ + Handles navigation from main menu through career setup screens. + + This class manages the complete navigation flow from the main menu + to the career mode, handling all intermediate setup screens with + intelligent fallback behavior when screen detection is uncertain. + + Attributes: + ctrl: Controller for input and screen capture + ocr: OCR engine for text recognition + yolo_engine: YOLO detection engine for UI elements + waiter: Synchronization utility for UI state transitions + timeout_navigation: Timeout for navigation operations (seconds) + timeout_screen_transition: Timeout for screen transitions (seconds) + """ + + def __init__( + self, + ctrl: IController, + ocr: Optional[OCRInterface], + yolo_engine: IDetector, + waiter: Waiter, + *, + timeout_navigation: float = 5.0, + timeout_screen_transition: float = 4.0, + ): + """ + Initialize CareerNavFlow with infrastructure components. + + Args: + ctrl: Controller for input and screen capture + ocr: OCR engine for text recognition (optional) + yolo_engine: YOLO detection engine + waiter: Waiter for UI synchronization + timeout_navigation: Timeout for navigation operations (default: 5.0s) + timeout_screen_transition: Timeout for screen transitions (default: 4.0s) + """ + self.ctrl = ctrl + self.ocr = ocr + self.yolo_engine = yolo_engine + self.waiter = waiter + + # Configuration + self.timeout_navigation = timeout_navigation + self.timeout_screen_transition = timeout_screen_transition + + logger_uma.debug( + "[CareerNavFlow] Initialized with timeouts: navigation=%.1fs, transition=%.1fs", + timeout_navigation, + timeout_screen_transition, + ) + + def navigate_to_career_from_menu(self) -> bool: + """ + Navigate from main menu to career mode. + + This method performs the following steps: + 1. Click ui_home button to ensure we're at the main menu + 2. Wait for ui_career button to appear + 3. Click ui_career button to enter career mode + + Returns: + True if navigation was successful, False otherwise + """ + logger_uma.info("[CareerNavFlow] Starting navigation from main menu to career") + + try: + # Step 1: Click ui_home button + logger_uma.debug("[CareerNavFlow] Clicking ui_home button") + clicked_home = self.waiter.click_when( + classes=["ui_home"], + timeout_s=self.timeout_navigation, + tag="career_nav_home", + ) + + if not clicked_home: + logger_uma.warning("[CareerNavFlow] Failed to click ui_home button") + return False + + logger_uma.debug("[CareerNavFlow] Successfully clicked ui_home button") + + # Step 2: Wait for and click ui_career button + logger_uma.debug("[CareerNavFlow] Waiting for ui_career button") + clicked_career = self.waiter.click_when( + classes=["ui_career"], + timeout_s=self.timeout_navigation, + tag="career_nav_career", + ) + + if not clicked_career: + logger_uma.warning("[CareerNavFlow] Failed to click ui_career button") + return False + + logger_uma.info("[CareerNavFlow] Successfully navigated to career mode") + return True + + except Exception as e: + logger_uma.error( + "[CareerNavFlow] Error during navigation: %s", + str(e), + exc_info=True, + ) + return False + + def _extract_career_step(self) -> CareerStep: + """ + Extract career_step indicator from current screen. + + Uses YOLO detection to identify which career setup screen is currently + displayed. This helps route to the appropriate handler for each screen. + + Returns: + CareerStep enum value indicating the current screen, or UNKNOWN if + the step cannot be determined + """ + try: + # Capture current screen and run YOLO detection + img, _, dets = self.yolo_engine.recognize( + imgsz=832, + conf=0.51, + iou=0.45, + tag="career_step_extract", + agent="career_loop", + ) + + # Look for career_step class in detections + step_dets = det_filter(dets, ["career_step"]) + + if not step_dets: + logger_uma.debug("[CareerNavFlow] No career_step indicator detected") + return CareerStep.UNKNOWN + + # For now, we'll use a simple heuristic based on detection confidence + # In a full implementation, you might OCR the step indicator or use + # additional context clues to determine the exact step + + # This is a placeholder - in practice, you'd need to determine which + # specific step based on the detection or additional screen context + logger_uma.debug( + "[CareerNavFlow] Detected career_step indicator but cannot determine specific step" + ) + return CareerStep.UNKNOWN + + except Exception as e: + logger_uma.warning( + "[CareerNavFlow] Error extracting career_step: %s", + str(e), + ) + return CareerStep.UNKNOWN + + def handle_setup_screen(self) -> bool: + """ + Handle current career setup screen. + + This method: + 1. Extracts the current career_step from the screen + 2. Routes to the appropriate handler based on the step + 3. Falls back to clicking "Next" button if step is unknown + + Returns: + True if the screen was handled successfully and advanced to the + next screen, False otherwise + """ + logger_uma.debug("[CareerNavFlow] Handling setup screen") + + try: + # Extract current step + step = self._extract_career_step() + + logger_uma.debug("[CareerNavFlow] Current career step: %s", step.value) + + # Route based on step + if step == CareerStep.SCENARIO_SELECT: + logger_uma.info("[CareerNavFlow] On scenario selection screen") + return self._click_next_button() + + elif step == CareerStep.TRAINEE_SELECT: + logger_uma.info("[CareerNavFlow] On trainee selection screen") + return self._click_next_button() + + elif step == CareerStep.LEGACY_SELECT: + logger_uma.info("[CareerNavFlow] On legacy selection screen") + return self._click_next_button() + + elif step == CareerStep.SUPPORT_FORMATION: + logger_uma.info("[CareerNavFlow] On support formation screen") + # Support formation is handled by SupportSelectFlow, not here + # Return True to indicate we've identified the screen + return True + + else: # CareerStep.UNKNOWN + logger_uma.debug( + "[CareerNavFlow] Unknown career step, using fallback behavior" + ) + return self._click_next_button() + + except Exception as e: + logger_uma.error( + "[CareerNavFlow] Error handling setup screen: %s", + str(e), + exc_info=True, + ) + return False + + def _click_next_button(self) -> bool: + """ + Fallback behavior: click button_green with "Next" text. + + This method is used when the career_step cannot be determined or as + the default action for most setup screens. It looks for a green button + with "Next" text and clicks it. + + If multiple green buttons are present, OCR is used to disambiguate and + select the one with "Next" text. + + Returns: + True if the Next button was clicked successfully, False otherwise + """ + logger_uma.debug("[CareerNavFlow] Attempting to click Next button") + + try: + # Use waiter to click button_green with "Next" text + # The waiter will handle OCR disambiguation if multiple buttons exist + clicked = self.waiter.click_when( + classes=["button_green"], + texts=["next"], + threshold=0.68, + timeout_s=self.timeout_screen_transition, + tag="career_nav_next", + ) + + if clicked: + logger_uma.debug("[CareerNavFlow] Successfully clicked Next button") + return True + else: + logger_uma.warning("[CareerNavFlow] Failed to find/click Next button") + return False + + except Exception as e: + logger_uma.error( + "[CareerNavFlow] Error clicking Next button: %s", + str(e), + exc_info=True, + ) + return False diff --git a/core/actions/support_select_flow.py b/core/actions/support_select_flow.py new file mode 100644 index 00000000..27f00912 --- /dev/null +++ b/core/actions/support_select_flow.py @@ -0,0 +1,590 @@ +""" +Support Card Selection Flow for Career Automation Loop. + +This module handles the intelligent selection of support cards during career +initialization, including scanning available cards, matching against preferences, +and refreshing the list when needed. +""" + +from __future__ import annotations + +import time +from typing import List, Optional + +from core.actions.career_loop_types import SupportCardInfo +from core.controllers.base import IController +from core.perception.ocr.interface import OCRInterface +from core.perception.yolo.interface import IDetector +from core.types import XYXY +from core.utils.geometry import crop_pil +from core.utils.logger import logger_uma +from core.utils.text import fuzzy_ratio +from core.utils.waiter import Waiter +from core.utils.yolo_objects import filter_by_classes as det_filter + + +class SupportSelectFlow: + """ + Handles support card selection with intelligent retry logic. + + This class manages the complete support card selection workflow: + - Opening the support card selection popup + - Scanning all available support cards + - Extracting level and name from each card + - Finding the optimal support matching preferences + - Refreshing the support list if needed + - Falling back to top support after max refreshes + + Attributes: + ctrl: Controller for input and screen capture + ocr: OCR engine for text recognition + yolo_engine: YOLO detection engine for UI elements + waiter: Synchronization utility for UI state transitions + preferred_support: Name of the preferred support card (e.g., "Riko Kashimoto") + preferred_level: Desired support card level (1-50) + max_refresh_attempts: Maximum number of times to refresh the support list + refresh_wait_seconds: Wait time after clicking refresh button + timeout_popup: Timeout for popup operations (seconds) + timeout_scan: Timeout for scanning operations (seconds) + """ + + def __init__( + self, + ctrl: IController, + ocr: Optional[OCRInterface], + yolo_engine: IDetector, + waiter: Waiter, + *, + preferred_support: str = "Riko Kashimoto", + preferred_level: int = 50, + max_refresh_attempts: int = 3, + refresh_wait_seconds: float = 5.0, + timeout_popup: float = 4.0, + timeout_scan: float = 3.0, + ): + """ + Initialize SupportSelectFlow with configuration. + + Args: + ctrl: Controller for input and screen capture + ocr: OCR engine for text recognition (optional) + yolo_engine: YOLO detection engine + waiter: Waiter for UI synchronization + preferred_support: Name of the preferred support card (default: "Riko Kashimoto") + preferred_level: Desired support card level (default: 50) + max_refresh_attempts: Maximum refresh attempts (default: 3) + refresh_wait_seconds: Wait time after refresh (default: 5.0s) + timeout_popup: Timeout for popup operations (default: 4.0s) + timeout_scan: Timeout for scanning operations (default: 3.0s) + """ + self.ctrl = ctrl + self.ocr = ocr + self.yolo_engine = yolo_engine + self.waiter = waiter + + # Configuration + self.preferred_support = preferred_support + self.preferred_level = preferred_level + self.max_refresh_attempts = max_refresh_attempts + self.refresh_wait_seconds = refresh_wait_seconds + self.timeout_popup = timeout_popup + self.timeout_scan = timeout_scan + + logger_uma.debug( + "[SupportSelectFlow] Initialized: preferred='%s' level=%d max_refresh=%d", + preferred_support, + preferred_level, + max_refresh_attempts, + ) + + def select_optimal_support(self) -> bool: + """ + Main entry point: open popup, find/select optimal support. + + This method orchestrates the complete support selection workflow: + 1. Open the support card selection popup + 2. Loop up to max_refresh_attempts times: + a. Scan all available support cards + b. Find optimal support matching criteria + c. If found, select and return success + d. If not found, refresh the list and retry + 3. If not found after max attempts, select top support as fallback + + Returns: + True if a support card was selected successfully, False otherwise + """ + logger_uma.info( + "[SupportSelectFlow] Starting support selection: target='%s' level=%d", + self.preferred_support, + self.preferred_level, + ) + + try: + # Step 1: Open support popup + if not self._open_support_popup(): + logger_uma.error("[SupportSelectFlow] Failed to open support popup") + return False + + # Step 2: Loop through refresh attempts + for attempt in range(self.max_refresh_attempts + 1): + logger_uma.debug( + "[SupportSelectFlow] Scan attempt %d/%d", + attempt + 1, + self.max_refresh_attempts + 1, + ) + + # Scan available support cards + cards = self._scan_support_cards() + + if not cards: + logger_uma.warning( + "[SupportSelectFlow] No support cards found on attempt %d", + attempt + 1, + ) + + # Try refreshing if we have attempts left + if attempt < self.max_refresh_attempts: + logger_uma.info("[SupportSelectFlow] Refreshing support list...") + if self._refresh_support_list(): + continue + else: + logger_uma.warning("[SupportSelectFlow] Refresh failed") + break + else: + logger_uma.error("[SupportSelectFlow] No cards found after all attempts") + return False + + logger_uma.info( + "[SupportSelectFlow] Found %d support cards", + len(cards), + ) + + # Find optimal support + optimal = self._find_optimal_support(cards) + + if optimal: + logger_uma.info( + "[SupportSelectFlow] Found optimal support: '%s' level %d", + optimal.name, + optimal.level, + ) + return self._select_support_card(optimal) + + # Not found - try refreshing if we have attempts left + if attempt < self.max_refresh_attempts: + logger_uma.info( + "[SupportSelectFlow] Optimal support not found, refreshing... (%d/%d)", + attempt + 1, + self.max_refresh_attempts, + ) + if not self._refresh_support_list(): + logger_uma.warning("[SupportSelectFlow] Refresh failed") + break + else: + logger_uma.warning( + "[SupportSelectFlow] Optimal support not found after %d attempts", + self.max_refresh_attempts + 1, + ) + + # Step 3: Fallback to top support + logger_uma.info("[SupportSelectFlow] Using fallback: selecting top support") + + # Scan one more time to get current cards + cards = self._scan_support_cards() + + if not cards: + logger_uma.error("[SupportSelectFlow] No cards available for fallback") + return False + + # Select the first (top) card + top_card = cards[0] + logger_uma.info( + "[SupportSelectFlow] Selecting fallback support: '%s' level %d", + top_card.name, + top_card.level, + ) + return self._select_support_card(top_card) + + except Exception as e: + logger_uma.error( + "[SupportSelectFlow] Error during support selection: %s", + str(e), + exc_info=True, + ) + return False + + def _open_support_popup(self) -> bool: + """ + Click career_add_friend_support button to open the support card list popup. + + Returns: + True if popup opened successfully, False otherwise + """ + logger_uma.debug("[SupportSelectFlow] Opening support card popup") + + try: + # Click the add friend support button + clicked = self.waiter.click_when( + classes=["career_add_friend_support"], + timeout_s=self.timeout_popup, + tag="support_open_popup", + ) + + if not clicked: + logger_uma.warning( + "[SupportSelectFlow] Failed to click career_add_friend_support button" + ) + return False + + # Wait a moment for popup to appear + time.sleep(0.5) + + logger_uma.debug("[SupportSelectFlow] Support popup opened successfully") + return True + + except Exception as e: + logger_uma.error( + "[SupportSelectFlow] Error opening support popup: %s", + str(e), + exc_info=True, + ) + return False + + def _scan_support_cards(self) -> List[SupportCardInfo]: + """ + Scan all career_support_container elements and extract information. + + This method: + 1. Detects all career_support_container elements using YOLO + 2. For each container, extracts career_support_level using OCR + 3. For each container, extracts career_support_name using OCR + 4. Creates SupportCardInfo objects with extracted data + + Returns: + List of SupportCardInfo objects for all scanned support cards + """ + logger_uma.debug("[SupportSelectFlow] Scanning support cards") + + try: + # Capture current screen and run YOLO detection + img, _, dets = self.yolo_engine.recognize( + imgsz=832, + conf=0.51, + iou=0.45, + tag="support_scan", + agent="career_loop", + ) + + # Filter for support card containers + container_dets = det_filter(dets, ["career_support_container"]) + + if not container_dets: + logger_uma.debug("[SupportSelectFlow] No support containers detected") + return [] + + logger_uma.debug( + "[SupportSelectFlow] Found %d support containers", + len(container_dets), + ) + + # Extract information from each container + cards: List[SupportCardInfo] = [] + + for i, container in enumerate(container_dets): + try: + card_info = self._extract_card_info(img, dets, container, i) + if card_info: + cards.append(card_info) + except Exception as e: + logger_uma.warning( + "[SupportSelectFlow] Error extracting card %d: %s", + i, + str(e), + ) + continue + + logger_uma.info( + "[SupportSelectFlow] Successfully extracted %d/%d support cards", + len(cards), + len(container_dets), + ) + + return cards + + except Exception as e: + logger_uma.error( + "[SupportSelectFlow] Error scanning support cards: %s", + str(e), + exc_info=True, + ) + return [] + + def _extract_card_info( + self, + img, + all_dets: list, + container_det: dict, + index: int, + ) -> Optional[SupportCardInfo]: + """ + Extract level and name from a single support card container. + + Args: + img: Full screen image + all_dets: All YOLO detections from the screen + container_det: The container detection dict + index: Index of this container (for logging) + + Returns: + SupportCardInfo if extraction successful, None otherwise + """ + if not self.ocr: + logger_uma.warning( + "[SupportSelectFlow] OCR not available, cannot extract card info" + ) + return None + + try: + container_xyxy = container_det["xyxy"] + + # Find level and name detections within this container + level_det = self._find_detection_in_container( + all_dets, + container_xyxy, + "career_support_level", + ) + name_det = self._find_detection_in_container( + all_dets, + container_xyxy, + "career_support_name", + ) + + # Extract level + level = -1 + if level_det: + level_crop = crop_pil(img, level_det["xyxy"]) + level = self.ocr.digits(level_crop) + + # Extract name + name = "" + if name_det: + name_crop = crop_pil(img, name_det["xyxy"]) + name = self.ocr.text(name_crop, min_conf=0.2).strip() + + # Validate extracted data + if level <= 0 or level > 50: + logger_uma.debug( + "[SupportSelectFlow] Card %d: Invalid level %d", + index, + level, + ) + # Use default level if extraction failed + level = 1 + + if not name: + logger_uma.debug( + "[SupportSelectFlow] Card %d: Empty name", + index, + ) + name = "Unknown" + + logger_uma.debug( + "[SupportSelectFlow] Card %d: '%s' level %d", + index, + name, + level, + ) + + return SupportCardInfo( + name=name, + level=level, + xyxy=container_xyxy, + container_detection=container_det, + ) + + except Exception as e: + logger_uma.warning( + "[SupportSelectFlow] Error extracting card %d info: %s", + index, + str(e), + ) + return None + + def _find_detection_in_container( + self, + all_dets: list, + container_xyxy: XYXY, + target_class: str, + ) -> Optional[dict]: + """ + Find a detection of target_class that is inside the container bounds. + + Args: + all_dets: All YOLO detections + container_xyxy: Container bounding box (x1, y1, x2, y2) + target_class: Class name to search for + + Returns: + Detection dict if found, None otherwise + """ + target_dets = det_filter(all_dets, [target_class]) + + if not target_dets: + return None + + cx1, cy1, cx2, cy2 = container_xyxy + + # Find detection with center inside container + for det in target_dets: + x1, y1, x2, y2 = det["xyxy"] + center_x = (x1 + x2) / 2 + center_y = (y1 + y2) / 2 + + if cx1 <= center_x <= cx2 and cy1 <= center_y <= cy2: + return det + + return None + + def _find_optimal_support( + self, + cards: List[SupportCardInfo], + ) -> Optional[SupportCardInfo]: + """ + Find support card matching preferred name and level. + + Uses fuzzy matching for support name comparison to handle OCR errors. + + Args: + cards: List of scanned support cards + + Returns: + SupportCardInfo if found, None otherwise + """ + if not cards: + return None + + logger_uma.debug( + "[SupportSelectFlow] Searching for optimal support: '%s' level %d", + self.preferred_support, + self.preferred_level, + ) + + # First pass: exact level match with fuzzy name match + best_match: Optional[SupportCardInfo] = None + best_ratio = 0.0 + fuzzy_threshold = 0.70 # Threshold for fuzzy name matching + + for card in cards: + # Check level match + if card.level != self.preferred_level: + continue + + # Check name match with fuzzy matching + ratio = fuzzy_ratio(card.name, self.preferred_support) + + logger_uma.debug( + "[SupportSelectFlow] Card '%s' level %d: fuzzy ratio %.2f", + card.name, + card.level, + ratio, + ) + + if ratio >= fuzzy_threshold and ratio > best_ratio: + best_match = card + best_ratio = ratio + + if best_match: + logger_uma.info( + "[SupportSelectFlow] Found optimal match: '%s' level %d (ratio: %.2f)", + best_match.name, + best_match.level, + best_ratio, + ) + return best_match + + logger_uma.debug( + "[SupportSelectFlow] No optimal support found matching criteria" + ) + return None + + def _refresh_support_list(self) -> bool: + """ + Click career_borrow_refresh button and wait for refresh to complete. + + Returns: + True if refresh was successful, False otherwise + """ + logger_uma.debug("[SupportSelectFlow] Refreshing support list") + + try: + # Click the refresh button + clicked = self.waiter.click_when( + classes=["career_borrow_refresh"], + timeout_s=self.timeout_scan, + tag="support_refresh", + ) + + if not clicked: + logger_uma.warning( + "[SupportSelectFlow] Failed to click career_borrow_refresh button" + ) + return False + + # Wait for refresh to complete + logger_uma.debug( + "[SupportSelectFlow] Waiting %.1fs for refresh to complete", + self.refresh_wait_seconds, + ) + time.sleep(self.refresh_wait_seconds) + + logger_uma.debug("[SupportSelectFlow] Refresh completed") + return True + + except Exception as e: + logger_uma.error( + "[SupportSelectFlow] Error refreshing support list: %s", + str(e), + exc_info=True, + ) + return False + + def _select_support_card(self, card: SupportCardInfo) -> bool: + """ + Click the specified support card container to select it. + + Args: + card: The support card to select + + Returns: + True if selection was successful, False otherwise + """ + logger_uma.info( + "[SupportSelectFlow] Selecting support card: '%s' level %d", + card.name, + card.level, + ) + + try: + # Calculate center of the container for clicking + x1, y1, x2, y2 = card.xyxy + center_x = int((x1 + x2) / 2) + center_y = int((y1 + y2) / 2) + + # Click the center of the container + self.ctrl.click(center_x, center_y) + + # Wait a moment for selection to register + time.sleep(0.5) + + logger_uma.info( + "[SupportSelectFlow] Successfully selected support card" + ) + return True + + except Exception as e: + logger_uma.error( + "[SupportSelectFlow] Error selecting support card: %s", + str(e), + exc_info=True, + ) + return False diff --git a/core/actions/unity_cup/agent.py b/core/actions/unity_cup/agent.py index 29ca5377..dc0adbe4 100644 --- a/core/actions/unity_cup/agent.py +++ b/core/actions/unity_cup/agent.py @@ -698,7 +698,7 @@ def run(self, *, delay: float = 0.4, max_iterations: int | None = None) -> None: # For other outcomes ("INFIRMARY", "RESTED", "CONTINUE") we just loop continue - if screen == "FinalScreen": + if screen == "FinalScreen" or screen == "CareerComplete": self.claw_turn = 0 # Only if skill list defined if len(self.skill_list) > 0 and self.lobby._go_skills(): @@ -712,19 +712,10 @@ def run(self, *, delay: float = 0.4, max_iterations: int | None = None) -> None: final_result.status.value, final_result.exit_recovered, ) - - # pick = det_filter(dets, ["lobby_skills"])[-1] - # x1 = pick["xyxy"][0] - # y1 = pick["xyxy"][1] - # x2 = pick["xyxy"][2] - # y2 = pick["xyxy"][3] - - # btn_width = abs(x2 - x1) - # x1 += btn_width + btn_width // 10 - # x2 += btn_width + btn_width // 10 - # self.ctrl.click_xyxy_center((x1, y1, x2, y2), clicks=1, jitter=1) + self.is_running = False # end of career logger_uma.info("Detected end of career") + try: self.skill_memory.reset(persist=True) logger_uma.info("[skill_memory] Reset after career completion") diff --git a/core/actions/ura/agent.py b/core/actions/ura/agent.py index 0949ca3b..632a222b 100644 --- a/core/actions/ura/agent.py +++ b/core/actions/ura/agent.py @@ -559,7 +559,7 @@ def run(self, *, delay: float = 0.4, max_iterations: int | None = None) -> None: # For other outcomes ("INFIRMARY", "RESTED", "CONTINUE") we just loop continue - if screen == "FinalScreen": + if screen == "FinalScreen" or screen == "CareerComplete": self.claw_turn = 0 # Only if skill list defined if len(self.skill_list) > 0 and self.lobby._go_skills(): @@ -573,19 +573,10 @@ def run(self, *, delay: float = 0.4, max_iterations: int | None = None) -> None: final_result.status.value, final_result.exit_recovered, ) - - # pick = det_filter(dets, ["lobby_skills"])[-1] - # x1 = pick["xyxy"][0] - # y1 = pick["xyxy"][1] - # x2 = pick["xyxy"][2] - # y2 = pick["xyxy"][3] - - # btn_width = abs(x2 - x1) - # x1 += btn_width + btn_width // 10 - # x2 += btn_width + btn_width // 10 - # self.ctrl.click_xyxy_center((x1, y1, x2, y2), clicks=1, jitter=1) + self.is_running = False # end of career logger_uma.info("Detected end of career") + try: self.skill_memory.reset(persist=True) logger_uma.info("[skill_memory] Reset after career completion") @@ -593,7 +584,6 @@ def run(self, *, delay: float = 0.4, max_iterations: int | None = None) -> None: logger_uma.error("[skill_memory] reset failed: %s", exc) continue - if screen == "ClawMachine": self.claw_turn += 1 logger_uma.debug( diff --git a/core/agent_career_loop.py b/core/agent_career_loop.py new file mode 100644 index 00000000..fbdde483 --- /dev/null +++ b/core/agent_career_loop.py @@ -0,0 +1,1162 @@ +""" +Career Loop Agent for Career Automation Loop. + +This module orchestrates the complete career farming loop: +- Navigate from main menu to career mode +- Handle all setup screens +- Select optimal support card +- Launch the training agent (AgentScenario) +- Loop back to start a new career upon completion +""" + +from __future__ import annotations + +import time +from typing import Optional + +from core.actions.career_loop_types import CareerLoopState, CareerStep +from core.actions.career_nav_flow import CareerNavFlow +from core.actions.support_select_flow import SupportSelectFlow +from core.agent_scenario import AgentScenario +from core.controllers.base import IController +from core.perception.ocr.interface import OCRInterface +from core.perception.yolo.interface import IDetector +from core.utils.abort import abort_requested, request_abort +from core.utils.geometry import crop_pil +from core.utils.logger import logger_uma +from core.utils.waiter import Waiter + + +class AgentCareerLoop: + """ + Top-level orchestrator that manages the complete career farming loop. + + This class coordinates the entire career automation workflow: + - Initializes navigation and support selection flows + - Executes career setup sequence + - Launches and monitors AgentScenario + - Detects career completion + - Loops back to start new career + - Handles errors and recovery + + Attributes: + ctrl: Controller for input and screen capture + ocr: OCR engine for text recognition + yolo_engine: YOLO detection engine for UI elements + waiter: Synchronization utility for UI state transitions + agent_scenario: The training agent to run for each career + career_nav: Navigation flow for menu and setup screens + support_select: Support card selection flow + state: Career loop state tracking + preferred_support: Name of the preferred support card + preferred_level: Desired support card level + max_careers: Maximum number of careers to run (None = infinite) + error_threshold: Stop after this many consecutive errors + """ + + def __init__( + self, + ctrl: IController, + ocr: Optional[OCRInterface], + yolo_engine: IDetector, + waiter: Waiter, + agent_scenario: AgentScenario, + *, + preferred_support: str = "Riko Kashimoto", + preferred_level: int = 50, + max_refresh_attempts: int = 3, + refresh_wait_seconds: float = 5.0, + max_careers: Optional[int] = None, + error_threshold: int = 5, + ): + """ + Initialize CareerLoopAgent with infrastructure components. + + Args: + ctrl: Controller for input and screen capture + ocr: OCR engine for text recognition (optional) + yolo_engine: YOLO detection engine + waiter: Waiter for UI synchronization + agent_scenario: The training agent to run for each career + preferred_support: Name of the preferred support card (default: "Riko Kashimoto") + preferred_level: Desired support card level (default: 50) + max_refresh_attempts: Maximum refresh attempts for support selection (default: 3) + refresh_wait_seconds: Wait time after refresh (default: 5.0s) + max_careers: Maximum number of careers to run, None for infinite (default: None) + error_threshold: Stop after this many consecutive errors (default: 5) + """ + self.ctrl = ctrl + self.ocr = ocr + self.yolo_engine = yolo_engine + self.waiter = waiter + self.agent_scenario = agent_scenario + + # Configuration + self.preferred_support = preferred_support + self.preferred_level = preferred_level + self.max_careers = max_careers + self.error_threshold = error_threshold + + # Initialize flows + self.career_nav = CareerNavFlow( + ctrl=ctrl, + ocr=ocr, + yolo_engine=yolo_engine, + waiter=waiter, + ) + + self.support_select = SupportSelectFlow( + ctrl=ctrl, + ocr=ocr, + yolo_engine=yolo_engine, + waiter=waiter, + preferred_support=preferred_support, + preferred_level=preferred_level, + max_refresh_attempts=max_refresh_attempts, + refresh_wait_seconds=refresh_wait_seconds, + ) + + # Initialize state tracking + self.state = CareerLoopState() + + logger_uma.info( + "[CareerLoopAgent] Initialized: support='%s' level=%d max_careers=%s error_threshold=%d", + preferred_support, + preferred_level, + max_careers if max_careers is not None else "infinite", + error_threshold, + ) + + def _confirm_career_start(self) -> bool: + """ + Confirm career start with double-click on "Start Career!" button. + + This method performs the following steps: + 1. Click button_green with "Start Career!" text using fuzzy search + 2. Check if "Restore" button appears (TP restoration needed) + 3. If restore needed, handle TP restoration flow + 4. Wait 3 seconds for UI transition + 5. Click button_green with "Start Career!" text again (double-click confirmation) + 6. Verify career started successfully + + Returns: + True if career start was confirmed successfully, False otherwise + """ + logger_uma.info("[CareerLoopAgent] Confirming career start") + + try: + # First click: Look for "Start Career!" button with fuzzy matching + logger_uma.debug("[CareerLoopAgent] First click on Start Career button") + clicked_first = self.waiter.click_when( + classes=["button_green"], + texts=["start", "career"], # Fuzzy search patterns + threshold=0.68, + timeout_s=5.0, + tag="career_start_confirm_1", + ) + + if not clicked_first: + logger_uma.warning("[CareerLoopAgent] Failed to click Start Career button (first attempt)") + return False + + logger_uma.debug("[CareerLoopAgent] First click successful, checking for TP restoration") + + # Wait a moment for potential restore dialog + time.sleep(1.5) + + # Check if "Restore" button appears (TP restoration needed) + img, _, dets = self.yolo_engine.recognize( + imgsz=832, + conf=0.51, + iou=0.45, + tag="career_start_restore_check", + agent="career_loop", + ) + + from core.utils.yolo_objects import filter_by_classes as det_filter + restore_buttons = det_filter(dets, ["button_green"]) + + # Check if any green button has "restore" text + restore_needed = False + if restore_buttons and self.ocr: + for det in restore_buttons: + roi = crop_pil(img, det["xyxy"]) + text = self.ocr.text(roi, min_conf=0.2).lower().strip() + if "restore" in text: + restore_needed = True + logger_uma.info("[CareerLoopAgent] TP restoration needed, handling restore flow") + break + + # Handle TP restoration if needed + if restore_needed: + if not self._handle_tp_restoration(): + logger_uma.warning("[CareerLoopAgent] TP restoration failed, continuing anyway") + # Continue even if restoration fails + + # Wait for UI transition + # Second click: Double-click confirmation + logger_uma.debug("[CareerLoopAgent] Second click on Start Career button") + clicked_second = self.waiter.click_when( + classes=["button_green"], + texts=["start", "career"], # Fuzzy search patterns + threshold=0.68, + timeout_s=5.0, + tag="career_start_confirm_2", + ) + + time.sleep(1) + clicked_second = self.waiter.click_when( + classes=["button_green"], + texts=["start", "career"], # Fuzzy search patterns + threshold=0.68, + timeout_s=5.0, + tag="career_start_confirm_2", + ) + + if not clicked_second: + logger_uma.warning("[CareerLoopAgent] Failed to click Start Career button (second attempt)") + return False + + logger_uma.info("[CareerLoopAgent] Career start confirmed successfully") + return True + + except Exception as e: + logger_uma.error( + "[CareerLoopAgent] Error confirming career start: %s", + str(e), + exc_info=True, + ) + return False + + def _handle_tp_restoration(self) -> bool: + """ + Handle TP (Training Points) restoration flow. + + This method performs the following steps: + 1. Click "Restore" button (green button with "restore" text) + 2. Wait for popup with ui_carat and ui_tp buttons + 3. Click "Use" button on the far right of ui_tp (prioritize TP over carat) + 4. Click "OK" button (green button) + 5. Wait 3-5 seconds + 6. Click "Close" button (white button) + + Returns: + True if TP restoration was successful, False otherwise + """ + logger_uma.info("[CareerLoopAgent] Handling TP restoration") + + try: + # Step 1: Click "Restore" button + logger_uma.debug("[CareerLoopAgent] Step 1: Clicking Restore button") + clicked_restore = self.waiter.click_when( + classes=["button_green"], + texts=["restore"], + threshold=0.68, + timeout_s=5.0, + tag="tp_restore_1", + ) + + if not clicked_restore: + logger_uma.warning("[CareerLoopAgent] Failed to click Restore button") + return False + + # Wait for popup to appear + time.sleep(1.5) + + # Step 2: Detect ui_tp and click "Use" button on the far right + logger_uma.debug("[CareerLoopAgent] Step 2: Looking for ui_tp and Use button") + img, _, dets = self.yolo_engine.recognize( + imgsz=832, + conf=0.51, + iou=0.45, + tag="tp_restore_popup", + agent="career_loop", + ) + + from core.utils.yolo_objects import filter_by_classes as det_filter + tp_dets = det_filter(dets, ["ui_tp"]) + white_buttons = det_filter(dets, ["button_white", "white_button"]) + use_carat = False + if not tp_dets: + logger_uma.warning("[CareerLoopAgent] ui_tp not found, trying to use carat instead") + # Fallback to carat if TP not available + carat_dets = det_filter(dets, ["ui_carat"]) + if carat_dets: + tp_dets = carat_dets + use_carat = True + else: + logger_uma.error("[CareerLoopAgent] Neither ui_tp nor ui_carat found") + return False + + # Find the "Use" button that's vertically aligned with ui_tp + # Based on detection data: + # - ui_tp is at y=(273.6, 371.7), center_y ≈ 322 + # - Use button for TP is at y=(152.0, 211.0), center_y ≈ 181 + # - ui_carat is at y=(135.0, 231.4), center_y ≈ 183 + # So we need to find the button that's vertically aligned (similar y-center) + if tp_dets and white_buttons: + tp_x1, tp_y1, tp_x2, tp_y2 = tp_dets[0]["xyxy"] # Use first TP detection + tp_center_y = (tp_y1 + tp_y2) / 2 # Center Y of TP icon + + logger_uma.debug( + "[CareerLoopAgent] TP icon at y-center=%.1f, looking for aligned Use button", + tp_center_y, + ) + + # Find white buttons to the right of TP icon and vertically aligned + use_button = None + best_match = None + min_y_diff = float('inf') + + for btn in white_buttons: + btn_x1, btn_y1, btn_x2, btn_y2 = btn["xyxy"] + btn_center_y = (btn_y1 + btn_y2) / 2 + y_diff = abs(btn_center_y - tp_center_y) + + # Button must be to the right of TP icon + if btn_x1 > tp_x2: + # Check vertical alignment (within 100 pixels tolerance) + if y_diff < 100: + if y_diff < min_y_diff: + min_y_diff = y_diff + best_match = btn + logger_uma.debug( + "[CareerLoopAgent] Found candidate Use button at y-center=%.1f (diff=%.1f)", + btn_center_y, + y_diff, + ) + + if best_match: + use_button = best_match + logger_uma.info( + "[CareerLoopAgent] Selected Use button with y-diff=%.1f (best vertical alignment)", + min_y_diff, + ) + + if use_button: + logger_uma.debug("[CareerLoopAgent] Clicking Use button for TP") + x1, y1, x2, y2 = use_button["xyxy"] + center_x = int((x1 + x2) / 2) + center_y = int((y1 + y2) / 2) + self.ctrl.click(center_x, center_y) + time.sleep(1.0) + else: + logger_uma.warning("[CareerLoopAgent] Use button not found by alignment, trying generic white button") + # Fallback: click any white button with "use" text + clicked_use = self.waiter.click_when( + classes=["button_white", "white_button"], + texts=["use"], + threshold=0.68, + timeout_s=3.0, + tag="tp_restore_use", + ) + if not clicked_use: + logger_uma.error("[CareerLoopAgent] Failed to click Use button") + return False + else: + logger_uma.error("[CareerLoopAgent] Could not find TP icon or white buttons") + return False + + if use_carat: + plus_button_clicked = self.waiter.click_when( + classes=["button_plus"], + threshold=0.68, + timeout_s=3.0, + tag="carat_restore_add" + ) + + if not plus_button_clicked: + logger_uma.error("[CareerLoopAgent] Failed to click Plus Button") + return False + + # Step 3: Click "OK" button (green button) + logger_uma.debug("[CareerLoopAgent] Step 3: Clicking OK button") + clicked_ok = self.waiter.click_when( + classes=["button_green"], + texts=["ok", "confirm"], + threshold=0.68, + timeout_s=5.0, + tag="tp_restore_ok", + ) + + if not clicked_ok: + logger_uma.warning("[CareerLoopAgent] Failed to click OK button") + return False + + # Step 4: Wait 3-5 seconds + logger_uma.debug("[CareerLoopAgent] Step 4: Waiting 4 seconds") + time.sleep(4.0) + + # Step 5: Click "Close" button (white button) + logger_uma.debug("[CareerLoopAgent] Step 5: Clicking Close button") + clicked_close = self.waiter.click_when( + classes=["button_white", "white_button"], + texts=["close"], + threshold=0.38, + timeout_s=5.0, + tag="tp_restore_close", + ) + + if not clicked_close: + logger_uma.warning("[CareerLoopAgent] Failed to click Close button") + return False + + logger_uma.info("[CareerLoopAgent] TP restoration completed successfully") + time.sleep(3.0) + return True + + except Exception as e: + logger_uma.error( + "[CareerLoopAgent] Error handling TP restoration: %s", + str(e), + exc_info=True, + ) + return False + + def _handle_career_start_skip(self) -> bool: + """ + Handle skip dialog at the beginning of a career. + + When starting a career from the career loop, the game shows a skip dialog. + This method performs the following steps: + 1. Click "button_skip" to open skip options + 2. Wait 1 second + 3. Click "no_skip" twice to decline skipping + 4. Continue with normal career flow + + Returns: + True if skip dialog was handled successfully, False otherwise + """ + logger_uma.info("[CareerLoopAgent] Handling career start skip dialog") + + try: + # Step 1: Click button_skip + logger_uma.debug("[CareerLoopAgent] Step 1: Clicking button_skip") + clicked_skip = self.waiter.click_when( + classes=["button_skip"], + timeout_s=10.0, + tag="career_start_skip_1", + ) + + if not clicked_skip: + logger_uma.debug("[CareerLoopAgent] button_skip not found, may not be needed") + return True # Not an error, skip dialog may not appear + + logger_uma.debug("[CareerLoopAgent] Clicked button_skip") + + # Step 2: Wait 1 second + time.sleep(1.0) + + # Step 3: Click no_skip twice + logger_uma.debug("[CareerLoopAgent] Step 3: Clicking no_skip (first time)") + clicked_no_skip_1 = self.waiter.click_when( + classes=["no_skip"], + timeout_s=5.0, + clicks=2, + tag="career_start_no_skip_1", + ) + time.sleep(1) + if not clicked_no_skip_1: + logger_uma.warning("[CareerLoopAgent] Failed to click no_skip (first time)") + clicked_no_skip_2 = self.waiter.click_when( + classes=["no_skip"], + timeout_s=5.0, + clicks=2, + tag="career_start_no_skip_2", + ) + + logger_uma.debug("[CareerLoopAgent] Clicked no_skip (first time), waiting briefly") + + time.sleep(1) + if self.waiter.click_when( + classes=["button_green"], + texts=["confirm"], + tag="career_start_confirm" + ): + logger_uma.info("[CareerLoopAgent] Skip dialog handled successfully") + return True + + except Exception as e: + logger_uma.error( + "[CareerLoopAgent] Error handling career start skip: %s", + str(e), + exc_info=True, + ) + return False + + def _handle_career_completion(self) -> bool: + """ + Handle career completion flow and return to home screen. + + This method performs the following steps: + 1. Click "career_complete" button + 2. Click "finish" button + 3. Wait 5 seconds for results processing + 4. Click "To Home", "Close", "Next" until ui_home is found + (avoiding "Edit Team" button) + + Returns: + True if successfully returned to home screen, False otherwise + """ + logger_uma.info("[CareerLoopAgent] Handling career completion flow") + + try: + # Step 1: Click career_complete button + logger_uma.debug("[CareerLoopAgent] Step 1: Clicking career_complete button") + if not self.waiter.seen(classes=["career_complete"], tag="career_completion_check_career_complete"): + return False + + clicked_complete = self.waiter.click_when( + classes=["career_complete"], + timeout_s=10.0, + tag="career_completion_1", + ) + + if not clicked_complete: + logger_uma.warning("[CareerLoopAgent] Failed to click career_complete button") + # Try to continue anyway + return False + else: + logger_uma.debug("[CareerLoopAgent] Clicked career_complete button") + time.sleep(1.5) + + # Step 2: Click finish button + logger_uma.debug("[CareerLoopAgent] Step 2: Clicking finish button") + clicked_finish = self.waiter.click_when( + classes=["button_green", "button_blue"], + texts=["finish", "complete", "done"], + threshold=0.68, + timeout_s=10.0, + tag="career_completion_finish", + ) + + if not clicked_finish: + logger_uma.warning("[CareerLoopAgent] Failed to click finish button") + # Try to continue anyway + else: + logger_uma.debug("[CareerLoopAgent] Clicked finish button") + + # Step 3: Wait 5 seconds for results processing + logger_uma.debug("[CareerLoopAgent] Step 3: Waiting 5 seconds for results processing") + time.sleep(5.0) + + # Step 4: Click through dialogs until we reach ui_home + logger_uma.debug("[CareerLoopAgent] Step 4: Clicking through dialogs to reach home") + max_clicks = 20 # Safety limit + clicks_count = 0 + + while clicks_count < max_clicks: + clicks_count += 1 + + # Check if we're at home screen + img, _, dets = self.yolo_engine.recognize( + imgsz=832, + conf=0.51, + iou=0.45, + tag="career_completion_check", + agent="career_loop", + ) + + from core.utils.yolo_objects import filter_by_classes as det_filter + home_dets = det_filter(dets, ["ui_home"]) + + if home_dets: + logger_uma.info("[CareerLoopAgent] Successfully reached home screen") + return True + + # Try to click "To Home", "Close", "Next", "OK" buttons + clicked = self.waiter.click_when( + classes=["ui_home", "button_green", "button_white", "button_blue", "button_close"], + texts=["home", "close", "next", "ok", "confirm"], + threshold=0.68, + timeout_s=3.0, + tag=f"career_completion_nav_{clicks_count}", + forbid_texts=["edit", "team"], + ) + + if not clicked: + logger_uma.debug( + "[CareerLoopAgent] No navigation button found (attempt %d/%d)", + clicks_count, + max_clicks, + ) + # Wait a moment and try again + time.sleep(1.0) + else: + logger_uma.debug( + "[CareerLoopAgent] Clicked navigation button (attempt %d/%d)", + clicks_count, + max_clicks, + ) + # Wait for screen transition + time.sleep(1.5) + + logger_uma.error( + "[CareerLoopAgent] Failed to reach home screen after %d clicks", + max_clicks, + ) + return False + + except Exception as e: + logger_uma.error( + "[CareerLoopAgent] Error handling career completion: %s", + str(e), + exc_info=True, + ) + return False + + def _execute_career_cycle(self) -> bool: + """ + Execute one complete career cycle. + + This method performs the following steps: + 1. Navigate to career from main menu + 2. Loop through setup screens (scenario, trainee, legacy) + 3. Detect support formation screen + 4. Handle support card selection + 5. Confirm career start (double-click) + 6. Launch agent_scenario and wait for completion + + Returns: + True if the career cycle completed successfully, False otherwise + """ + logger_uma.info( + "[CareerLoopAgent] Starting career cycle %d", + self.state.total_careers_completed + 1, + ) + + # Record start time + self.state.current_career_start_time = time.time() + + try: + #Check if in career mode + if self._check_if_in_career(): + logger_uma.info("[CareerLoopAgent] Pre-Step: Career checker - Already in career") + return True + if self._handle_career_completion(): + logger_uma.info("[CareerLoopAgent] Career Complete") + return True + # Step 1: Navigate to career from main menu + logger_uma.info("[CareerLoopAgent] Step 1: Navigating to career from main menu") + if not self.career_nav.navigate_to_career_from_menu(): + logger_uma.error("[CareerLoopAgent] Failed to navigate to career") + return False + + logger_uma.debug("[CareerLoopAgent] Navigation to career successful") + + # Step 2: Loop through setup screens + logger_uma.info("[CareerLoopAgent] Step 2: Handling setup screens") + + # We need to handle multiple setup screens until we reach support formation + # The design specifies we should loop through setup screens + max_setup_screens = 10 # Safety limit to prevent infinite loops + setup_screen_count = 0 + + while setup_screen_count < max_setup_screens: + setup_screen_count += 1 + + logger_uma.debug( + "[CareerLoopAgent] Handling setup screen %d/%d", + setup_screen_count, + max_setup_screens, + ) + + # Check if we've reached support formation screen + # We'll try to detect it by checking for career_add_friend_support button + img, _, dets = self.yolo_engine.recognize( + imgsz=832, + conf=0.51, + iou=0.45, + tag="career_cycle_check", + agent="career_loop", + ) + + # Check for support formation screen indicator + from core.utils.yolo_objects import filter_by_classes as det_filter + support_button_dets = det_filter(dets, ["career_add_friend_support"]) + + if support_button_dets: + logger_uma.info("[CareerLoopAgent] Detected support formation screen") + break + + # Not on support formation yet, handle current setup screen + if not self.career_nav.handle_setup_screen(): + logger_uma.warning( + "[CareerLoopAgent] Failed to handle setup screen %d", + setup_screen_count, + ) + # Try to continue anyway + time.sleep(1.0) + else: + # Wait a moment for screen transition + time.sleep(1.0) + + if setup_screen_count >= max_setup_screens: + logger_uma.error( + "[CareerLoopAgent] Exceeded max setup screens (%d), may be stuck", + max_setup_screens, + ) + return False + + # Step 3: Handle support formation screen + logger_uma.info("[CareerLoopAgent] Step 3: Selecting optimal support card") + if not self.support_select.select_optimal_support(): + logger_uma.error("[CareerLoopAgent] Failed to select support card") + return False + + logger_uma.debug("[CareerLoopAgent] Support card selected successfully") + + # Step 4: Confirm career start + logger_uma.info("[CareerLoopAgent] Step 4: Confirming career start") + if not self._confirm_career_start(): + logger_uma.error("[CareerLoopAgent] Failed to confirm career start") + return False + + logger_uma.debug("[CareerLoopAgent] Career start confirmed") + + # Step 5: Launch agent_scenario + logger_uma.info("[CareerLoopAgent] Step 5: Launching training agent") + + # Wait a moment for career to fully start + time.sleep(3.0) + + # Step 5a: Handle skip dialog at career start + logger_uma.info("[CareerLoopAgent] Step 5a: Handling skip dialog") + if not self._handle_career_start_skip(): + logger_uma.warning("[CareerLoopAgent] Failed to handle skip dialog, continuing anyway") + # Continue even if skip handling fails + + # Run the agent scenario + # The agent will run until the career is complete + logger_uma.info("[CareerLoopAgent] Running agent scenario...") + self.agent_scenario.run() + + logger_uma.info( + "[CareerLoopAgent] Agent scenario completed for career %d", + self.state.total_careers_completed + 1, + ) + + # Step 6: Handle career completion and return to home + logger_uma.info("[CareerLoopAgent] Step 6: Handling career completion") + if not self._handle_career_completion(): + logger_uma.error("[CareerLoopAgent] Failed to handle career completion") + return False + + logger_uma.debug("[CareerLoopAgent] Career completion handled successfully") + + # Calculate cycle time + if self.state.current_career_start_time: + cycle_time = time.time() - self.state.current_career_start_time + logger_uma.info( + "[CareerLoopAgent] Career cycle completed in %.1f seconds (%.1f minutes)", + cycle_time, + cycle_time / 60.0, + ) + + return True + + except Exception as e: + logger_uma.error( + "[CareerLoopAgent] Error during career cycle: %s", + str(e), + exc_info=True, + ) + return False + + def _return_to_main_menu(self) -> bool: + """ + Return to main menu for error recovery. + + This method attempts to return to the main menu by clicking the + ui_home button. This is used for error recovery when something + goes wrong during the career cycle. + + Returns: + True if successfully returned to main menu, False otherwise + """ + logger_uma.info("[CareerLoopAgent] Attempting to return to main menu for recovery") + + try: + # Click ui_home button to return to main menu + clicked = self.waiter.click_when( + classes=["ui_home"], + timeout_s=5.0, + tag="career_loop_recovery", + ) + + if clicked: + logger_uma.info("[CareerLoopAgent] Successfully returned to main menu") + # Wait a moment for menu to stabilize + time.sleep(1.0) + return True + else: + logger_uma.warning("[CareerLoopAgent] Failed to click ui_home button for recovery") + return False + + except Exception as e: + logger_uma.error( + "[CareerLoopAgent] Error returning to main menu: %s", + str(e), + exc_info=True, + ) + return False + + def _execute_career_cycle_with_recovery(self) -> bool: + """ + Execute career cycle with error recovery wrapper. + + This method wraps _execute_career_cycle with retry logic and error handling: + - Retries up to 3 times on failure + - Handles different exception types appropriately + - Updates CareerLoopState on success/error + - Returns to main menu on errors + + Returns: + True if career cycle completed successfully, False otherwise + """ + max_retries = 3 + + for attempt in range(max_retries): + try: + logger_uma.debug( + "[CareerLoopAgent] Career cycle attempt %d/%d", + attempt + 1, + max_retries, + ) + + # Execute the career cycle + success = self._execute_career_cycle() + + if success: + # Record success and reset error tracking + self.state.record_success() + logger_uma.info( + "[CareerLoopAgent] Career cycle successful (total: %d)", + self.state.total_careers_completed, + ) + return True + else: + # Cycle failed, but no exception was raised + error_msg = f"Career cycle failed on attempt {attempt + 1}/{max_retries}" + logger_uma.warning("[CareerLoopAgent] %s", error_msg) + + # Try to return to main menu for recovery + if attempt < max_retries - 1: + logger_uma.info("[CareerLoopAgent] Attempting recovery...") + self._return_to_main_menu() + time.sleep(2.0) # Wait before retry + else: + # Last attempt failed + self.state.record_error(error_msg) + logger_uma.error( + "[CareerLoopAgent] All retry attempts exhausted (%d consecutive errors)", + self.state.consecutive_errors, + ) + return False + + except KeyboardInterrupt: + # User requested stop, propagate immediately + logger_uma.info("[CareerLoopAgent] Keyboard interrupt received") + raise + + except Exception as e: + error_msg = f"Exception during career cycle: {type(e).__name__}: {str(e)}" + logger_uma.error( + "[CareerLoopAgent] %s (attempt %d/%d)", + error_msg, + attempt + 1, + max_retries, + exc_info=True, + ) + + # Try to return to main menu for recovery + if attempt < max_retries - 1: + logger_uma.info("[CareerLoopAgent] Attempting recovery...") + try: + self._return_to_main_menu() + time.sleep(2.0) # Wait before retry + except Exception as recovery_error: + logger_uma.error( + "[CareerLoopAgent] Recovery failed: %s", + str(recovery_error), + ) + # Continue to next retry anyway + else: + # Last attempt failed + self.state.record_error(error_msg) + logger_uma.error( + "[CareerLoopAgent] All retry attempts exhausted (%d consecutive errors)", + self.state.consecutive_errors, + ) + return False + + # Should not reach here, but handle it anyway + self.state.record_error("Max retries exceeded") + return False + + def _check_if_in_career(self) -> bool: + """ + Check if we're already in an active career (lobby or training screen). + + This method detects if we're currently in a career by looking for + the career_step indicator and OCR'ing its text to confirm it says "Career". + + Returns: + True if we're in an active career, False otherwise + """ + try: + logger_uma.debug("[CareerLoopAgent] Checking if already in career...") + + # Capture current screen and detect career_step + img, _, dets = self.yolo_engine.recognize( + imgsz=832, + conf=0.51, + iou=0.45, + tag="career_check_in_career", + agent="career_loop", + ) + + from core.utils.yolo_objects import filter_by_classes as det_filter + career_step_dets = det_filter(dets, ["career_step"]) + + if not career_step_dets: + logger_uma.debug("[CareerLoopAgent] No career_step detected - not in career") + return False + + # OCR the career_step region to check if it says "Career" + if not self.ocr: + logger_uma.warning("[CareerLoopAgent] OCR not available, cannot verify career_step text") + return False + + # Use the first career_step detection + career_step_det = career_step_dets[0] + region = crop_pil(img, career_step_det["xyxy"], pad=0) + text = self.ocr.text(region, min_conf=0.2).strip().lower() + + logger_uma.debug( + "[CareerLoopAgent] career_step OCR text: '%s' (conf=%.3f)", + text, + career_step_det.get("conf", 0.0), + ) + + # Check if text contains "career" + if "career" in text or "training" in text: + logger_uma.info(f"[CareerLoopAgent] Detected career_step with {text} text - already in career!") + self.state.is_running = True + self.agent_scenario.run() + return True + else: + logger_uma.debug( + "[CareerLoopAgent] career_step text '%s' does not contain 'career' - not in career", + text, + ) + return False + + except Exception as e: + logger_uma.warning( + "[CareerLoopAgent] Error checking if in career: %s", + str(e), + ) + return False + + def run(self, max_loops: Optional[int] = None) -> None: + """ + Main loop: navigate → setup → launch agent → repeat. + + This method runs the career automation loop until one of the following occurs: + - F1 is pressed (abort signal) + - max_careers limit is reached + - error_threshold consecutive errors occur + + The loop executes career cycles with recovery, checks abort signals between + iterations, and maintains comprehensive statistics. + + Args: + max_loops: Override for max_careers (for testing purposes) + """ + # Use provided max_loops or fall back to configured max_careers + effective_max = max_loops if max_loops is not None else self.max_careers + + logger_uma.info( + "[CareerLoopAgent] Starting career loop: max_careers=%s error_threshold=%d", + effective_max if effective_max is not None else "infinite", + self.error_threshold, + ) + + # Check if we're already in an active career + if self._check_if_in_career(): + logger_uma.info( + "[CareerLoopAgent] Already in career - spawning agent directly without navigation" + ) + + # Set running flag + self.state.is_running = True + + try: + # Run the agent scenario directly + logger_uma.info("[CareerLoopAgent] Running agent scenario for existing career...") + self.agent_scenario.run() + + logger_uma.info("[CareerLoopAgent] Agent scenario completed") + + # Handle career completion and return to home + logger_uma.info("[CareerLoopAgent] Handling career completion") + if self._handle_career_completion(): + self.state.record_success() + logger_uma.info( + "[CareerLoopAgent] Career completed successfully (total: %d)", + self.state.total_careers_completed, + ) + else: + logger_uma.warning("[CareerLoopAgent] Failed to handle career completion") + self.state.record_error("Failed to handle career completion") + + except KeyboardInterrupt: + logger_uma.info("[CareerLoopAgent] Keyboard interrupt received") + raise + + except Exception as e: + error_msg = f"Exception during in-career agent run: {type(e).__name__}: {str(e)}" + logger_uma.error("[CareerLoopAgent] %s", error_msg, exc_info=True) + self.state.record_error(error_msg) + + # After handling the existing career, check if we should continue + if abort_requested(): + logger_uma.info("[CareerLoopAgent] Abort signal detected after existing career, stopping") + self.state.is_running = False + return + + logger_uma.info("[CareerLoopAgent] Existing career handled, continuing with normal loop") + + # Set running flag (or keep it if already set from above) + self.state.is_running = True + + # Initialize loop counter + loop_iteration = 0 + + try: + while self.state.is_running: + loop_iteration += 1 + + # Check abort signal before starting new career + if abort_requested(): + logger_uma.info( + "[CareerLoopAgent] Abort signal detected, stopping loop" + ) + break + + # Check if we've reached max careers limit + if effective_max is not None and self.state.total_careers_completed >= effective_max: + logger_uma.info( + "[CareerLoopAgent] Reached max careers limit (%d), stopping loop", + effective_max, + ) + break + + # Check if we've exceeded error threshold + if self.state.consecutive_errors >= self.error_threshold: + logger_uma.error( + "[CareerLoopAgent] Exceeded error threshold (%d consecutive errors), stopping loop", + self.state.consecutive_errors, + ) + break + + # Log loop statistics + logger_uma.info( + "[CareerLoopAgent] Loop iteration %d: careers_completed=%d consecutive_errors=%d", + loop_iteration, + self.state.total_careers_completed, + self.state.consecutive_errors, + ) + + # Execute career cycle with recovery + cycle_start_time = time.time() + success = self._execute_career_cycle_with_recovery() + cycle_duration = time.time() - cycle_start_time + + if success: + logger_uma.info( + "[CareerLoopAgent] Career cycle %d completed successfully in %.1f seconds", + loop_iteration, + cycle_duration, + ) + else: + logger_uma.warning( + "[CareerLoopAgent] Career cycle %d failed after %.1f seconds", + loop_iteration, + cycle_duration, + ) + + # Check abort signal after career cycle + if abort_requested(): + logger_uma.info( + "[CareerLoopAgent] Abort signal detected after career cycle, stopping loop" + ) + break + + # Brief pause between careers + if self.state.is_running: + time.sleep(1.0) + + except KeyboardInterrupt: + logger_uma.info("[CareerLoopAgent] Keyboard interrupt received, stopping loop") + + except Exception as e: + logger_uma.error( + "[CareerLoopAgent] Unexpected error in main loop: %s", + str(e), + exc_info=True, + ) + + finally: + # Set running flag to False + self.state.is_running = False + + # Log final statistics + logger_uma.info( + "[CareerLoopAgent] Career loop stopped: total_careers=%d consecutive_errors=%d last_error='%s'", + self.state.total_careers_completed, + self.state.consecutive_errors, + self.state.last_error or "none", + ) + + def emergency_stop(self) -> None: + """ + Emergency stop for immediate loop termination. + + This method provides a cooperative emergency stop mechanism: + - Sets is_running flag to False to stop the main loop + - Signals agent_scenario to stop using request_abort() + - Cleans up resources + - Logs the emergency stop event + + This is a best-effort immediate stop that allows the current + operation to complete gracefully before terminating. + """ + logger_uma.warning("[CareerLoopAgent] Emergency stop requested") + + try: + # Set running flag to False + self.state.is_running = False + + # Signal agent_scenario to stop + request_abort() + + # If agent_scenario has an emergency_stop method, call it + if hasattr(self.agent_scenario, 'emergency_stop'): + try: + self.agent_scenario.emergency_stop() + except Exception as e: + logger_uma.error( + "[CareerLoopAgent] Error calling agent_scenario.emergency_stop: %s", + str(e), + ) + + logger_uma.info("[CareerLoopAgent] Emergency stop completed") + + except Exception as e: + logger_uma.error( + "[CareerLoopAgent] Error during emergency stop: %s", + str(e), + exc_info=True, + ) diff --git a/core/perception/analyzers/screen.py b/core/perception/analyzers/screen.py index a0a522ec..27ea23dc 100644 --- a/core/perception/analyzers/screen.py +++ b/core/perception/analyzers/screen.py @@ -67,6 +67,7 @@ def classify_screen_ura( "pal": "lobby_pal", "button_change": "button_change", "race_badge": "race_badge", + "career_complete": "career_complete", } counts = Counter(d["name"] for d in dets) @@ -88,6 +89,10 @@ def classify_screen_ura( has_pal = _any_conf(dets, names_map["pal"], lobby_conf) has_button_change = _any_conf(dets, names_map["button_change"], lobby_conf) has_race_badge = _any_conf(dets, names_map["race_badge"], lobby_conf) + has_career_complete = _any_conf(dets, names_map["career_complete"], 0.5) + + if has_career_complete: + return "CareerComplete", {"has_career_complete": has_career_complete} # 1) Event if n_event_choices >= 2: @@ -203,6 +208,7 @@ def classify_screen_unity_cup( "pal": "lobby_pal", "button_change": "button_change", "race_badge": "race_badge", + "career_complete": "career_complete", } counts = Counter(d["name"] for d in dets) @@ -273,7 +279,10 @@ def _apply_relaxed( race_after_next = _any_conf(dets, names_map["race_after_next"], 0.5) has_button_claw_action = _any_conf(dets, names_map["button_claw_action"], lobby_conf) has_claw = _any_conf(dets, names_map["claw"], lobby_conf) + has_career_complete = _any_conf(dets, names_map["career_complete"], 0.5) + if has_career_complete: + return "CareerComplete", {"has_career_complete": has_career_complete} # 1) Event if n_event_choices >= 2: diff --git a/core/settings.py b/core/settings.py index 559892c9..203bdf8f 100644 --- a/core/settings.py +++ b/core/settings.py @@ -77,6 +77,16 @@ class Settings: # Race if no good training options are available (default: False = skip race if no good training) RACE_IF_NO_GOOD_VALUE: bool = _env_bool("RACE_IF_NO_GOOD_VALUE", default=False) + # --------- Career Loop Configuration --------- + # Career automation loop settings for unattended career farming + CAREER_LOOP_ENABLED: bool = _env_bool("CAREER_LOOP_ENABLED", default=True) + CAREER_LOOP_MAX_CAREERS: Optional[int] = _env_int("CAREER_LOOP_MAX_CAREERS", default=5) or None # None = infinite + CAREER_LOOP_PREFERRED_SUPPORT: str = _env("CAREER_LOOP_PREFERRED_SUPPORT", default="Riko Kashimoto") or "Riko Kashimoto" + CAREER_LOOP_PREFERRED_LEVEL: int = _env_int("CAREER_LOOP_PREFERRED_LEVEL", default=50) + CAREER_LOOP_MAX_REFRESH: int = _env_int("CAREER_LOOP_MAX_REFRESH", default=5) + CAREER_LOOP_REFRESH_WAIT: float = _env_float("CAREER_LOOP_REFRESH_WAIT", default=5.0) + CAREER_LOOP_ERROR_THRESHOLD: int = _env_int("CAREER_LOOP_ERROR_THRESHOLD", default=5) + # --------- Project roots & paths --------- CORE_DIR: Path = Path(__file__).resolve().parent ROOT_DIR: Path = CORE_DIR.parent @@ -101,6 +111,11 @@ class Settings: YOLO_WEIGHTS_NAV: Path = Path( _env("YOLO_WEIGHTS_NAV") or (MODELS_DIR / "uma_nav.pt") ) + + YOLO_WEIGHTS_CAREER_LOOP: Path = Path( + _env("YOLO_WEIGHTS_CAREER_LOOP") or (MODELS_DIR / "uma_career_loop.pt") + ) + IS_BUTTON_ACTIVE_CLF_PATH: Path = Path( _env("IS_BUTTON_ACTIVE_CLF_PATH") or (MODELS_DIR / "active_button_clf.joblib") ) @@ -144,7 +159,7 @@ class Settings: # --------- Detection (YOLO) --------- YOLO_IMGSZ: int = _env_int("YOLO_IMGSZ", default=832) - YOLO_CONF: float = _env_float("YOLO_CONF", default=0.60) # should be 0.7 in general, but we are a little conservative here... + YOLO_CONF: float = _env_float("YOLO_CONF", default=0.50) # should be 0.7 in general, but we are a little conservative here... YOLO_IOU: float = _env_float("YOLO_IOU", default=0.45) UNITY_CUP_GOLDEN_CONF: float = _env_float("UNITY_CUP_GOLDEN_CONF", default=0.61) UNITY_CUP_GOLDEN_RELAXED_CONF: float = _env_float( @@ -516,6 +531,49 @@ def _read_branch(raw_branch: Any) -> tuple[List[dict], Optional[str]]: cls.PRESET_OVERLAY_DURATION = max(1.0, float(adv.get("presetOverlaySeconds"))) except Exception: pass + # Career Loop Configuration + career_loop = adv.get("careerLoop", {}) or {} + cls.CAREER_LOOP_ENABLED = bool(career_loop.get("enabled", cls.CAREER_LOOP_ENABLED)) + + max_careers = career_loop.get("maxCareers") + if max_careers is not None: + try: + cls.CAREER_LOOP_MAX_CAREERS = int(max_careers) if int(max_careers) > 0 else None + except (TypeError, ValueError): + pass + + preferred_support = career_loop.get("preferredSupport") + if preferred_support and isinstance(preferred_support, str): + cls.CAREER_LOOP_PREFERRED_SUPPORT = preferred_support.strip() + + preferred_level = career_loop.get("preferredLevel") + if preferred_level is not None: + try: + cls.CAREER_LOOP_PREFERRED_LEVEL = max(1, min(100, int(preferred_level))) + except (TypeError, ValueError): + pass + + max_refresh = career_loop.get("maxRefresh") + if max_refresh is not None: + try: + cls.CAREER_LOOP_MAX_REFRESH = max(0, min(20, int(max_refresh))) + except (TypeError, ValueError): + pass + + refresh_wait = career_loop.get("refreshWait") + if refresh_wait is not None: + try: + cls.CAREER_LOOP_REFRESH_WAIT = max(1.0, min(30.0, float(refresh_wait))) + except (TypeError, ValueError): + pass + + error_threshold = career_loop.get("errorThreshold") + if error_threshold is not None: + try: + cls.CAREER_LOOP_ERROR_THRESHOLD = max(1, min(20, int(error_threshold))) + except (TypeError, ValueError): + pass + # Update training configuration undertrain_threshold = float( adv.get("undertrainThreshold", cls.UNDERTRAIN_THRESHOLD) diff --git a/core/types.py b/core/types.py index 7d11cbe2..2be076e5 100644 --- a/core/types.py +++ b/core/types.py @@ -30,6 +30,7 @@ class DetectionDict(TypedDict): ScreenName = Literal[ + "CareerComplete", "Raceday", "RaceLobby", "Inspiration", diff --git a/main.py b/main.py index 8c032f82..50ed3620 100644 --- a/main.py +++ b/main.py @@ -25,6 +25,7 @@ from core.utils.logger import logger_uma, setup_uma_logging from core.settings import Settings from core.agent_nav import AgentNav +from core.agent_career_loop import AgentCareerLoop from core.utils.hotkey_manager import get_hotkey_manager from server.main import app @@ -96,8 +97,8 @@ def make_controller_from_settings() -> IController: def make_ocr_yolo_from_settings( - ctrl: IController, weights: str | Path | None = None -) -> tuple[OCRInterface, IDetector]: + ctrl: IController, weights: str | Path | None = None, career_loop_weights: str | Path | None = None +) -> tuple[OCRInterface, IDetector, IDetector]: """Build fresh OCR and YOLO engines based on current Settings.""" resolved_weights = weights if weights is not None else Settings.ACTIVE_YOLO_WEIGHTS weights_str = str(resolved_weights) if resolved_weights is not None else None @@ -125,7 +126,15 @@ def make_ocr_yolo_from_settings( yolo_engine = RemoteYOLOEngine( ctrl=ctrl, base_url=Settings.EXTERNAL_PROCESSOR_URL ) - return ocr, yolo_engine + if career_loop_weights: + career_loop_engine = RemoteYOLOEngine( + ctrl=ctrl, base_url=Settings.EXTERNAL_PROCESSOR_URL, weights=career_loop_weights + ) + else: + career_loop_engine = RemoteYOLOEngine( + ctrl=ctrl, base_url=Settings.EXTERNAL_PROCESSOR_URL, weights=Settings.YOLO_WEIGHTS_CAREER_LOOP + ) + return ocr, yolo_engine, career_loop_engine logger_uma.info("[PERCEPTION] Using internal processors") from core.perception.ocr.ocr_local import LocalOCREngine @@ -140,7 +149,12 @@ def make_ocr_yolo_from_settings( else: yolo_engine = LocalYOLOEngine(ctrl=ctrl) - return ocr, yolo_engine + if career_loop_weights: + career_loop_engine = LocalYOLOEngine(ctrl=ctrl, weights=career_loop_weights) + else: + career_loop_engine = LocalYOLOEngine(ctrl=ctrl, weights=Settings.YOLO_WEIGHTS_CAREER_LOOP) + + return ocr, yolo_engine, career_loop_engine # --------------------------- @@ -237,7 +251,7 @@ def __init__(self): self.running: bool = False self._lock = threading.Lock() - def start(self): + def start(self, career_loop_mode: False): """ Reload config.json -> Settings.apply_config -> build fresh controller + OCR/YOLO -> run Player. This guarantees we always reflect the latest UI changes at start time. @@ -285,7 +299,7 @@ def start(self): ) return - ocr, yolo_engine = make_ocr_yolo_from_settings(ctrl) + ocr, yolo_engine, career_loop_engine = make_ocr_yolo_from_settings(ctrl) # 4) Extract preset-specific runtime opts (skill_list / plan_races / select_style) preset_opts = Settings.extract_runtime_preset(cfg or {}) @@ -334,9 +348,53 @@ def start(self): def _runner(): re_init = False try: - logger_uma.info("[BOT] Started.") + if career_loop_mode: + logger_uma.info("[BOT] Started in Career Loop Mode.") + else: + logger_uma.info("[BOT] Started.") + + + if career_loop_mode: + logger_uma.info("[BOT] Career Loop Mode: Creating AgentCareerLoop wrapper...") + # Create Waiter instance for career loop flows + from core.utils.waiter import Waiter, PollConfig + waiter_config = PollConfig( + imgsz=Settings.YOLO_IMGSZ, + conf=Settings.YOLO_CONF, + iou=Settings.YOLO_IOU, + poll_interval_s=0.5, + timeout_s=4.0, + tag="career_loop", + agent="career_loop", + ) + waiter = Waiter(ctrl=ctrl, ocr=ocr, yolo_engine=career_loop_engine, config=waiter_config) + + # Create AgentCareerLoop with all required dependencies + career_loop = AgentCareerLoop( + ctrl=ctrl, + ocr=ocr, + yolo_engine=career_loop_engine, + waiter=waiter, + agent_scenario=self.agent_scenario, + preferred_support=Settings.CAREER_LOOP_PREFERRED_SUPPORT, + preferred_level=Settings.CAREER_LOOP_PREFERRED_LEVEL, + max_refresh_attempts=Settings.CAREER_LOOP_MAX_REFRESH, + refresh_wait_seconds=Settings.CAREER_LOOP_REFRESH_WAIT, + max_careers=Settings.CAREER_LOOP_MAX_CAREERS, + error_threshold=Settings.CAREER_LOOP_ERROR_THRESHOLD, + ) + + logger_uma.info( + "[BOT] Career Loop Mode: Starting loop (max_careers=%s, support=%s Lv%d)...", + Settings.CAREER_LOOP_MAX_CAREERS or "infinite", + Settings.CAREER_LOOP_PREFERRED_SUPPORT, + Settings.CAREER_LOOP_PREFERRED_LEVEL, + ) + + # Run the career loop + career_loop.run() # if not none - if self.agent_scenario: + elif self.agent_scenario: self.agent_scenario.run( delay=getattr(Settings, "MAIN_LOOP_DELAY", 0.4), max_iterations=getattr(Settings, "MAX_ITERATIONS", None), @@ -512,8 +570,9 @@ def stop(self): # --------------------------- def hotkey_loop(bot_state: BotState, nav_state: NavState): # Support configured hotkey and F2 as backup for Player; F7/F8 for AgentNav - configured = str(getattr(Settings, "HOTKEY", "F2") or "F2").upper() - keys_bot = [configured] + configured = str(getattr(Settings, "HOTKEY", "F2")).upper() + keys_bot = sorted(set([configured, "F2"])) + keys_career_loop = ["F1"] keys_nav = ["F7", "F8", "F9"] logger_uma.info(f"[HOTKEY] Run bot in Scenario (e.g. URA, Unity Cup): press {', '.join(keys_bot)} to start/stop.") logger_uma.info("[HOTKEY] AgentNav: press F7=TeamTrials, F8=DailyRaces") @@ -526,6 +585,7 @@ def hotkey_loop(bot_state: BotState, nav_state: NavState): # Debounce across both hook & poll paths - INCREASED to prevent race condition # between hook (trigger_on_release) and polling (while key pressed) last_ts_toggle = 0.0 + last_ts_career_loop = 0.0 last_ts_team = 0.0 last_ts_daily = 0.0 last_ts_roulette = 0.0 @@ -691,6 +751,38 @@ def _debounced_toggle(source: str): if was_running: _show_scenario_stopped_overlay_if_needed() + def _debounced_career_loop(source: str): + nonlocal last_ts_career_loop + now = time.time() + if now - last_ts_career_loop < 0.8: + logger_uma.debug(f"[HOTKEY] Debounced career-loop from {source}.") + return + last_ts_career_loop = now + + # Check if career loop is enabled in settings + if not bot_state.running and not Settings.CAREER_LOOP_ENABLED: + logger_uma.warning( + "[HOTKEY] Career Loop Mode is disabled in settings. " + "Enable it in the Web UI (config.json: careerLoop.enabled = true) to use F1." + ) + return + + if not bot_state.running: + # Starting career loop mode + if not _select_scenario_before_start(): + return + + logger_uma.info("[HOTKEY] Career Loop Mode: Starting...") + _show_preset_overlay_if_needed() + + # Start in career loop mode (start() will set running flag internally) + bot_state.start(career_loop_mode=True) + else: + # Stopping career loop mode + logger_uma.info("[HOTKEY] Career Loop Mode: Stopping...") + bot_state.stop() + + def _debounced_team(source: str): nonlocal last_ts_team now = time.time() @@ -756,9 +848,32 @@ def _debounced_roulette(source: str): else: nav_state.start(action="roulette") + # Try to register hooks hotkey_mgr = get_hotkey_manager() + # F1 for Career Loop Mode + for k in keys_career_loop: + try: + logger_uma.debug(f"[HOTKEY] Registering hook for {k}…") + success = hotkey_mgr.add_hotkey( + k, + lambda key=k: event_q.put(("career_loop", f"hook:{key}")), + suppress=False, + trigger_on_release=True, + ) + if success: + hooked_keys.add(k) + logger_uma.info(f"[HOTKEY] Hook active for '{k}' (Career Loop Mode).") + except PermissionError as e: + logger_uma.warning( + f"[HOTKEY] PermissionError registering '{k}'. On Windows you may need to run as Administrator. {e}" + ) + except Exception as e: + logger_uma.warning(f"[HOTKEY] Could not register '{k}': {e}") + + # Try to register hooks + for k in keys_bot: try: logger_uma.debug(f"[HOTKEY] Registering hook for {k}…") @@ -810,7 +925,10 @@ def _debounced_roulette(source: str): try: while True: ev, source = event_q.get_nowait() - if ev == "toggle": + logger_uma.debug(f"[HOTKEY] Poll detected '{ev}' from {source}.") + if ev == "career_loop": + _debounced_career_loop(source) + elif ev == "toggle": _debounced_toggle(source) elif ev == "team": _debounced_team(source) @@ -822,6 +940,16 @@ def _debounced_roulette(source: str): pass fired = False + # F1 for Career Loop Mode + for k in keys_career_loop: + try: + if hotkey_mgr.is_pressed(k): + logger_uma.debug(f"[HOTKEY] Poll detected '{k}'.") + _debounced_career_loop(f"poll:{k}") + fired = True + time.sleep(0.20) + except Exception as e: + logger_uma.debug(f"[HOTKEY] Poll error on '{k}': {e}") for k in keys_bot: try: if hotkey_mgr.is_pressed(k): diff --git a/models/uma_career_loop.pt b/models/uma_career_loop.pt new file mode 100644 index 00000000..260bbefd Binary files /dev/null and b/models/uma_career_loop.pt differ diff --git a/requirements.txt b/requirements.txt index 71bc3a6b..88229d8c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +adbutils==2.12.0 aistudio-sdk==0.3.6 annotated-doc==0.0.3 annotated-types==0.7.0 diff --git a/web/dist/assets/index-BY8sGv-Y.js b/web/dist/assets/index-BY8sGv-Y.js new file mode 100644 index 00000000..255d4636 --- /dev/null +++ b/web/dist/assets/index-BY8sGv-Y.js @@ -0,0 +1,280 @@ +function qA(e,t){for(var n=0;no[i]})}}}return Object.freeze(Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}))}(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))o(i);new MutationObserver(i=>{for(const l of i)if(l.type==="childList")for(const c of l.addedNodes)c.tagName==="LINK"&&c.rel==="modulepreload"&&o(c)}).observe(document,{childList:!0,subtree:!0});function n(i){const l={};return i.integrity&&(l.integrity=i.integrity),i.referrerPolicy&&(l.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?l.credentials="include":i.crossOrigin==="anonymous"?l.credentials="omit":l.credentials="same-origin",l}function o(i){if(i.ep)return;i.ep=!0;const l=n(i);fetch(i.href,l)}})();function vy(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Im={exports:{}},pc={};/** + * @license React + * react-jsx-runtime.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var cS;function VA(){if(cS)return pc;cS=1;var e=Symbol.for("react.transitional.element"),t=Symbol.for("react.fragment");function n(o,i,l){var c=null;if(l!==void 0&&(c=""+l),i.key!==void 0&&(c=""+i.key),"key"in i){l={};for(var d in i)d!=="key"&&(l[d]=i[d])}else l=i;return i=l.ref,{$$typeof:e,type:o,key:c,ref:i!==void 0?i:null,props:l}}return pc.Fragment=t,pc.jsx=n,pc.jsxs=n,pc}var uS;function GA(){return uS||(uS=1,Im.exports=VA()),Im.exports}var p=GA(),Lm={exports:{}},St={};/** + * @license React + * react.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var dS;function WA(){if(dS)return St;dS=1;var e=Symbol.for("react.transitional.element"),t=Symbol.for("react.portal"),n=Symbol.for("react.fragment"),o=Symbol.for("react.strict_mode"),i=Symbol.for("react.profiler"),l=Symbol.for("react.consumer"),c=Symbol.for("react.context"),d=Symbol.for("react.forward_ref"),f=Symbol.for("react.suspense"),m=Symbol.for("react.memo"),g=Symbol.for("react.lazy"),v=Symbol.iterator;function x(N){return N===null||typeof N!="object"?null:(N=v&&N[v]||N["@@iterator"],typeof N=="function"?N:null)}var C={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},w=Object.assign,S={};function T(N,I,H){this.props=N,this.context=I,this.refs=S,this.updater=H||C}T.prototype.isReactComponent={},T.prototype.setState=function(N,I){if(typeof N!="object"&&typeof N!="function"&&N!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,N,I,"setState")},T.prototype.forceUpdate=function(N){this.updater.enqueueForceUpdate(this,N,"forceUpdate")};function k(){}k.prototype=T.prototype;function P(N,I,H){this.props=N,this.context=I,this.refs=S,this.updater=H||C}var R=P.prototype=new k;R.constructor=P,w(R,T.prototype),R.isPureReactComponent=!0;var A=Array.isArray,M={H:null,A:null,T:null,S:null,V:null},_=Object.prototype.hasOwnProperty;function $(N,I,H,Z,te,ce){return H=ce.ref,{$$typeof:e,type:N,key:I,ref:H!==void 0?H:null,props:ce}}function z(N,I){return $(N.type,I,void 0,void 0,void 0,N.props)}function B(N){return typeof N=="object"&&N!==null&&N.$$typeof===e}function U(N){var I={"=":"=0",":":"=2"};return"$"+N.replace(/[=:]/g,function(H){return I[H]})}var q=/\/+/g;function E(N,I){return typeof N=="object"&&N!==null&&N.key!=null?U(""+N.key):I.toString(36)}function F(){}function L(N){switch(N.status){case"fulfilled":return N.value;case"rejected":throw N.reason;default:switch(typeof N.status=="string"?N.then(F,F):(N.status="pending",N.then(function(I){N.status==="pending"&&(N.status="fulfilled",N.value=I)},function(I){N.status==="pending"&&(N.status="rejected",N.reason=I)})),N.status){case"fulfilled":return N.value;case"rejected":throw N.reason}}throw N}function G(N,I,H,Z,te){var ce=typeof N;(ce==="undefined"||ce==="boolean")&&(N=null);var Y=!1;if(N===null)Y=!0;else switch(ce){case"bigint":case"string":case"number":Y=!0;break;case"object":switch(N.$$typeof){case e:case t:Y=!0;break;case g:return Y=N._init,G(Y(N._payload),I,H,Z,te)}}if(Y)return te=te(N),Y=Z===""?"."+E(N,0):Z,A(te)?(H="",Y!=null&&(H=Y.replace(q,"$&/")+"/"),G(te,I,H,"",function(ke){return ke})):te!=null&&(B(te)&&(te=z(te,H+(te.key==null||N&&N.key===te.key?"":(""+te.key).replace(q,"$&/")+"/")+Y)),I.push(te)),1;Y=0;var Re=Z===""?".":Z+":";if(A(N))for(var Ce=0;Ce>>1,N=j[X];if(0>>1;Xi(Z,J))tei(ce,Z)?(j[X]=ce,j[te]=J,X=te):(j[X]=Z,j[H]=J,X=H);else if(tei(ce,J))j[X]=ce,j[te]=J,X=te;else break e}}return W}function i(j,W){var J=j.sortIndex-W.sortIndex;return J!==0?J:j.id-W.id}if(e.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var l=performance;e.unstable_now=function(){return l.now()}}else{var c=Date,d=c.now();e.unstable_now=function(){return c.now()-d}}var f=[],m=[],g=1,v=null,x=3,C=!1,w=!1,S=!1,T=!1,k=typeof setTimeout=="function"?setTimeout:null,P=typeof clearTimeout=="function"?clearTimeout:null,R=typeof setImmediate<"u"?setImmediate:null;function A(j){for(var W=n(m);W!==null;){if(W.callback===null)o(m);else if(W.startTime<=j)o(m),W.sortIndex=W.expirationTime,t(f,W);else break;W=n(m)}}function M(j){if(S=!1,A(j),!w)if(n(f)!==null)w=!0,_||(_=!0,E());else{var W=n(m);W!==null&&G(M,W.startTime-j)}}var _=!1,$=-1,z=5,B=-1;function U(){return T?!0:!(e.unstable_now()-Bj&&U());){var X=v.callback;if(typeof X=="function"){v.callback=null,x=v.priorityLevel;var N=X(v.expirationTime<=j);if(j=e.unstable_now(),typeof N=="function"){v.callback=N,A(j),W=!0;break t}v===n(f)&&o(f),A(j)}else o(f);v=n(f)}if(v!==null)W=!0;else{var I=n(m);I!==null&&G(M,I.startTime-j),W=!1}}break e}finally{v=null,x=J,C=!1}W=void 0}}finally{W?E():_=!1}}}var E;if(typeof R=="function")E=function(){R(q)};else if(typeof MessageChannel<"u"){var F=new MessageChannel,L=F.port2;F.port1.onmessage=q,E=function(){L.postMessage(null)}}else E=function(){k(q,0)};function G(j,W){$=k(function(){j(e.unstable_now())},W)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(j){j.callback=null},e.unstable_forceFrameRate=function(j){0>j||125X?(j.sortIndex=J,t(m,j),n(f)===null&&j===n(m)&&(S?(P($),$=-1):S=!0,G(M,J-X))):(j.sortIndex=N,t(f,j),w||C||(w=!0,_||(_=!0,E()))),j},e.unstable_shouldYield=U,e.unstable_wrapCallback=function(j){var W=x;return function(){var J=x;x=W;try{return j.apply(this,arguments)}finally{x=J}}}})(Fm)),Fm}var hS;function KA(){return hS||(hS=1,Um.exports=ZA()),Um.exports}var Hm={exports:{}},fr={};/** + * @license React + * react-dom.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var mS;function YA(){if(mS)return fr;mS=1;var e=by();function t(f){var m="https://react.dev/errors/"+f;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(t){console.error(t)}}return e(),Hm.exports=YA(),Hm.exports}/** + * @license React + * react-dom-client.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var yS;function XA(){if(yS)return hc;yS=1;var e=KA(),t=by(),n=Ew();function o(r){var a="https://react.dev/errors/"+r;if(1N||(r.current=X[N],X[N]=null,N--)}function Z(r,a){N++,X[N]=r.current,r.current=a}var te=I(null),ce=I(null),Y=I(null),Re=I(null);function Ce(r,a){switch(Z(Y,a),Z(ce,r),Z(te,null),a.nodeType){case 9:case 11:r=(r=a.documentElement)&&(r=r.namespaceURI)?Nx(r):0;break;default:if(r=a.tagName,a=a.namespaceURI)a=Nx(a),r=Ix(a,r);else switch(r){case"svg":r=1;break;case"math":r=2;break;default:r=0}}H(te),Z(te,r)}function ke(){H(te),H(ce),H(Y)}function ge(r){r.memoizedState!==null&&Z(Re,r);var a=te.current,s=Ix(a,r.type);a!==s&&(Z(ce,r),Z(te,s))}function Ne(r){ce.current===r&&(H(te),H(ce)),Re.current===r&&(H(Re),lc._currentValue=J)}var Q=Object.prototype.hasOwnProperty,se=e.unstable_scheduleCallback,ue=e.unstable_cancelCallback,Ie=e.unstable_shouldYield,Ge=e.unstable_requestPaint,Ve=e.unstable_now,Te=e.unstable_getCurrentPriorityLevel,st=e.unstable_ImmediatePriority,$e=e.unstable_UserBlockingPriority,tt=e.unstable_NormalPriority,Pe=e.unstable_LowPriority,Et=e.unstable_IdlePriority,ft=e.log,Oe=e.unstable_setDisableYieldValue,xe=null,Ee=null;function ze(r){if(typeof ft=="function"&&Oe(r),Ee&&typeof Ee.setStrictMode=="function")try{Ee.setStrictMode(xe,r)}catch{}}var _e=Math.clz32?Math.clz32:mt,Ue=Math.log,Me=Math.LN2;function mt(r){return r>>>=0,r===0?32:31-(Ue(r)/Me|0)|0}var pt=256,xt=4194304;function Le(r){var a=r&42;if(a!==0)return a;switch(r&-r){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return r&4194048;case 4194304:case 8388608:case 16777216:case 33554432:return r&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return r}}function lt(r,a,s){var u=r.pendingLanes;if(u===0)return 0;var h=0,y=r.suspendedLanes,O=r.pingedLanes;r=r.warmLanes;var D=u&134217727;return D!==0?(u=D&~y,u!==0?h=Le(u):(O&=D,O!==0?h=Le(O):s||(s=D&~r,s!==0&&(h=Le(s))))):(D=u&~y,D!==0?h=Le(D):O!==0?h=Le(O):s||(s=u&~r,s!==0&&(h=Le(s)))),h===0?0:a!==0&&a!==h&&(a&y)===0&&(y=h&-h,s=a&-a,y>=s||y===32&&(s&4194048)!==0)?a:h}function gt(r,a){return(r.pendingLanes&~(r.suspendedLanes&~r.pingedLanes)&a)===0}function nn(r,a){switch(r){case 1:case 2:case 4:case 8:case 64:return a+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return a+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function xn(){var r=pt;return pt<<=1,(pt&4194048)===0&&(pt=256),r}function Qn(){var r=xt;return xt<<=1,(xt&62914560)===0&&(xt=4194304),r}function Nn(r){for(var a=[],s=0;31>s;s++)a.push(r);return a}function nt(r,a){r.pendingLanes|=a,a!==268435456&&(r.suspendedLanes=0,r.pingedLanes=0,r.warmLanes=0)}function yt(r,a,s,u,h,y){var O=r.pendingLanes;r.pendingLanes=s,r.suspendedLanes=0,r.pingedLanes=0,r.warmLanes=0,r.expiredLanes&=s,r.entangledLanes&=s,r.errorRecoveryDisabledLanes&=s,r.shellSuspendCounter=0;var D=r.entanglements,V=r.expirationTimes,re=r.hiddenUpdates;for(s=O&~s;0)":-1h||V[u]!==re[h]){var me=` +`+V[u].replace(" at new "," at ");return r.displayName&&me.includes("")&&(me=me.replace("",r.displayName)),me}while(1<=u&&0<=h);break}}}finally{Qr=!1,Error.prepareStackTrace=s}return(s=r?r.displayName||r.name:"")?xr(s):""}function IT(r){switch(r.tag){case 26:case 27:case 5:return xr(r.type);case 16:return xr("Lazy");case 13:return xr("Suspense");case 19:return xr("SuspenseList");case 0:case 15:return na(r.type,!1);case 11:return na(r.type.render,!1);case 1:return na(r.type,!0);case 31:return xr("Activity");default:return""}}function Pv(r){try{var a="";do a+=IT(r),r=r.return;while(r);return a}catch(s){return` +Error generating stack: `+s.message+` +`+s.stack}}function Jr(r){switch(typeof r){case"bigint":case"boolean":case"number":case"string":case"undefined":return r;case"object":return r;default:return""}}function jv(r){var a=r.type;return(r=r.nodeName)&&r.toLowerCase()==="input"&&(a==="checkbox"||a==="radio")}function LT(r){var a=jv(r)?"checked":"value",s=Object.getOwnPropertyDescriptor(r.constructor.prototype,a),u=""+r[a];if(!r.hasOwnProperty(a)&&typeof s<"u"&&typeof s.get=="function"&&typeof s.set=="function"){var h=s.get,y=s.set;return Object.defineProperty(r,a,{configurable:!0,get:function(){return h.call(this)},set:function(O){u=""+O,y.call(this,O)}}),Object.defineProperty(r,a,{enumerable:s.enumerable}),{getValue:function(){return u},setValue:function(O){u=""+O},stopTracking:function(){r._valueTracker=null,delete r[a]}}}}function Au(r){r._valueTracker||(r._valueTracker=LT(r))}function _v(r){if(!r)return!1;var a=r._valueTracker;if(!a)return!0;var s=a.getValue(),u="";return r&&(u=jv(r)?r.checked?"true":"false":r.value),r=u,r!==s?(a.setValue(r),!0):!1}function ku(r){if(r=r||(typeof document<"u"?document:void 0),typeof r>"u")return null;try{return r.activeElement||r.body}catch{return r.body}}var BT=/[\n"\\]/g;function eo(r){return r.replace(BT,function(a){return"\\"+a.charCodeAt(0).toString(16)+" "})}function Dp(r,a,s,u,h,y,O,D){r.name="",O!=null&&typeof O!="function"&&typeof O!="symbol"&&typeof O!="boolean"?r.type=O:r.removeAttribute("type"),a!=null?O==="number"?(a===0&&r.value===""||r.value!=a)&&(r.value=""+Jr(a)):r.value!==""+Jr(a)&&(r.value=""+Jr(a)):O!=="submit"&&O!=="reset"||r.removeAttribute("value"),a!=null?$p(r,O,Jr(a)):s!=null?$p(r,O,Jr(s)):u!=null&&r.removeAttribute("value"),h==null&&y!=null&&(r.defaultChecked=!!y),h!=null&&(r.checked=h&&typeof h!="function"&&typeof h!="symbol"),D!=null&&typeof D!="function"&&typeof D!="symbol"&&typeof D!="boolean"?r.name=""+Jr(D):r.removeAttribute("name")}function zv(r,a,s,u,h,y,O,D){if(y!=null&&typeof y!="function"&&typeof y!="symbol"&&typeof y!="boolean"&&(r.type=y),a!=null||s!=null){if(!(y!=="submit"&&y!=="reset"||a!=null))return;s=s!=null?""+Jr(s):"",a=a!=null?""+Jr(a):s,D||a===r.value||(r.value=a),r.defaultValue=a}u=u??h,u=typeof u!="function"&&typeof u!="symbol"&&!!u,r.checked=D?r.checked:!!u,r.defaultChecked=!!u,O!=null&&typeof O!="function"&&typeof O!="symbol"&&typeof O!="boolean"&&(r.name=O)}function $p(r,a,s){a==="number"&&ku(r.ownerDocument)===r||r.defaultValue===""+s||(r.defaultValue=""+s)}function rs(r,a,s,u){if(r=r.options,a){a={};for(var h=0;h"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Up=!1;if(ra)try{var Cl={};Object.defineProperty(Cl,"passive",{get:function(){Up=!0}}),window.addEventListener("test",Cl,Cl),window.removeEventListener("test",Cl,Cl)}catch{Up=!1}var Ta=null,Fp=null,Mu=null;function Uv(){if(Mu)return Mu;var r,a=Fp,s=a.length,u,h="value"in Ta?Ta.value:Ta.textContent,y=h.length;for(r=0;r=Tl),Wv=" ",Zv=!1;function Kv(r,a){switch(r){case"keyup":return hE.indexOf(a.keyCode)!==-1;case"keydown":return a.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Yv(r){return r=r.detail,typeof r=="object"&&"data"in r?r.data:null}var ss=!1;function gE(r,a){switch(r){case"compositionend":return Yv(a);case"keypress":return a.which!==32?null:(Zv=!0,Wv);case"textInput":return r=a.data,r===Wv&&Zv?null:r;default:return null}}function yE(r,a){if(ss)return r==="compositionend"||!Wp&&Kv(r,a)?(r=Uv(),Mu=Fp=Ta=null,ss=!1,r):null;switch(r){case"paste":return null;case"keypress":if(!(a.ctrlKey||a.altKey||a.metaKey)||a.ctrlKey&&a.altKey){if(a.char&&1=a)return{node:s,offset:a-r};r=u}e:{for(;s;){if(s.nextSibling){s=s.nextSibling;break e}s=s.parentNode}s=void 0}s=ob(s)}}function ib(r,a){return r&&a?r===a?!0:r&&r.nodeType===3?!1:a&&a.nodeType===3?ib(r,a.parentNode):"contains"in r?r.contains(a):r.compareDocumentPosition?!!(r.compareDocumentPosition(a)&16):!1:!1}function sb(r){r=r!=null&&r.ownerDocument!=null&&r.ownerDocument.defaultView!=null?r.ownerDocument.defaultView:window;for(var a=ku(r.document);a instanceof r.HTMLIFrameElement;){try{var s=typeof a.contentWindow.location.href=="string"}catch{s=!1}if(s)r=a.contentWindow;else break;a=ku(r.document)}return a}function Yp(r){var a=r&&r.nodeName&&r.nodeName.toLowerCase();return a&&(a==="input"&&(r.type==="text"||r.type==="search"||r.type==="tel"||r.type==="url"||r.type==="password")||a==="textarea"||r.contentEditable==="true")}var TE=ra&&"documentMode"in document&&11>=document.documentMode,ls=null,Xp=null,Ol=null,Qp=!1;function lb(r,a,s){var u=s.window===s?s.document:s.nodeType===9?s:s.ownerDocument;Qp||ls==null||ls!==ku(u)||(u=ls,"selectionStart"in u&&Yp(u)?u={start:u.selectionStart,end:u.selectionEnd}:(u=(u.ownerDocument&&u.ownerDocument.defaultView||window).getSelection(),u={anchorNode:u.anchorNode,anchorOffset:u.anchorOffset,focusNode:u.focusNode,focusOffset:u.focusOffset}),Ol&&kl(Ol,u)||(Ol=u,u=xd(Xp,"onSelect"),0>=O,h-=O,aa=1<<32-_e(a)+h|s<y?y:8;var O=j.T,D={};j.T=D,Nh(r,!1,a,s);try{var V=h(),re=j.S;if(re!==null&&re(D,V),V!==null&&typeof V=="object"&&typeof V.then=="function"){var me=zE(V,u);ql(r,a,me,Hr(r))}else ql(r,a,u,Hr(r))}catch(be){ql(r,a,{then:function(){},status:"rejected",reason:be},Hr())}finally{W.p=y,j.T=O}}function LE(){}function Dh(r,a,s,u){if(r.tag!==5)throw Error(o(476));var h=c0(r).queue;l0(r,h,a,J,s===null?LE:function(){return u0(r),s(u)})}function c0(r){var a=r.memoizedState;if(a!==null)return a;a={memoizedState:J,baseState:J,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:ca,lastRenderedState:J},next:null};var s={};return a.next={memoizedState:s,baseState:s,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:ca,lastRenderedState:s},next:null},r.memoizedState=a,r=r.alternate,r!==null&&(r.memoizedState=a),a}function u0(r){var a=c0(r).next.queue;ql(r,a,{},Hr())}function $h(){return dr(lc)}function d0(){return Bn().memoizedState}function f0(){return Bn().memoizedState}function BE(r){for(var a=r.return;a!==null;){switch(a.tag){case 24:case 3:var s=Hr();r=ka(s);var u=Oa(a,r,s);u!==null&&(qr(u,a,s),Il(u,a,s)),a={cache:fh()},r.payload=a;return}a=a.return}}function UE(r,a,s){var u=Hr();s={lane:u,revertLane:0,action:s,hasEagerState:!1,eagerState:null,next:null},ed(r)?h0(a,s):(s=nh(r,a,s,u),s!==null&&(qr(s,r,u),m0(s,a,u)))}function p0(r,a,s){var u=Hr();ql(r,a,s,u)}function ql(r,a,s,u){var h={lane:u,revertLane:0,action:s,hasEagerState:!1,eagerState:null,next:null};if(ed(r))h0(a,h);else{var y=r.alternate;if(r.lanes===0&&(y===null||y.lanes===0)&&(y=a.lastRenderedReducer,y!==null))try{var O=a.lastRenderedState,D=y(O,s);if(h.hasEagerState=!0,h.eagerState=D,Ir(D,O))return Nu(r,a,h,0),rn===null&&$u(),!1}catch{}finally{}if(s=nh(r,a,h,u),s!==null)return qr(s,r,u),m0(s,a,u),!0}return!1}function Nh(r,a,s,u){if(u={lane:2,revertLane:mm(),action:u,hasEagerState:!1,eagerState:null,next:null},ed(r)){if(a)throw Error(o(479))}else a=nh(r,s,u,2),a!==null&&qr(a,r,2)}function ed(r){var a=r.alternate;return r===Rt||a!==null&&a===Rt}function h0(r,a){vs=Zu=!0;var s=r.pending;s===null?a.next=a:(a.next=s.next,s.next=a),r.pending=a}function m0(r,a,s){if((s&4194048)!==0){var u=a.lanes;u&=r.pendingLanes,s|=u,a.lanes=s,Ut(r,s)}}var td={readContext:dr,use:Yu,useCallback:Mn,useContext:Mn,useEffect:Mn,useImperativeHandle:Mn,useLayoutEffect:Mn,useInsertionEffect:Mn,useMemo:Mn,useReducer:Mn,useRef:Mn,useState:Mn,useDebugValue:Mn,useDeferredValue:Mn,useTransition:Mn,useSyncExternalStore:Mn,useId:Mn,useHostTransitionStatus:Mn,useFormState:Mn,useActionState:Mn,useOptimistic:Mn,useMemoCache:Mn,useCacheRefresh:Mn},g0={readContext:dr,use:Yu,useCallback:function(r,a){return Rr().memoizedState=[r,a===void 0?null:a],r},useContext:dr,useEffect:Jb,useImperativeHandle:function(r,a,s){s=s!=null?s.concat([r]):null,Ju(4194308,4,r0.bind(null,a,r),s)},useLayoutEffect:function(r,a){return Ju(4194308,4,r,a)},useInsertionEffect:function(r,a){Ju(4,2,r,a)},useMemo:function(r,a){var s=Rr();a=a===void 0?null:a;var u=r();if(Ri){ze(!0);try{r()}finally{ze(!1)}}return s.memoizedState=[u,a],u},useReducer:function(r,a,s){var u=Rr();if(s!==void 0){var h=s(a);if(Ri){ze(!0);try{s(a)}finally{ze(!1)}}}else h=a;return u.memoizedState=u.baseState=h,r={pending:null,lanes:0,dispatch:null,lastRenderedReducer:r,lastRenderedState:h},u.queue=r,r=r.dispatch=UE.bind(null,Rt,r),[u.memoizedState,r]},useRef:function(r){var a=Rr();return r={current:r},a.memoizedState=r},useState:function(r){r=Ph(r);var a=r.queue,s=p0.bind(null,Rt,a);return a.dispatch=s,[r.memoizedState,s]},useDebugValue:_h,useDeferredValue:function(r,a){var s=Rr();return zh(s,r,a)},useTransition:function(){var r=Ph(!1);return r=l0.bind(null,Rt,r.queue,!0,!1),Rr().memoizedState=r,[!1,r]},useSyncExternalStore:function(r,a,s){var u=Rt,h=Rr();if(Nt){if(s===void 0)throw Error(o(407));s=s()}else{if(s=a(),rn===null)throw Error(o(349));(_t&124)!==0||Nb(u,a,s)}h.memoizedState=s;var y={value:s,getSnapshot:a};return h.queue=y,Jb(Lb.bind(null,u,y,r),[r]),u.flags|=2048,xs(9,Qu(),Ib.bind(null,u,y,s,a),null),s},useId:function(){var r=Rr(),a=rn.identifierPrefix;if(Nt){var s=ia,u=aa;s=(u&~(1<<32-_e(u)-1)).toString(32)+s,a="«"+a+"R"+s,s=Ku++,0ut?(tr=rt,rt=null):tr=rt.sibling;var Dt=oe(ee,rt,ne[ut],ye);if(Dt===null){rt===null&&(rt=tr);break}r&&rt&&Dt.alternate===null&&a(ee,rt),K=y(Dt,K,ut),Tt===null?Xe=Dt:Tt.sibling=Dt,Tt=Dt,rt=tr}if(ut===ne.length)return s(ee,rt),Nt&&vi(ee,ut),Xe;if(rt===null){for(;utut?(tr=rt,rt=null):tr=rt.sibling;var Ga=oe(ee,rt,Dt.value,ye);if(Ga===null){rt===null&&(rt=tr);break}r&&rt&&Ga.alternate===null&&a(ee,rt),K=y(Ga,K,ut),Tt===null?Xe=Ga:Tt.sibling=Ga,Tt=Ga,rt=tr}if(Dt.done)return s(ee,rt),Nt&&vi(ee,ut),Xe;if(rt===null){for(;!Dt.done;ut++,Dt=ne.next())Dt=be(ee,Dt.value,ye),Dt!==null&&(K=y(Dt,K,ut),Tt===null?Xe=Dt:Tt.sibling=Dt,Tt=Dt);return Nt&&vi(ee,ut),Xe}for(rt=u(rt);!Dt.done;ut++,Dt=ne.next())Dt=ae(rt,ee,ut,Dt.value,ye),Dt!==null&&(r&&Dt.alternate!==null&&rt.delete(Dt.key===null?ut:Dt.key),K=y(Dt,K,ut),Tt===null?Xe=Dt:Tt.sibling=Dt,Tt=Dt);return r&&rt.forEach(function(HA){return a(ee,HA)}),Nt&&vi(ee,ut),Xe}function Xt(ee,K,ne,ye){if(typeof ne=="object"&&ne!==null&&ne.type===w&&ne.key===null&&(ne=ne.props.children),typeof ne=="object"&&ne!==null){switch(ne.$$typeof){case x:e:{for(var Xe=ne.key;K!==null;){if(K.key===Xe){if(Xe=ne.type,Xe===w){if(K.tag===7){s(ee,K.sibling),ye=h(K,ne.props.children),ye.return=ee,ee=ye;break e}}else if(K.elementType===Xe||typeof Xe=="object"&&Xe!==null&&Xe.$$typeof===z&&v0(Xe)===K.type){s(ee,K.sibling),ye=h(K,ne.props),Gl(ye,ne),ye.return=ee,ee=ye;break e}s(ee,K);break}else a(ee,K);K=K.sibling}ne.type===w?(ye=gi(ne.props.children,ee.mode,ye,ne.key),ye.return=ee,ee=ye):(ye=Lu(ne.type,ne.key,ne.props,null,ee.mode,ye),Gl(ye,ne),ye.return=ee,ee=ye)}return O(ee);case C:e:{for(Xe=ne.key;K!==null;){if(K.key===Xe)if(K.tag===4&&K.stateNode.containerInfo===ne.containerInfo&&K.stateNode.implementation===ne.implementation){s(ee,K.sibling),ye=h(K,ne.children||[]),ye.return=ee,ee=ye;break e}else{s(ee,K);break}else a(ee,K);K=K.sibling}ye=ah(ne,ee.mode,ye),ye.return=ee,ee=ye}return O(ee);case z:return Xe=ne._init,ne=Xe(ne._payload),Xt(ee,K,ne,ye)}if(G(ne))return dt(ee,K,ne,ye);if(E(ne)){if(Xe=E(ne),typeof Xe!="function")throw Error(o(150));return ne=Xe.call(ne),ct(ee,K,ne,ye)}if(typeof ne.then=="function")return Xt(ee,K,nd(ne),ye);if(ne.$$typeof===R)return Xt(ee,K,Hu(ee,ne),ye);rd(ee,ne)}return typeof ne=="string"&&ne!==""||typeof ne=="number"||typeof ne=="bigint"?(ne=""+ne,K!==null&&K.tag===6?(s(ee,K.sibling),ye=h(K,ne),ye.return=ee,ee=ye):(s(ee,K),ye=oh(ne,ee.mode,ye),ye.return=ee,ee=ye),O(ee)):s(ee,K)}return function(ee,K,ne,ye){try{Vl=0;var Xe=Xt(ee,K,ne,ye);return Ss=null,Xe}catch(rt){if(rt===$l||rt===Vu)throw rt;var Tt=Lr(29,rt,null,ee.mode);return Tt.lanes=ye,Tt.return=ee,Tt}finally{}}}var Cs=b0(!0),x0=b0(!1),ao=I(null),No=null;function Pa(r){var a=r.alternate;Z(Gn,Gn.current&1),Z(ao,r),No===null&&(a===null||ys.current!==null||a.memoizedState!==null)&&(No=r)}function S0(r){if(r.tag===22){if(Z(Gn,Gn.current),Z(ao,r),No===null){var a=r.alternate;a!==null&&a.memoizedState!==null&&(No=r)}}else ja()}function ja(){Z(Gn,Gn.current),Z(ao,ao.current)}function ua(r){H(ao),No===r&&(No=null),H(Gn)}var Gn=I(0);function od(r){for(var a=r;a!==null;){if(a.tag===13){var s=a.memoizedState;if(s!==null&&(s=s.dehydrated,s===null||s.data==="$?"||Am(s)))return a}else if(a.tag===19&&a.memoizedProps.revealOrder!==void 0){if((a.flags&128)!==0)return a}else if(a.child!==null){a.child.return=a,a=a.child;continue}if(a===r)break;for(;a.sibling===null;){if(a.return===null||a.return===r)return null;a=a.return}a.sibling.return=a.return,a=a.sibling}return null}function Ih(r,a,s,u){a=r.memoizedState,s=s(u,a),s=s==null?a:g({},a,s),r.memoizedState=s,r.lanes===0&&(r.updateQueue.baseState=s)}var Lh={enqueueSetState:function(r,a,s){r=r._reactInternals;var u=Hr(),h=ka(u);h.payload=a,s!=null&&(h.callback=s),a=Oa(r,h,u),a!==null&&(qr(a,r,u),Il(a,r,u))},enqueueReplaceState:function(r,a,s){r=r._reactInternals;var u=Hr(),h=ka(u);h.tag=1,h.payload=a,s!=null&&(h.callback=s),a=Oa(r,h,u),a!==null&&(qr(a,r,u),Il(a,r,u))},enqueueForceUpdate:function(r,a){r=r._reactInternals;var s=Hr(),u=ka(s);u.tag=2,a!=null&&(u.callback=a),a=Oa(r,u,s),a!==null&&(qr(a,r,s),Il(a,r,s))}};function C0(r,a,s,u,h,y,O){return r=r.stateNode,typeof r.shouldComponentUpdate=="function"?r.shouldComponentUpdate(u,y,O):a.prototype&&a.prototype.isPureReactComponent?!kl(s,u)||!kl(h,y):!0}function w0(r,a,s,u){r=a.state,typeof a.componentWillReceiveProps=="function"&&a.componentWillReceiveProps(s,u),typeof a.UNSAFE_componentWillReceiveProps=="function"&&a.UNSAFE_componentWillReceiveProps(s,u),a.state!==r&&Lh.enqueueReplaceState(a,a.state,null)}function Ti(r,a){var s=a;if("ref"in a){s={};for(var u in a)u!=="ref"&&(s[u]=a[u])}if(r=r.defaultProps){s===a&&(s=g({},s));for(var h in r)s[h]===void 0&&(s[h]=r[h])}return s}var ad=typeof reportError=="function"?reportError:function(r){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var a=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof r=="object"&&r!==null&&typeof r.message=="string"?String(r.message):String(r),error:r});if(!window.dispatchEvent(a))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",r);return}console.error(r)};function R0(r){ad(r)}function T0(r){console.error(r)}function E0(r){ad(r)}function id(r,a){try{var s=r.onUncaughtError;s(a.value,{componentStack:a.stack})}catch(u){setTimeout(function(){throw u})}}function A0(r,a,s){try{var u=r.onCaughtError;u(s.value,{componentStack:s.stack,errorBoundary:a.tag===1?a.stateNode:null})}catch(h){setTimeout(function(){throw h})}}function Bh(r,a,s){return s=ka(s),s.tag=3,s.payload={element:null},s.callback=function(){id(r,a)},s}function k0(r){return r=ka(r),r.tag=3,r}function O0(r,a,s,u){var h=s.type.getDerivedStateFromError;if(typeof h=="function"){var y=u.value;r.payload=function(){return h(y)},r.callback=function(){A0(a,s,u)}}var O=s.stateNode;O!==null&&typeof O.componentDidCatch=="function"&&(r.callback=function(){A0(a,s,u),typeof h!="function"&&(Ia===null?Ia=new Set([this]):Ia.add(this));var D=u.stack;this.componentDidCatch(u.value,{componentStack:D!==null?D:""})})}function HE(r,a,s,u,h){if(s.flags|=32768,u!==null&&typeof u=="object"&&typeof u.then=="function"){if(a=s.alternate,a!==null&&_l(a,s,h,!0),s=ao.current,s!==null){switch(s.tag){case 13:return No===null?um():s.alternate===null&&Cn===0&&(Cn=3),s.flags&=-257,s.flags|=65536,s.lanes=h,u===mh?s.flags|=16384:(a=s.updateQueue,a===null?s.updateQueue=new Set([u]):a.add(u),fm(r,u,h)),!1;case 22:return s.flags|=65536,u===mh?s.flags|=16384:(a=s.updateQueue,a===null?(a={transitions:null,markerInstances:null,retryQueue:new Set([u])},s.updateQueue=a):(s=a.retryQueue,s===null?a.retryQueue=new Set([u]):s.add(u)),fm(r,u,h)),!1}throw Error(o(435,s.tag))}return fm(r,u,h),um(),!1}if(Nt)return a=ao.current,a!==null?((a.flags&65536)===0&&(a.flags|=256),a.flags|=65536,a.lanes=h,u!==lh&&(r=Error(o(422),{cause:u}),jl(to(r,s)))):(u!==lh&&(a=Error(o(423),{cause:u}),jl(to(a,s))),r=r.current.alternate,r.flags|=65536,h&=-h,r.lanes|=h,u=to(u,s),h=Bh(r.stateNode,u,h),vh(r,h),Cn!==4&&(Cn=2)),!1;var y=Error(o(520),{cause:u});if(y=to(y,s),Jl===null?Jl=[y]:Jl.push(y),Cn!==4&&(Cn=2),a===null)return!0;u=to(u,s),s=a;do{switch(s.tag){case 3:return s.flags|=65536,r=h&-h,s.lanes|=r,r=Bh(s.stateNode,u,r),vh(s,r),!1;case 1:if(a=s.type,y=s.stateNode,(s.flags&128)===0&&(typeof a.getDerivedStateFromError=="function"||y!==null&&typeof y.componentDidCatch=="function"&&(Ia===null||!Ia.has(y))))return s.flags|=65536,h&=-h,s.lanes|=h,h=k0(h),O0(h,r,s,u),vh(s,h),!1}s=s.return}while(s!==null);return!1}var M0=Error(o(461)),Jn=!1;function ar(r,a,s,u){a.child=r===null?x0(a,null,s,u):Cs(a,r.child,s,u)}function P0(r,a,s,u,h){s=s.render;var y=a.ref;if("ref"in u){var O={};for(var D in u)D!=="ref"&&(O[D]=u[D])}else O=u;return Ci(a),u=wh(r,a,s,O,y,h),D=Rh(),r!==null&&!Jn?(Th(r,a,h),da(r,a,h)):(Nt&&D&&ih(a),a.flags|=1,ar(r,a,u,h),a.child)}function j0(r,a,s,u,h){if(r===null){var y=s.type;return typeof y=="function"&&!rh(y)&&y.defaultProps===void 0&&s.compare===null?(a.tag=15,a.type=y,_0(r,a,y,u,h)):(r=Lu(s.type,null,u,a,a.mode,h),r.ref=a.ref,r.return=a,a.child=r)}if(y=r.child,!Zh(r,h)){var O=y.memoizedProps;if(s=s.compare,s=s!==null?s:kl,s(O,u)&&r.ref===a.ref)return da(r,a,h)}return a.flags|=1,r=oa(y,u),r.ref=a.ref,r.return=a,a.child=r}function _0(r,a,s,u,h){if(r!==null){var y=r.memoizedProps;if(kl(y,u)&&r.ref===a.ref)if(Jn=!1,a.pendingProps=u=y,Zh(r,h))(r.flags&131072)!==0&&(Jn=!0);else return a.lanes=r.lanes,da(r,a,h)}return Uh(r,a,s,u,h)}function z0(r,a,s){var u=a.pendingProps,h=u.children,y=r!==null?r.memoizedState:null;if(u.mode==="hidden"){if((a.flags&128)!==0){if(u=y!==null?y.baseLanes|s:s,r!==null){for(h=a.child=r.child,y=0;h!==null;)y=y|h.lanes|h.childLanes,h=h.sibling;a.childLanes=y&~u}else a.childLanes=0,a.child=null;return D0(r,a,u,s)}if((s&536870912)!==0)a.memoizedState={baseLanes:0,cachePool:null},r!==null&&qu(a,y!==null?y.cachePool:null),y!==null?_b(a,y):xh(),S0(a);else return a.lanes=a.childLanes=536870912,D0(r,a,y!==null?y.baseLanes|s:s,s)}else y!==null?(qu(a,y.cachePool),_b(a,y),ja(),a.memoizedState=null):(r!==null&&qu(a,null),xh(),ja());return ar(r,a,h,s),a.child}function D0(r,a,s,u){var h=hh();return h=h===null?null:{parent:Vn._currentValue,pool:h},a.memoizedState={baseLanes:s,cachePool:h},r!==null&&qu(a,null),xh(),S0(a),r!==null&&_l(r,a,u,!0),null}function sd(r,a){var s=a.ref;if(s===null)r!==null&&r.ref!==null&&(a.flags|=4194816);else{if(typeof s!="function"&&typeof s!="object")throw Error(o(284));(r===null||r.ref!==s)&&(a.flags|=4194816)}}function Uh(r,a,s,u,h){return Ci(a),s=wh(r,a,s,u,void 0,h),u=Rh(),r!==null&&!Jn?(Th(r,a,h),da(r,a,h)):(Nt&&u&&ih(a),a.flags|=1,ar(r,a,s,h),a.child)}function $0(r,a,s,u,h,y){return Ci(a),a.updateQueue=null,s=Db(a,u,s,h),zb(r),u=Rh(),r!==null&&!Jn?(Th(r,a,y),da(r,a,y)):(Nt&&u&&ih(a),a.flags|=1,ar(r,a,s,y),a.child)}function N0(r,a,s,u,h){if(Ci(a),a.stateNode===null){var y=fs,O=s.contextType;typeof O=="object"&&O!==null&&(y=dr(O)),y=new s(u,y),a.memoizedState=y.state!==null&&y.state!==void 0?y.state:null,y.updater=Lh,a.stateNode=y,y._reactInternals=a,y=a.stateNode,y.props=u,y.state=a.memoizedState,y.refs={},gh(a),O=s.contextType,y.context=typeof O=="object"&&O!==null?dr(O):fs,y.state=a.memoizedState,O=s.getDerivedStateFromProps,typeof O=="function"&&(Ih(a,s,O,u),y.state=a.memoizedState),typeof s.getDerivedStateFromProps=="function"||typeof y.getSnapshotBeforeUpdate=="function"||typeof y.UNSAFE_componentWillMount!="function"&&typeof y.componentWillMount!="function"||(O=y.state,typeof y.componentWillMount=="function"&&y.componentWillMount(),typeof y.UNSAFE_componentWillMount=="function"&&y.UNSAFE_componentWillMount(),O!==y.state&&Lh.enqueueReplaceState(y,y.state,null),Bl(a,u,y,h),Ll(),y.state=a.memoizedState),typeof y.componentDidMount=="function"&&(a.flags|=4194308),u=!0}else if(r===null){y=a.stateNode;var D=a.memoizedProps,V=Ti(s,D);y.props=V;var re=y.context,me=s.contextType;O=fs,typeof me=="object"&&me!==null&&(O=dr(me));var be=s.getDerivedStateFromProps;me=typeof be=="function"||typeof y.getSnapshotBeforeUpdate=="function",D=a.pendingProps!==D,me||typeof y.UNSAFE_componentWillReceiveProps!="function"&&typeof y.componentWillReceiveProps!="function"||(D||re!==O)&&w0(a,y,u,O),Aa=!1;var oe=a.memoizedState;y.state=oe,Bl(a,u,y,h),Ll(),re=a.memoizedState,D||oe!==re||Aa?(typeof be=="function"&&(Ih(a,s,be,u),re=a.memoizedState),(V=Aa||C0(a,s,V,u,oe,re,O))?(me||typeof y.UNSAFE_componentWillMount!="function"&&typeof y.componentWillMount!="function"||(typeof y.componentWillMount=="function"&&y.componentWillMount(),typeof y.UNSAFE_componentWillMount=="function"&&y.UNSAFE_componentWillMount()),typeof y.componentDidMount=="function"&&(a.flags|=4194308)):(typeof y.componentDidMount=="function"&&(a.flags|=4194308),a.memoizedProps=u,a.memoizedState=re),y.props=u,y.state=re,y.context=O,u=V):(typeof y.componentDidMount=="function"&&(a.flags|=4194308),u=!1)}else{y=a.stateNode,yh(r,a),O=a.memoizedProps,me=Ti(s,O),y.props=me,be=a.pendingProps,oe=y.context,re=s.contextType,V=fs,typeof re=="object"&&re!==null&&(V=dr(re)),D=s.getDerivedStateFromProps,(re=typeof D=="function"||typeof y.getSnapshotBeforeUpdate=="function")||typeof y.UNSAFE_componentWillReceiveProps!="function"&&typeof y.componentWillReceiveProps!="function"||(O!==be||oe!==V)&&w0(a,y,u,V),Aa=!1,oe=a.memoizedState,y.state=oe,Bl(a,u,y,h),Ll();var ae=a.memoizedState;O!==be||oe!==ae||Aa||r!==null&&r.dependencies!==null&&Fu(r.dependencies)?(typeof D=="function"&&(Ih(a,s,D,u),ae=a.memoizedState),(me=Aa||C0(a,s,me,u,oe,ae,V)||r!==null&&r.dependencies!==null&&Fu(r.dependencies))?(re||typeof y.UNSAFE_componentWillUpdate!="function"&&typeof y.componentWillUpdate!="function"||(typeof y.componentWillUpdate=="function"&&y.componentWillUpdate(u,ae,V),typeof y.UNSAFE_componentWillUpdate=="function"&&y.UNSAFE_componentWillUpdate(u,ae,V)),typeof y.componentDidUpdate=="function"&&(a.flags|=4),typeof y.getSnapshotBeforeUpdate=="function"&&(a.flags|=1024)):(typeof y.componentDidUpdate!="function"||O===r.memoizedProps&&oe===r.memoizedState||(a.flags|=4),typeof y.getSnapshotBeforeUpdate!="function"||O===r.memoizedProps&&oe===r.memoizedState||(a.flags|=1024),a.memoizedProps=u,a.memoizedState=ae),y.props=u,y.state=ae,y.context=V,u=me):(typeof y.componentDidUpdate!="function"||O===r.memoizedProps&&oe===r.memoizedState||(a.flags|=4),typeof y.getSnapshotBeforeUpdate!="function"||O===r.memoizedProps&&oe===r.memoizedState||(a.flags|=1024),u=!1)}return y=u,sd(r,a),u=(a.flags&128)!==0,y||u?(y=a.stateNode,s=u&&typeof s.getDerivedStateFromError!="function"?null:y.render(),a.flags|=1,r!==null&&u?(a.child=Cs(a,r.child,null,h),a.child=Cs(a,null,s,h)):ar(r,a,s,h),a.memoizedState=y.state,r=a.child):r=da(r,a,h),r}function I0(r,a,s,u){return Pl(),a.flags|=256,ar(r,a,s,u),a.child}var Fh={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function Hh(r){return{baseLanes:r,cachePool:Tb()}}function qh(r,a,s){return r=r!==null?r.childLanes&~s:0,a&&(r|=io),r}function L0(r,a,s){var u=a.pendingProps,h=!1,y=(a.flags&128)!==0,O;if((O=y)||(O=r!==null&&r.memoizedState===null?!1:(Gn.current&2)!==0),O&&(h=!0,a.flags&=-129),O=(a.flags&32)!==0,a.flags&=-33,r===null){if(Nt){if(h?Pa(a):ja(),Nt){var D=Sn,V;if(V=D){e:{for(V=D,D=$o;V.nodeType!==8;){if(!D){D=null;break e}if(V=Ao(V.nextSibling),V===null){D=null;break e}}D=V}D!==null?(a.memoizedState={dehydrated:D,treeContext:yi!==null?{id:aa,overflow:ia}:null,retryLane:536870912,hydrationErrors:null},V=Lr(18,null,null,0),V.stateNode=D,V.return=a,a.child=V,Sr=a,Sn=null,V=!0):V=!1}V||xi(a)}if(D=a.memoizedState,D!==null&&(D=D.dehydrated,D!==null))return Am(D)?a.lanes=32:a.lanes=536870912,null;ua(a)}return D=u.children,u=u.fallback,h?(ja(),h=a.mode,D=ld({mode:"hidden",children:D},h),u=gi(u,h,s,null),D.return=a,u.return=a,D.sibling=u,a.child=D,h=a.child,h.memoizedState=Hh(s),h.childLanes=qh(r,O,s),a.memoizedState=Fh,u):(Pa(a),Vh(a,D))}if(V=r.memoizedState,V!==null&&(D=V.dehydrated,D!==null)){if(y)a.flags&256?(Pa(a),a.flags&=-257,a=Gh(r,a,s)):a.memoizedState!==null?(ja(),a.child=r.child,a.flags|=128,a=null):(ja(),h=u.fallback,D=a.mode,u=ld({mode:"visible",children:u.children},D),h=gi(h,D,s,null),h.flags|=2,u.return=a,h.return=a,u.sibling=h,a.child=u,Cs(a,r.child,null,s),u=a.child,u.memoizedState=Hh(s),u.childLanes=qh(r,O,s),a.memoizedState=Fh,a=h);else if(Pa(a),Am(D)){if(O=D.nextSibling&&D.nextSibling.dataset,O)var re=O.dgst;O=re,u=Error(o(419)),u.stack="",u.digest=O,jl({value:u,source:null,stack:null}),a=Gh(r,a,s)}else if(Jn||_l(r,a,s,!1),O=(s&r.childLanes)!==0,Jn||O){if(O=rn,O!==null&&(u=s&-s,u=(u&42)!==0?1:wt(u),u=(u&(O.suspendedLanes|s))!==0?0:u,u!==0&&u!==V.retryLane))throw V.retryLane=u,ds(r,u),qr(O,r,u),M0;D.data==="$?"||um(),a=Gh(r,a,s)}else D.data==="$?"?(a.flags|=192,a.child=r.child,a=null):(r=V.treeContext,Sn=Ao(D.nextSibling),Sr=a,Nt=!0,bi=null,$o=!1,r!==null&&(ro[oo++]=aa,ro[oo++]=ia,ro[oo++]=yi,aa=r.id,ia=r.overflow,yi=a),a=Vh(a,u.children),a.flags|=4096);return a}return h?(ja(),h=u.fallback,D=a.mode,V=r.child,re=V.sibling,u=oa(V,{mode:"hidden",children:u.children}),u.subtreeFlags=V.subtreeFlags&65011712,re!==null?h=oa(re,h):(h=gi(h,D,s,null),h.flags|=2),h.return=a,u.return=a,u.sibling=h,a.child=u,u=h,h=a.child,D=r.child.memoizedState,D===null?D=Hh(s):(V=D.cachePool,V!==null?(re=Vn._currentValue,V=V.parent!==re?{parent:re,pool:re}:V):V=Tb(),D={baseLanes:D.baseLanes|s,cachePool:V}),h.memoizedState=D,h.childLanes=qh(r,O,s),a.memoizedState=Fh,u):(Pa(a),s=r.child,r=s.sibling,s=oa(s,{mode:"visible",children:u.children}),s.return=a,s.sibling=null,r!==null&&(O=a.deletions,O===null?(a.deletions=[r],a.flags|=16):O.push(r)),a.child=s,a.memoizedState=null,s)}function Vh(r,a){return a=ld({mode:"visible",children:a},r.mode),a.return=r,r.child=a}function ld(r,a){return r=Lr(22,r,null,a),r.lanes=0,r.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null},r}function Gh(r,a,s){return Cs(a,r.child,null,s),r=Vh(a,a.pendingProps.children),r.flags|=2,a.memoizedState=null,r}function B0(r,a,s){r.lanes|=a;var u=r.alternate;u!==null&&(u.lanes|=a),uh(r.return,a,s)}function Wh(r,a,s,u,h){var y=r.memoizedState;y===null?r.memoizedState={isBackwards:a,rendering:null,renderingStartTime:0,last:u,tail:s,tailMode:h}:(y.isBackwards=a,y.rendering=null,y.renderingStartTime=0,y.last=u,y.tail=s,y.tailMode=h)}function U0(r,a,s){var u=a.pendingProps,h=u.revealOrder,y=u.tail;if(ar(r,a,u.children,s),u=Gn.current,(u&2)!==0)u=u&1|2,a.flags|=128;else{if(r!==null&&(r.flags&128)!==0)e:for(r=a.child;r!==null;){if(r.tag===13)r.memoizedState!==null&&B0(r,s,a);else if(r.tag===19)B0(r,s,a);else if(r.child!==null){r.child.return=r,r=r.child;continue}if(r===a)break e;for(;r.sibling===null;){if(r.return===null||r.return===a)break e;r=r.return}r.sibling.return=r.return,r=r.sibling}u&=1}switch(Z(Gn,u),h){case"forwards":for(s=a.child,h=null;s!==null;)r=s.alternate,r!==null&&od(r)===null&&(h=s),s=s.sibling;s=h,s===null?(h=a.child,a.child=null):(h=s.sibling,s.sibling=null),Wh(a,!1,h,s,y);break;case"backwards":for(s=null,h=a.child,a.child=null;h!==null;){if(r=h.alternate,r!==null&&od(r)===null){a.child=h;break}r=h.sibling,h.sibling=s,s=h,h=r}Wh(a,!0,s,null,y);break;case"together":Wh(a,!1,null,null,void 0);break;default:a.memoizedState=null}return a.child}function da(r,a,s){if(r!==null&&(a.dependencies=r.dependencies),Na|=a.lanes,(s&a.childLanes)===0)if(r!==null){if(_l(r,a,s,!1),(s&a.childLanes)===0)return null}else return null;if(r!==null&&a.child!==r.child)throw Error(o(153));if(a.child!==null){for(r=a.child,s=oa(r,r.pendingProps),a.child=s,s.return=a;r.sibling!==null;)r=r.sibling,s=s.sibling=oa(r,r.pendingProps),s.return=a;s.sibling=null}return a.child}function Zh(r,a){return(r.lanes&a)!==0?!0:(r=r.dependencies,!!(r!==null&&Fu(r)))}function qE(r,a,s){switch(a.tag){case 3:Ce(a,a.stateNode.containerInfo),Ea(a,Vn,r.memoizedState.cache),Pl();break;case 27:case 5:ge(a);break;case 4:Ce(a,a.stateNode.containerInfo);break;case 10:Ea(a,a.type,a.memoizedProps.value);break;case 13:var u=a.memoizedState;if(u!==null)return u.dehydrated!==null?(Pa(a),a.flags|=128,null):(s&a.child.childLanes)!==0?L0(r,a,s):(Pa(a),r=da(r,a,s),r!==null?r.sibling:null);Pa(a);break;case 19:var h=(r.flags&128)!==0;if(u=(s&a.childLanes)!==0,u||(_l(r,a,s,!1),u=(s&a.childLanes)!==0),h){if(u)return U0(r,a,s);a.flags|=128}if(h=a.memoizedState,h!==null&&(h.rendering=null,h.tail=null,h.lastEffect=null),Z(Gn,Gn.current),u)break;return null;case 22:case 23:return a.lanes=0,z0(r,a,s);case 24:Ea(a,Vn,r.memoizedState.cache)}return da(r,a,s)}function F0(r,a,s){if(r!==null)if(r.memoizedProps!==a.pendingProps)Jn=!0;else{if(!Zh(r,s)&&(a.flags&128)===0)return Jn=!1,qE(r,a,s);Jn=(r.flags&131072)!==0}else Jn=!1,Nt&&(a.flags&1048576)!==0&&vb(a,Uu,a.index);switch(a.lanes=0,a.tag){case 16:e:{r=a.pendingProps;var u=a.elementType,h=u._init;if(u=h(u._payload),a.type=u,typeof u=="function")rh(u)?(r=Ti(u,r),a.tag=1,a=N0(null,a,u,r,s)):(a.tag=0,a=Uh(null,a,u,r,s));else{if(u!=null){if(h=u.$$typeof,h===A){a.tag=11,a=P0(null,a,u,r,s);break e}else if(h===$){a.tag=14,a=j0(null,a,u,r,s);break e}}throw a=L(u)||u,Error(o(306,a,""))}}return a;case 0:return Uh(r,a,a.type,a.pendingProps,s);case 1:return u=a.type,h=Ti(u,a.pendingProps),N0(r,a,u,h,s);case 3:e:{if(Ce(a,a.stateNode.containerInfo),r===null)throw Error(o(387));u=a.pendingProps;var y=a.memoizedState;h=y.element,yh(r,a),Bl(a,u,null,s);var O=a.memoizedState;if(u=O.cache,Ea(a,Vn,u),u!==y.cache&&dh(a,[Vn],s,!0),Ll(),u=O.element,y.isDehydrated)if(y={element:u,isDehydrated:!1,cache:O.cache},a.updateQueue.baseState=y,a.memoizedState=y,a.flags&256){a=I0(r,a,u,s);break e}else if(u!==h){h=to(Error(o(424)),a),jl(h),a=I0(r,a,u,s);break e}else{switch(r=a.stateNode.containerInfo,r.nodeType){case 9:r=r.body;break;default:r=r.nodeName==="HTML"?r.ownerDocument.body:r}for(Sn=Ao(r.firstChild),Sr=a,Nt=!0,bi=null,$o=!0,s=x0(a,null,u,s),a.child=s;s;)s.flags=s.flags&-3|4096,s=s.sibling}else{if(Pl(),u===h){a=da(r,a,s);break e}ar(r,a,u,s)}a=a.child}return a;case 26:return sd(r,a),r===null?(s=Gx(a.type,null,a.pendingProps,null))?a.memoizedState=s:Nt||(s=a.type,r=a.pendingProps,u=Cd(Y.current).createElement(s),u[et]=a,u[Ye]=r,sr(u,s,r),gn(u),a.stateNode=u):a.memoizedState=Gx(a.type,r.memoizedProps,a.pendingProps,r.memoizedState),null;case 27:return ge(a),r===null&&Nt&&(u=a.stateNode=Hx(a.type,a.pendingProps,Y.current),Sr=a,$o=!0,h=Sn,Ua(a.type)?(km=h,Sn=Ao(u.firstChild)):Sn=h),ar(r,a,a.pendingProps.children,s),sd(r,a),r===null&&(a.flags|=4194304),a.child;case 5:return r===null&&Nt&&((h=u=Sn)&&(u=vA(u,a.type,a.pendingProps,$o),u!==null?(a.stateNode=u,Sr=a,Sn=Ao(u.firstChild),$o=!1,h=!0):h=!1),h||xi(a)),ge(a),h=a.type,y=a.pendingProps,O=r!==null?r.memoizedProps:null,u=y.children,Rm(h,y)?u=null:O!==null&&Rm(h,O)&&(a.flags|=32),a.memoizedState!==null&&(h=wh(r,a,$E,null,null,s),lc._currentValue=h),sd(r,a),ar(r,a,u,s),a.child;case 6:return r===null&&Nt&&((r=s=Sn)&&(s=bA(s,a.pendingProps,$o),s!==null?(a.stateNode=s,Sr=a,Sn=null,r=!0):r=!1),r||xi(a)),null;case 13:return L0(r,a,s);case 4:return Ce(a,a.stateNode.containerInfo),u=a.pendingProps,r===null?a.child=Cs(a,null,u,s):ar(r,a,u,s),a.child;case 11:return P0(r,a,a.type,a.pendingProps,s);case 7:return ar(r,a,a.pendingProps,s),a.child;case 8:return ar(r,a,a.pendingProps.children,s),a.child;case 12:return ar(r,a,a.pendingProps.children,s),a.child;case 10:return u=a.pendingProps,Ea(a,a.type,u.value),ar(r,a,u.children,s),a.child;case 9:return h=a.type._context,u=a.pendingProps.children,Ci(a),h=dr(h),u=u(h),a.flags|=1,ar(r,a,u,s),a.child;case 14:return j0(r,a,a.type,a.pendingProps,s);case 15:return _0(r,a,a.type,a.pendingProps,s);case 19:return U0(r,a,s);case 31:return u=a.pendingProps,s=a.mode,u={mode:u.mode,children:u.children},r===null?(s=ld(u,s),s.ref=a.ref,a.child=s,s.return=a,a=s):(s=oa(r.child,u),s.ref=a.ref,a.child=s,s.return=a,a=s),a;case 22:return z0(r,a,s);case 24:return Ci(a),u=dr(Vn),r===null?(h=hh(),h===null&&(h=rn,y=fh(),h.pooledCache=y,y.refCount++,y!==null&&(h.pooledCacheLanes|=s),h=y),a.memoizedState={parent:u,cache:h},gh(a),Ea(a,Vn,h)):((r.lanes&s)!==0&&(yh(r,a),Bl(a,null,null,s),Ll()),h=r.memoizedState,y=a.memoizedState,h.parent!==u?(h={parent:u,cache:u},a.memoizedState=h,a.lanes===0&&(a.memoizedState=a.updateQueue.baseState=h),Ea(a,Vn,u)):(u=y.cache,Ea(a,Vn,u),u!==h.cache&&dh(a,[Vn],s,!0))),ar(r,a,a.pendingProps.children,s),a.child;case 29:throw a.pendingProps}throw Error(o(156,a.tag))}function fa(r){r.flags|=4}function H0(r,a){if(a.type!=="stylesheet"||(a.state.loading&4)!==0)r.flags&=-16777217;else if(r.flags|=16777216,!Xx(a)){if(a=ao.current,a!==null&&((_t&4194048)===_t?No!==null:(_t&62914560)!==_t&&(_t&536870912)===0||a!==No))throw Nl=mh,Eb;r.flags|=8192}}function cd(r,a){a!==null&&(r.flags|=4),r.flags&16384&&(a=r.tag!==22?Qn():536870912,r.lanes|=a,Es|=a)}function Wl(r,a){if(!Nt)switch(r.tailMode){case"hidden":a=r.tail;for(var s=null;a!==null;)a.alternate!==null&&(s=a),a=a.sibling;s===null?r.tail=null:s.sibling=null;break;case"collapsed":s=r.tail;for(var u=null;s!==null;)s.alternate!==null&&(u=s),s=s.sibling;u===null?a||r.tail===null?r.tail=null:r.tail.sibling=null:u.sibling=null}}function yn(r){var a=r.alternate!==null&&r.alternate.child===r.child,s=0,u=0;if(a)for(var h=r.child;h!==null;)s|=h.lanes|h.childLanes,u|=h.subtreeFlags&65011712,u|=h.flags&65011712,h.return=r,h=h.sibling;else for(h=r.child;h!==null;)s|=h.lanes|h.childLanes,u|=h.subtreeFlags,u|=h.flags,h.return=r,h=h.sibling;return r.subtreeFlags|=u,r.childLanes=s,a}function VE(r,a,s){var u=a.pendingProps;switch(sh(a),a.tag){case 31:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return yn(a),null;case 1:return yn(a),null;case 3:return s=a.stateNode,u=null,r!==null&&(u=r.memoizedState.cache),a.memoizedState.cache!==u&&(a.flags|=2048),la(Vn),ke(),s.pendingContext&&(s.context=s.pendingContext,s.pendingContext=null),(r===null||r.child===null)&&(Ml(a)?fa(a):r===null||r.memoizedState.isDehydrated&&(a.flags&256)===0||(a.flags|=1024,Sb())),yn(a),null;case 26:return s=a.memoizedState,r===null?(fa(a),s!==null?(yn(a),H0(a,s)):(yn(a),a.flags&=-16777217)):s?s!==r.memoizedState?(fa(a),yn(a),H0(a,s)):(yn(a),a.flags&=-16777217):(r.memoizedProps!==u&&fa(a),yn(a),a.flags&=-16777217),null;case 27:Ne(a),s=Y.current;var h=a.type;if(r!==null&&a.stateNode!=null)r.memoizedProps!==u&&fa(a);else{if(!u){if(a.stateNode===null)throw Error(o(166));return yn(a),null}r=te.current,Ml(a)?bb(a):(r=Hx(h,u,s),a.stateNode=r,fa(a))}return yn(a),null;case 5:if(Ne(a),s=a.type,r!==null&&a.stateNode!=null)r.memoizedProps!==u&&fa(a);else{if(!u){if(a.stateNode===null)throw Error(o(166));return yn(a),null}if(r=te.current,Ml(a))bb(a);else{switch(h=Cd(Y.current),r){case 1:r=h.createElementNS("http://www.w3.org/2000/svg",s);break;case 2:r=h.createElementNS("http://www.w3.org/1998/Math/MathML",s);break;default:switch(s){case"svg":r=h.createElementNS("http://www.w3.org/2000/svg",s);break;case"math":r=h.createElementNS("http://www.w3.org/1998/Math/MathML",s);break;case"script":r=h.createElement("div"),r.innerHTML=" +
diff --git a/web/src/components/general/AdvancedSettings.tsx b/web/src/components/general/AdvancedSettings.tsx index 58e545f2..cb35cd76 100644 --- a/web/src/components/general/AdvancedSettings.tsx +++ b/web/src/components/general/AdvancedSettings.tsx @@ -17,6 +17,15 @@ export default function AdvancedSettings() { const [topStats, setTopStats] = useState(a.topStatsFocus) const [externalUrl, setExternalUrl] = useState(a.externalProcessorUrl) + // Career Loop state + const [careerLoopEnabled, setCareerLoopEnabled] = useState(a.careerLoop?.enabled ?? true) + const [maxCareers, setMaxCareers] = useState(a.careerLoop?.maxCareers ?? 5) + const [preferredSupport, setPreferredSupport] = useState(a.careerLoop?.preferredSupport ?? 'Riko Kashimoto') + const [preferredLevel, setPreferredLevel] = useState(a.careerLoop?.preferredLevel ?? 50) + const [maxRefresh, setMaxRefresh] = useState(a.careerLoop?.maxRefresh ?? 5) + const [refreshWait, setRefreshWait] = useState(a.careerLoop?.refreshWait ?? 5.0) + const [errorThreshold, setErrorThreshold] = useState(a.careerLoop?.errorThreshold ?? 5) + const autoRestMarks = useMemo(() => { const marks = [{ value: 1 }] for (let v = 5; v <= 70; v += 5) { @@ -34,6 +43,13 @@ export default function AdvancedSettings() { setUndertrain(a.undertrainThreshold) setTopStats(a.topStatsFocus) setExternalUrl(a.externalProcessorUrl) + setCareerLoopEnabled(a.careerLoop?.enabled ?? true) + setMaxCareers(a.careerLoop?.maxCareers ?? 5) + setPreferredSupport(a.careerLoop?.preferredSupport ?? 'Riko Kashimoto') + setPreferredLevel(a.careerLoop?.preferredLevel ?? 50) + setMaxRefresh(a.careerLoop?.maxRefresh ?? 5) + setRefreshWait(a.careerLoop?.refreshWait ?? 5.0) + setErrorThreshold(a.careerLoop?.errorThreshold ?? 5) }, [ a.autoRestMinimum, a.skillCheckInterval, @@ -41,6 +57,7 @@ export default function AdvancedSettings() { a.undertrainThreshold, a.topStatsFocus, a.externalProcessorUrl, + a.careerLoop, ]) const commitAdvanced = (key: K, value: (typeof a)[K]) => { @@ -380,6 +397,258 @@ export default function AdvancedSettings() { })} sx={{ mt: 2 }} /> + + + Career Loop Automation + + Automated career farming for unattended gameplay. Press F1 to start/stop career loop mode. + + + { + const enabled = e.target.checked + setCareerLoopEnabled(enabled) + setGeneral({ + advanced: { + ...a, + careerLoop: { + ...(a.careerLoop || {}), + enabled, + maxCareers: maxCareers || null, + preferredSupport, + preferredLevel, + maxRefresh, + refreshWait, + errorThreshold, + }, + }, + }) + }} + /> + } + label={careerLoopEnabled ? 'Enabled' : 'Disabled'} + /> + } + info="Enable automated career loop for farming multiple careers." + /> + + { + const val = e.target.value === '' ? 0 : parseInt(e.target.value, 10) + setMaxCareers(val) + }} + onBlur={() => { + setGeneral({ + advanced: { + ...a, + careerLoop: { + ...(a.careerLoop || {}), + enabled: careerLoopEnabled, + maxCareers: maxCareers || 0, + preferredSupport, + preferredLevel, + maxRefresh, + refreshWait, + errorThreshold, + }, + }, + }) + }} + inputProps={{ min: 0, max: 999 }} + placeholder="0 = infinite" + sx={{ width: 120 }} + /> + } + info="Number of careers to complete (0 or empty = infinite loop)." + /> + + setPreferredSupport(e.target.value)} + onBlur={() => { + setGeneral({ + advanced: { + ...a, + careerLoop: { + ...(a.careerLoop || {}), + enabled: careerLoopEnabled, + maxCareers: maxCareers || null, + preferredSupport, + preferredLevel, + maxRefresh, + refreshWait, + errorThreshold, + }, + }, + }) + }} + placeholder="Riko Kashimoto" + /> + } + info="Name of the preferred support card to select." + /> + + { + const next = Math.max(1, Math.min(100, Math.round(toNumber(v)))) + setPreferredLevel(next) + }, + onCommit: (_, v) => { + const next = Math.max(1, Math.min(100, Math.round(toNumber(v)))) + setPreferredLevel(next) + setGeneral({ + advanced: { + ...a, + careerLoop: { + ...(a.careerLoop || {}), + enabled: careerLoopEnabled, + maxCareers: maxCareers || null, + preferredSupport, + preferredLevel: next, + maxRefresh, + refreshWait, + errorThreshold, + }, + }, + }) + }, + })} + info="Preferred support card level (1-100)." + sx={{ mt: 2 }} + /> + + { + const next = Math.max(0, Math.min(20, Math.round(toNumber(v)))) + setMaxRefresh(next) + }, + onCommit: (_, v) => { + const next = Math.max(0, Math.min(20, Math.round(toNumber(v)))) + setMaxRefresh(next) + setGeneral({ + advanced: { + ...a, + careerLoop: { + ...(a.careerLoop || {}), + enabled: careerLoopEnabled, + maxCareers: maxCareers || null, + preferredSupport, + preferredLevel, + maxRefresh: next, + refreshWait, + errorThreshold, + }, + }, + }) + }, + })} + info="Maximum times to refresh the support list (0-20)." + sx={{ mt: 2 }} + /> + + { + const val = parseFloat(e.target.value) + if (!isNaN(val)) setRefreshWait(val) + }} + onBlur={() => { + const clamped = Math.max(1.0, Math.min(30.0, refreshWait)) + setRefreshWait(clamped) + setGeneral({ + advanced: { + ...a, + careerLoop: { + ...(a.careerLoop || {}), + enabled: careerLoopEnabled, + maxCareers: maxCareers || null, + preferredSupport, + preferredLevel, + maxRefresh, + refreshWait: clamped, + errorThreshold, + }, + }, + }) + }} + inputProps={{ min: 1.0, max: 30.0, step: 0.5 }} + sx={{ width: 120 }} + /> + } + info="Wait time between support list refreshes (1.0-30.0 seconds)." + /> + + { + const next = Math.max(1, Math.min(20, Math.round(toNumber(v)))) + setErrorThreshold(next) + }, + onCommit: (_, v) => { + const next = Math.max(1, Math.min(20, Math.round(toNumber(v)))) + setErrorThreshold(next) + setGeneral({ + advanced: { + ...a, + careerLoop: { + ...(a.careerLoop || {}), + enabled: careerLoopEnabled, + maxCareers: maxCareers || null, + preferredSupport, + preferredLevel, + maxRefresh, + refreshWait, + errorThreshold: next, + }, + }, + }) + }, + })} + info="Max consecutive errors before stopping the loop (1-20)." + sx={{ mt: 2 }} + /> ) diff --git a/web/src/models/config.schema.ts b/web/src/models/config.schema.ts index 1e4d6444..f2cb6a98 100644 --- a/web/src/models/config.schema.ts +++ b/web/src/models/config.schema.ts @@ -133,6 +133,15 @@ export const generalSchema = z.object({ topStatsFocus: z.number().int().min(1).max(5).default(3), skillCheckInterval: z.number().int().min(1).max(12).default(3), skillPtsDelta: z.number().int().min(0).max(1000).default(60), + careerLoop: z.object({ + enabled: z.boolean().default(true), + maxCareers: z.number().nullable().default(5), + preferredSupport: z.string().default('Riko Kashimoto'), + preferredLevel: z.number().int().min(1).max(100).default(50), + maxRefresh: z.number().int().min(0).max(20).default(5), + refreshWait: z.number().min(1).max(30).default(5), + errorThreshold: z.number().int().min(1).max(20).default(5), + }).optional(), }).default({ hotkey: 'F2', debugMode: true, @@ -195,8 +204,8 @@ export const presetSchema = z.object({ unityCupAdvanced: unityCupAdvancedSchema.optional().default(() => defaultUnityCupAdvanced()), // Make optional on input, but always present on output via default() event_setup: (() => { - const rarity = z.enum(['SSR','SR','R']) - const attr = z.enum(['SPD','STA','PWR','GUTS','WIT','PAL']) + const rarity = z.enum(['SSR', 'SR', 'R']) + const attr = z.enum(['SPD', 'STA', 'PWR', 'GUTS', 'WIT', 'PAL']) const supportPriority = z.object({ enabled: z.boolean().default(true), scoreBlueGreen: z.number().min(0).max(10).default(0.75), @@ -225,7 +234,7 @@ export const presetSchema = z.object({ avoidEnergyOverflow: z.boolean().default(true).optional(), rewardPriority: z.array(z.enum(['skill_pts', 'stats', 'hints'])).default(['skill_pts', 'stats', 'hints']).optional(), }).nullable() - const selectedTrainee = z.object({ + const selectedTrainee = z.object({ name: z.string(), avoidEnergyOverflow: z.boolean().default(true).optional(), rewardPriority: z.array(z.enum(['skill_pts', 'stats', 'hints'])).default(['skill_pts', 'stats', 'hints']).optional(), @@ -249,8 +258,8 @@ export const presetSchema = z.object({ const eventSetup = z.object({ supports: z.array(selectedSupport.nullable()).length(6).default([null, null, null, null, null, null]), scenario: selectedScenario.default(null), - trainee: selectedTrainee.default(null), - prefs: eventPrefs.default({ + trainee: selectedTrainee.default(null), + prefs: eventPrefs.default({ overrides: {}, patterns: [], defaults: { support: 1, trainee: 1, scenario: 1 }, @@ -262,7 +271,7 @@ export const presetSchema = z.object({ })().default({ supports: [null, null, null, null, null, null], scenario: null, - trainee: null, + trainee: null, prefs: { overrides: {}, patterns: [], diff --git a/web/src/models/types.ts b/web/src/models/types.ts index 7018097a..34a0440f 100644 --- a/web/src/models/types.ts +++ b/web/src/models/types.ts @@ -59,6 +59,16 @@ export interface GeneralConfig { // Skills optimization (Raceday auto-buy gating) skillCheckInterval: number // Check skills every N turns (1 = every turn) skillPtsDelta: number // Only check if points increased by at least this amount + // Career Loop automation settings + careerLoop?: { + enabled: boolean + maxCareers: number | null // null or 0 = infinite + preferredSupport: string + preferredLevel: number // 1-100 + maxRefresh: number // 0-20 + refreshWait: number // 1.0-30.0 seconds + errorThreshold: number // 1-20 + } } }