diff --git a/.gitignore b/.gitignore index 5dc484a6e..052507c22 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ recordings .claude/ .claude/settings.local.json .DS_Store -*.egg-info \ No newline at end of file +*.egg-info +.vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json index d8cd7433a..f531dde22 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,8 @@ "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", "python.terminal.activateEnvironment": true, "python-envs.pythonProjects": [], - "python-envs.defaultEnvManager": "ms-python.python:system" -} \ No newline at end of file + "python-envs.defaultEnvManager": "ms-python.python:system", + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true, + "files.trimFinalNewlines": true +} diff --git a/pyclashbot/__main__.py b/pyclashbot/__main__.py index 18194e31c..188daab90 100644 --- a/pyclashbot/__main__.py +++ b/pyclashbot/__main__.py @@ -72,6 +72,11 @@ def as_int(field: UIField, default: int) -> int: UIField.RANDOM_PLAYS_USER_TOGGLE.value: as_bool(UIField.RANDOM_PLAYS_USER_TOGGLE), UIField.DISABLE_WIN_TRACK_TOGGLE.value: as_bool(UIField.DISABLE_WIN_TRACK_TOGGLE), UIField.RECORD_FIGHTS_TOGGLE.value: as_bool(UIField.RECORD_FIGHTS_TOGGLE), + UIField.SCHEDULER_ENABLED.value: as_bool(UIField.SCHEDULER_ENABLED), + UIField.SCHEDULER_START_HOUR.value: as_int(UIField.SCHEDULER_START_HOUR, 8), + UIField.SCHEDULER_START_MINUTE.value: as_int(UIField.SCHEDULER_START_MINUTE, 0), + UIField.SCHEDULER_END_HOUR.value: as_int(UIField.SCHEDULER_END_HOUR, 22), + UIField.SCHEDULER_END_MINUTE.value: as_int(UIField.SCHEDULER_END_MINUTE, 0), } job_dictionary["upgrade_user_toggle"] = as_bool(UIField.CARD_UPGRADE_USER_TOGGLE) @@ -93,6 +98,10 @@ def as_int(field: UIField, default: int) -> int: # Default: Vulkan on macOS, OpenGL on Windows job_dictionary["bluestacks_render_mode"] = "vlcn" if is_macos() else "gl" + # BlueStacks instance selection + if UIField.BLUESTACKS_INSTANCE.value in values: + job_dictionary["bluestacks_instance"] = values.get(UIField.BLUESTACKS_INSTANCE.value) + # Emulator selection if values.get(UIField.GOOGLE_PLAY_EMULATOR_TOGGLE.value): job_dictionary["emulator"] = EmulatorType.GOOGLE_PLAY @@ -242,6 +251,8 @@ def __init__(self, settings: dict[str, Any] | None = None) -> None: self.ui.adb_restart_btn.configure(command=self._on_adb_restart) self.ui.adb_set_size_btn.configure(command=self._on_adb_set_size) self.ui.adb_reset_size_btn.configure(command=self._on_adb_reset_size) + self.ui.bs_refresh_btn.configure(command=self._on_bluestacks_refresh) + self.ui.register_bluestacks_refresh_callback(self._get_bluestacks_instances) # Multiprocessing primitives self.process: WorkerProcess | None = None @@ -470,6 +481,35 @@ def run(self, start_on_run: bool = False) -> None: self.ui.after(200, self._on_start) self.ui.mainloop() + def _get_bluestacks_instances(self) -> list[str]: + """Get list of available BlueStacks instances (no side effects).""" + try: + from pyclashbot.emulators.bluestacks import list_bluestacks_instances + + instances = list_bluestacks_instances() + return instances if instances else ["pyclashbot-96"] + except Exception as e: + self.logger.change_status(f"Error getting BlueStacks instances: {e}") + return ["pyclashbot-96"] + + def _on_bluestacks_refresh(self) -> None: + """Handle BlueStacks refresh button click.""" + self.logger.change_status("Refreshing BlueStacks instances list...") + try: + instances = self._get_bluestacks_instances() + self.ui.bs_instance_combo.configure(values=instances) + if instances: + current_selection = self.ui.bs_instance_var.get() + if current_selection not in instances: + self.ui.bs_instance_var.set(instances[0]) + self.logger.change_status(f"Found instances: {', '.join(instances)}") + else: + self.ui.bs_instance_var.set("pyclashbot-96") + self.logger.change_status("No BlueStacks instances found.") + except Exception as e: + self.logger.change_status(f"Error refreshing BlueStacks instances: {e}") + + def main_gui(start_on_run: bool = False, settings: dict[str, Any] | None = None) -> None: app = BotApplication(settings) diff --git a/pyclashbot/bot/worker.py b/pyclashbot/bot/worker.py index 078aad821..694d37226 100644 --- a/pyclashbot/bot/worker.py +++ b/pyclashbot/bot/worker.py @@ -49,8 +49,9 @@ def _setup_emulator(self, jobs: dict[str, Any], logger: ProcessLogger): # Default: Vulkan on macOS, OpenGL on Windows default_mode = "vlcn" if is_macos() else "gl" bs_mode = jobs.get("bluestacks_render_mode", default_mode) + bs_instance = jobs.get("bluestacks_instance", "pyclashbot-96") render_settings = {"graphics_renderer": bs_mode} - return controller_class(logger=logger, render_settings=render_settings) + return controller_class(logger=logger, render_settings=render_settings, instance_name=bs_instance) elif emulator_selection == EmulatorType.MEMU: print("Creating MEmu emulator") @@ -74,6 +75,61 @@ def _setup_emulator(self, jobs: dict[str, Any], logger: ProcessLogger): return None + def _check_scheduler(self, jobs: dict[str, Any], logger: ProcessLogger) -> bool: + """Check if bot should stop based on scheduler settings. + + Returns: + True if bot should continue, False if it should stop + """ + import datetime + from pyclashbot.utils.scheduler import ScheduleConfig + + def _parse_bool(value: object) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return value != 0 + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "y", "on"} + return bool(value) + + scheduler_enabled = _parse_bool(jobs.get("scheduler_enabled", False)) + if not scheduler_enabled: + return True + + config = ScheduleConfig( + enabled=True, + start_hour=int(jobs.get("scheduler_start_hour", 8)), + start_minute=int(jobs.get("scheduler_start_minute", 0)), + end_hour=int(jobs.get("scheduler_end_hour", 22)), + end_minute=int(jobs.get("scheduler_end_minute", 0)), + ) + + now = datetime.datetime.now().time() + start_time = config.start_time + end_time = config.end_time + + if start_time <= end_time: + is_within = start_time <= now <= end_time + else: + # Do not allow overnight windows when start time is later than end time + is_within = False + + logger.log( + "Scheduler check: " + f"now={now.strftime('%H:%M')} window={start_time.strftime('%H:%M')}-{end_time.strftime('%H:%M')} " + f"within={is_within}" + ) + + if not is_within: + logger.change_status("Outside scheduled hours - stopping bot") + logger.log( + f"Schedule window {config.start_hour:02d}:{config.start_minute:02d} - {config.end_hour:02d}:{config.end_minute:02d}, current time outside" + ) + return False + + return True + def _run_bot_loop(self, emulator, jobs: dict[str, Any], logger: ProcessLogger) -> None: """Run the main bot state loop.""" state = "start" @@ -86,6 +142,11 @@ def _run_bot_loop(self, emulator, jobs: dict[str, Any], logger: ProcessLogger) - try: new_state = state_tree(emulator, logger, state, jobs, state_history, state_order) + # Check scheduler after completing a battle cycle (after end_fight state executes) + if state == "end_fight": + if not self._check_scheduler(jobs, logger): + break + # Check for restart loops if new_state == "restart": consecutive_restarts += 1 diff --git a/pyclashbot/emulators/bluestacks.py b/pyclashbot/emulators/bluestacks.py index eb324acd6..3d2cad4b0 100644 --- a/pyclashbot/emulators/bluestacks.py +++ b/pyclashbot/emulators/bluestacks.py @@ -16,6 +16,46 @@ DEBUG = False +def list_bluestacks_instances() -> list[str]: + """Return display names of available BlueStacks instances without side effects. + + Reads registry and bluestacks.conf directly to avoid launching/stopping instances. + """ + try: + if is_macos(): + base = "/Users/Shared/Library/Application Support/BlueStacks" + bs_conf = os.path.join(base, "bluestacks.conf") + else: + import winreg + + try: + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\BlueStacks_nxt") as key: + data_dir, _ = winreg.QueryValueEx(key, "DataDir") + except (OSError, FileNotFoundError): + # BlueStacks not installed or registry key not accessible + return [] + dd = normpath(str(data_dir)) + bs_conf = os.path.join(os.path.dirname(dd), "bluestacks.conf") + + if not os.path.isfile(bs_conf): + return [] + with open(bs_conf, "r", encoding="utf-8", errors="ignore") as f: + text = f.read() + names: list[str] = [] + for m in re.finditer(r'^bst\.instance\.([^.=]+)\.display_name="([^"]+)"\s*$', text, flags=re.M): + names.append(m.group(2)) + # Deduplicate preserving order + seen = set() + ordered = [] + for n in names: + if n not in seen: + seen.add(n) + ordered.append(n) + return ordered + except Exception: + return [] + + class BlueStacksEmulatorController(AdbBasedController): """BlueStacks 5 controller using HD-Adb. @@ -26,11 +66,11 @@ class BlueStacksEmulatorController(AdbBasedController): supported_platforms = [Platform.WINDOWS, Platform.MACOS] - def __init__(self, logger, render_settings: dict | None = None): + def __init__(self, logger, render_settings: dict | None = None, instance_name: str | None = None): self.logger = logger self.expected_dims = (419, 633) # Bypassing bs5's stupid dim limits - self.instance_name = "pyclashbot-96" + self.instance_name = instance_name or "pyclashbot-96" self.internal_name: str | None = None self.instance_port: int | None = None self.device_serial: str | None = None # "127.0.0.1:" @@ -345,6 +385,35 @@ def _pick_unlinked_instance(self) -> str | None: return internal return None + def get_available_instances(self) -> list[str]: + """Get list of all available BlueStacks instances with their display names. + + Returns a list of display names of all BlueStacks instances. + If no instances are found, returns an empty list. + """ + try: + conf = self._read_text(self.bs_conf_path) + if not conf: + return [] + + instance_list = self._list_instance_internals(conf) + if not instance_list: + return [] + + # Get display names for each internal instance + display_names = [] + for internal in instance_list: + display_name = self._get_conf_value(conf, f"bst.instance.{internal}.display_name") + if display_name: + display_names.append(display_name) + else: + # Fallback to internal name if no display name is set + display_names.append(internal) + + return display_names + except Exception: + return [] + def _ensure_managed_instance(self): # If display entry exists if self._display_name_exists(self.bs_conf_path, self.instance_name): @@ -534,6 +603,42 @@ def _refresh_instance_port(self): if DEBUG: print(f"[Bluestacks 5] Refreshed instance port -> {self.device_serial}") + def _ensure_target_instance(self) -> bool: + """Resolve internal name/port for the selected display name; fallback to first instance.""" + # Already resolved + if self.internal_name and self.instance_port and self.device_serial: + return True + + conf_text = self._read_text(self.bs_conf_path) or "" + internal: str | None = None + + # 1) Try matching display name from MimMetaData or conf + if self.instance_name: + internal = self._find_internal_by_display_name(self.mim_meta_path, self.instance_name) or \ + self._find_internal_in_conf_by_display(self.bs_conf_path, self.instance_name) + + # 2) Fallback: pick the first instance present + if not internal: + internals = self._list_instance_internals(conf_text) + if internals: + internal = internals[0] + fallback_display = self._get_conf_value(conf_text, f"bst.instance.{internal}.display_name") or internal + self.logger.log( + f"[BlueStacks 5] Requested instance '{self.instance_name}' not found. " + f"Using '{fallback_display}' instead." + ) + # Update outward-facing name so window-title checks align + self.instance_name = fallback_display + + if not internal: + self.logger.change_status("No BlueStacks instance found in configuration.") + return False + + self.internal_name = internal + self.instance_port = self._read_instance_adb_port(self.bs_conf_path, internal) + self.device_serial = f"127.0.0.1:{self.instance_port}" if self.instance_port else None + return self.instance_port is not None + def _connect(self) -> bool: """Connect only to this instance's port.""" if not self.device_serial: @@ -570,7 +675,7 @@ def _is_this_instance_running(self) -> bool: return False # Windows: tasklist CSV parsing - title = self.instance_name + title = (self.instance_name or "").strip().lower() try: res = subprocess.run( 'tasklist /v /fi "IMAGENAME eq HD-Player.exe" /fo csv', @@ -583,13 +688,23 @@ def _is_this_instance_running(self) -> bool: return False reader = csv.reader(io.StringIO(res.stdout)) next(reader, None) # skip header + any_player = False for row in reader: if not row: continue image = (row[0] if len(row) > 0 else "").strip().lower() window_title = (row[-1] if len(row) > 0 else "").strip() - if image == "hd-player.exe" and window_title == title: - return True # yes, I parsed CSV from tasklist. no, I'm not proud of it + window_title_lower = window_title.lower() + + if image == "hd-player.exe": + any_player = True + # Accept exact, contains, or empty title (fallback to any player) + if not title or window_title_lower == title or title in window_title_lower: + return True # yes, I parsed CSV from tasklist. no, I'm not proud of it + + # Fallback: if any BlueStacks player is running, assume ours to avoid hanging + if any_player: + return True except Exception: return False return False @@ -637,17 +752,25 @@ def restart(self) -> bool: start_ts = time.time() self.logger.change_status("Starting BlueStacks 5 emulator restart process...") + if not self._ensure_target_instance(): + return False + self.logger.change_status("Stopping pyclashbot BlueStacks 5 instance...") self.stop() - self.logger.change_status("Launching BlueStacks 5 (pyclashbot-96)...") + self.logger.change_status(f"Launching BlueStacks 5 ({self.instance_name})...") self.start() # Wait for only our instance boot_timeout = 180 + soft_wait = 15 # after this, proceed even if window title not matched t0 = time.time() while not self._is_this_instance_running(): - if time.time() - t0 > boot_timeout: + elapsed = time.time() - t0 + if elapsed > soft_wait: + self.logger.log("[BlueStacks 5] Proceeding without window title match; assuming launched.") + break + if elapsed > boot_timeout: self.logger.change_status("Timeout waiting for pyclashbot instance to start - retrying...") return False interruptible_sleep(0.5) diff --git a/pyclashbot/interface/enums.py b/pyclashbot/interface/enums.py index 42da2dc40..70eee1542 100644 --- a/pyclashbot/interface/enums.py +++ b/pyclashbot/interface/enums.py @@ -60,6 +60,12 @@ class UIField(StrEnum): GP_WSI = "gp_wsi" ADB_TOGGLE = "adb_toggle" ADB_SERIAL = "adb_serial" + BLUESTACKS_INSTANCE = "bluestacks_instance" + SCHEDULER_ENABLED = "scheduler_enabled" + SCHEDULER_START_HOUR = "scheduler_start_hour" + SCHEDULER_START_MINUTE = "scheduler_start_minute" + SCHEDULER_END_HOUR = "scheduler_end_hour" + SCHEDULER_END_MINUTE = "scheduler_end_minute" BATTLE_STAT_LABELS: dict[StatField, str] = { diff --git a/pyclashbot/interface/ui.py b/pyclashbot/interface/ui.py index 7f4a4a638..21d8dc2cd 100644 --- a/pyclashbot/interface/ui.py +++ b/pyclashbot/interface/ui.py @@ -1,7 +1,9 @@ from __future__ import annotations +import re import tkinter as tk from collections.abc import Callable +from datetime import datetime from tkinter import messagebox from typing import TYPE_CHECKING @@ -30,6 +32,7 @@ UIField, ) from pyclashbot.interface.widgets import DualRingGauge +from pyclashbot.utils.colored_logging import LogColorMap if TYPE_CHECKING: from collections.abc import Callable @@ -56,8 +59,17 @@ def __init__(self) -> None: self.theme_var = ttk.StringVar(value=current_theme) self.discord_rpc_var = ttk.BooleanVar(value=False) self.advanced_settings_var = ttk.BooleanVar(value=False) + self.scheduler_enabled_var = ttk.BooleanVar(value=False) + + # Set start time to current time by default + now = datetime.now() + self.scheduler_start_hour_var = ttk.StringVar(value=f"{now.hour:02d}") + self.scheduler_start_minute_var = ttk.StringVar(value=f"{now.minute:02d}") + self.scheduler_end_hour_var = ttk.StringVar(value="22") + self.scheduler_end_minute_var = ttk.StringVar(value="00") self._config_callback: Callable[[dict[str, object]], None] | None = None self._open_logs_callback: Callable[[], None] | None = None + self._bluestacks_refresh_callback: Callable[[], list[str]] | None = None self._config_widgets: dict[str, tk.Widget] = {} self._theme_labels: list[tk.Widget] = [] self._traces: list[tuple[tk.Variable, str]] = [] @@ -77,6 +89,12 @@ def register_config_callback(self, callback: Callable[[dict[str, object]], None] def register_open_logs_callback(self, callback: Callable[[], None]) -> None: self._open_logs_callback = callback + def register_bluestacks_refresh_callback(self, callback: Callable[[], list[str]]) -> None: + """Register a callback to get available BlueStacks instances. + + The callback should return a list of available instance names. + """ + self._bluestacks_refresh_callback = callback def get_all_values(self) -> dict[str, object]: values: dict[str, object] = {} for field, var in self.jobs_vars.items(): @@ -99,6 +117,7 @@ def get_all_values(self) -> dict[str, object]: values[UIField.BS_RENDERER_DX.value] = bs_render == "DirectX" values[UIField.BS_RENDERER_GL.value] = bs_render == "OpenGL" values[UIField.BS_RENDERER_VK.value] = bs_render == "Vulkan" + values[UIField.BLUESTACKS_INSTANCE.value] = self.bs_instance_var.get() for field, var in self.gp_vars.items(): values[field.value] = var.get() @@ -107,6 +126,11 @@ def get_all_values(self) -> dict[str, object]: values[UIField.THEME_NAME.value] = self.theme_var.get() or self.DEFAULT_THEME values[UIField.DISCORD_RPC_TOGGLE.value] = bool(self.discord_rpc_var.get()) + values[UIField.SCHEDULER_ENABLED.value] = bool(self.scheduler_enabled_var.get()) + values[UIField.SCHEDULER_START_HOUR.value] = self._safe_int(self.scheduler_start_hour_var.get(), 8) + values[UIField.SCHEDULER_START_MINUTE.value] = self._safe_int(self.scheduler_start_minute_var.get(), 0) + values[UIField.SCHEDULER_END_HOUR.value] = self._safe_int(self.scheduler_end_hour_var.get(), 22) + values[UIField.SCHEDULER_END_MINUTE.value] = self._safe_int(self.scheduler_end_minute_var.get(), 0) return values def set_all_values(self, values: dict[str, object]) -> None: @@ -160,6 +184,10 @@ def set_all_values(self, values: dict[str, object]) -> None: elif values.get(UIField.BS_RENDERER_GL.value): self.bs_render_var.set("OpenGL") + if UIField.BLUESTACKS_INSTANCE.value in values: + instance = str(values[UIField.BLUESTACKS_INSTANCE.value]) + self.bs_instance_var.set(instance) + for field, var in self.gp_vars.items(): config = next((c for c in GOOGLE_PLAY_SETTINGS if c.key == field), None) if field.value in values and values[field.value] is not None: @@ -170,6 +198,16 @@ def set_all_values(self, values: dict[str, object]) -> None: if UIField.ADB_SERIAL.value in values: self.adb_serial_var.set(str(values[UIField.ADB_SERIAL.value])) + if UIField.SCHEDULER_ENABLED.value in values: + self.scheduler_enabled_var.set(bool(values[UIField.SCHEDULER_ENABLED.value])) + + # Start time always shows current PC time (not saved value) + # Only load end time from saved config + if UIField.SCHEDULER_END_HOUR.value in values: + self.scheduler_end_hour_var.set(str(values[UIField.SCHEDULER_END_HOUR.value]).zfill(2)) + if UIField.SCHEDULER_END_MINUTE.value in values: + self.scheduler_end_minute_var.set(str(values[UIField.SCHEDULER_END_MINUTE.value]).zfill(2)) + self._update_google_play_comboboxes() finally: @@ -250,10 +288,27 @@ def hide_action_button(self) -> None: def append_log(self, message: str) -> None: self.event_log.configure(state="normal") self.event_log.delete("1.0", "end") - self.event_log.insert("end", message) + clean_message = self._strip_ansi(message) + self.event_log.insert("end", clean_message) + self._apply_log_tags(clean_message) self.event_log.configure(state="disabled") self.event_log.see("end") + def _init_log_tags(self) -> None: + for color_name, hex_code in LogColorMap.COLOR_HEX.items(): + self.event_log.tag_configure(color_name, foreground=hex_code) + + @staticmethod + def _strip_ansi(message: str) -> str: + return re.sub(r"\x1b\[[0-9;]*m", "", message) + + def _apply_log_tags(self, message: str) -> None: + for pattern, color_name in LogColorMap.HIGHLIGHT_PATTERNS: + for match in re.finditer(pattern, message, flags=re.IGNORECASE): + start = f"1.0+{match.start()}c" + end = f"1.0+{match.end()}c" + self.event_log.tag_add(color_name, start, end) + def set_status(self, text: str) -> None: self._status_text = text @@ -327,6 +382,7 @@ def _build_bottom_row(self) -> None: self.event_log = tk.Text(bottom, height=3, wrap="word") self.event_log.grid(row=0, column=0, sticky="nsew") self.event_log.configure(state="disabled") + self._init_log_tags() self._status_text = "Idle" # Single unified button below log @@ -557,6 +613,36 @@ def _create_memu_settings(self, parent_frame: ttk.Frame) -> None: self._register_config_widget(config.key.value, rb) def _create_bluestacks_settings(self, parent_frame: ttk.Frame) -> None: + # Instance Selection Frame + instance_frame = ttk.Labelframe(parent_frame, text="Instance Selection", padding=10) + instance_frame.pack(fill="x", padx=5, pady=5) + instance_frame.columnconfigure(1, weight=1) + + ttk.Label(instance_frame, text="BlueStacks Instance:").grid(row=0, column=0, padx=(0, 5), sticky="w") + + self.bs_instance_var = ttk.StringVar(value="pyclashbot-96") + self.bs_instance_combo = ttk.Combobox( + instance_frame, + textvariable=self.bs_instance_var, + state=READONLY, + width=25, + ) + self.bs_instance_combo.grid(row=0, column=1, padx=5, sticky="ew") + self._register_config_widget(UIField.BLUESTACKS_INSTANCE.value, self.bs_instance_combo) + self._trace_variable(self.bs_instance_var) + + self.bs_refresh_btn = ttk.Button( + instance_frame, + text="Refresh", + command=self._on_bluestacks_refresh, + ) + self.bs_refresh_btn.grid(row=0, column=2, padx=(5, 0), sticky="ew") + self._register_config_widget("bs_refresh_btn", self.bs_refresh_btn) + + # Load available instances + self._update_bluestacks_instances() + + # Render Mode Frame (Advanced) self.bluestacks_advanced_frame = ttk.Labelframe(parent_frame, text="Render Mode", padding=10) self.bluestacks_advanced_frame.pack_forget() @@ -749,6 +835,66 @@ def _create_misc_tab(self) -> None: ) self.open_logs_btn.pack(fill="x", pady=(6, 0)) + ttk.Separator(self.misc_tab, orient="horizontal").pack(fill="x", padx=10, pady=(6, 0)) + scheduler_frame = ttk.Labelframe(self.misc_tab, text="Scheduler", padding=10) + scheduler_frame.pack(fill="x", padx=10, pady=10) + + scheduler_checkbox = ttk.Checkbutton( + scheduler_frame, + text="Enable Schedule", + variable=self.scheduler_enabled_var, + bootstyle="round-toggle", + command=self._notify_config_change, + ) + scheduler_checkbox.pack(anchor="w", pady=(0, 8)) + self._trace_variable(self.scheduler_enabled_var) + + time_frame = ttk.Frame(scheduler_frame) + time_frame.pack(fill="x") + + ttk.Label(time_frame, text="Start Time:").grid(row=0, column=0, sticky="w", padx=(0, 5)) + ttk.Spinbox( + time_frame, + from_=0, + to=23, + width=3, + textvariable=self.scheduler_start_hour_var, + command=self._notify_config_change, + ).grid(row=0, column=1, padx=2) + ttk.Label(time_frame, text=":").grid(row=0, column=2) + ttk.Spinbox( + time_frame, + from_=0, + to=59, + width=3, + textvariable=self.scheduler_start_minute_var, + command=self._notify_config_change, + ).grid(row=0, column=3, padx=2) + + ttk.Label(time_frame, text="End Time:").grid(row=1, column=0, sticky="w", padx=(0, 5), pady=(6, 0)) + ttk.Spinbox( + time_frame, + from_=0, + to=23, + width=3, + textvariable=self.scheduler_end_hour_var, + command=self._notify_config_change, + ).grid(row=1, column=1, padx=2, pady=(6, 0)) + ttk.Label(time_frame, text=":").grid(row=1, column=2, pady=(6, 0)) + ttk.Spinbox( + time_frame, + from_=0, + to=59, + width=3, + textvariable=self.scheduler_end_minute_var, + command=self._notify_config_change, + ).grid(row=1, column=3, padx=2, pady=(6, 0)) + + self._trace_variable(self.scheduler_start_hour_var) + self._trace_variable(self.scheduler_start_minute_var) + self._trace_variable(self.scheduler_end_hour_var) + self._trace_variable(self.scheduler_end_minute_var) + ttk.Separator(self.misc_tab, orient="horizontal").pack(fill="x", padx=10, pady=(6, 0)) display_frame = ttk.Labelframe(self.misc_tab, text="Display Settings", padding=10) display_frame.pack(fill="x", padx=10, pady=10) @@ -857,7 +1003,9 @@ def _show_current_emulator_settings(self) -> None: # Show the selected frame frame_to_show = self.emulator_settings_frames.get(selected_emulator) should_show = True - if selected_emulator in {EmulatorType.MEMU, EmulatorType.BLUESTACKS, EmulatorType.GOOGLE_PLAY}: + # Show MEmu/Google Play settings only when Advanced is enabled. + # BlueStacks settings (instance selector) are always visible; its render mode stays in Advanced. + if selected_emulator in {EmulatorType.MEMU, EmulatorType.GOOGLE_PLAY}: should_show = show_advanced if frame_to_show and should_show: frame_to_show.pack(fill=BOTH, expand=YES) @@ -899,6 +1047,31 @@ def _on_advanced_settings_toggled(self) -> None: # Re-evaluate which emulator settings should be visible when the toggle changes self._show_current_emulator_settings() + def _update_bluestacks_instances(self) -> None: + """Update the list of available BlueStacks instances in the combobox.""" + if self._bluestacks_refresh_callback: + try: + instances = self._bluestacks_refresh_callback() + if instances: + self.bs_instance_combo.configure(values=instances) + # Set to the first instance if current value is not in the list + current = self.bs_instance_var.get() + if current not in instances: + self.bs_instance_var.set(instances[0]) + except Exception: + # If callback fails, just keep current values + pass + else: + # If no callback is registered, try to populate with default + current = self.bs_instance_var.get() + if current: + self.bs_instance_combo.configure(values=[current]) + + def _on_bluestacks_refresh(self) -> None: + """Handle the BlueStacks refresh button click.""" + self._update_bluestacks_instances() + + @staticmethod def _safe_int(value: object, fallback: int = 0) -> int: try: diff --git a/pyclashbot/utils/colored_logging.py b/pyclashbot/utils/colored_logging.py new file mode 100644 index 000000000..6d76ad56a --- /dev/null +++ b/pyclashbot/utils/colored_logging.py @@ -0,0 +1,237 @@ +"""Colored logging utilities for the bot.""" + +import re +from enum import Enum + + +class LogLevel(Enum): + """Log level types with associated colors.""" + + INFO = ("\033[94m", "INFO") # Blue + SUCCESS = ("\033[92m", "SUCCESS") # Green + WARNING = ("\033[93m", "WARNING") # Yellow + ERROR = ("\033[91m", "ERROR") # Red + DEBUG = ("\033[96m", "DEBUG") # Cyan + NOTICE = ("\033[95m", "NOTICE") # Magenta + IDLE = ("\033[90m", "IDLE") # Gray + RESET = ("\033[0m", "RESET") # Reset color + + @property + def color(self) -> str: + """Get ANSI color code.""" + return self.value[0] + + @property + def label(self) -> str: + """Get log level label.""" + return self.value[1] + + +class ColoredFormatter: + """Formats log messages with colors for terminal output.""" + + @staticmethod + def format_message(level: LogLevel, message: str, use_colors: bool = True) -> str: + """Format a log message with optional colors. + + Args: + level: Log level + message: Message to format + use_colors: Whether to include ANSI color codes + + Returns: + Formatted message + """ + if not use_colors: + return f"[{level.label}] {message}" + + return f"{level.color}[{level.label}]{LogLevel.RESET.color} {message}" + + @staticmethod + def info(message: str) -> str: + """Format an info message.""" + return ColoredFormatter.format_message(LogLevel.INFO, message) + + @staticmethod + def success(message: str) -> str: + """Format a success message.""" + return ColoredFormatter.format_message(LogLevel.SUCCESS, message) + + @staticmethod + def warning(message: str) -> str: + """Format a warning message.""" + return ColoredFormatter.format_message(LogLevel.WARNING, message) + + @staticmethod + def error(message: str) -> str: + """Format an error message.""" + return ColoredFormatter.format_message(LogLevel.ERROR, message) + + @staticmethod + def debug(message: str) -> str: + """Format a debug message.""" + return ColoredFormatter.format_message(LogLevel.DEBUG, message) + + +class LogColorMap: + """Maps message patterns to colors for automatic coloring.""" + + COLOR_CODES = { + "yellow": "\033[93m", + "gray": "\033[90m", + "cyan": "\033[96m", + "blue": "\033[94m", + "green": "\033[92m", + "magenta": "\033[95m", + "orange": "\033[38;5;208m", + "light_blue": "\033[96m", + "red": "\033[91m", + } + + COLOR_HEX = { + "yellow": "#f1c40f", + "gray": "#95a5a6", + "cyan": "#00bcd4", + "blue": "#3498db", + "green": "#2ecc71", + "magenta": "#9b59b6", + "orange": "#e67e22", + "light_blue": "#5dade2", + "red": "#e74c3c", + } + + # Pattern matching rules + SUCCESS_KEYWORDS = [ + "success", + "passed", + "completed", + "detected", + "found", + "connected", + "started", + "running", + ] + WARNING_KEYWORDS = [ + "warning", + "retrying", + "timeout", + "skipping", + "skipped", + "suspended", + "paused", + ] + STOP_KEYWORDS = [ + "stopping", + "stopped", + "outside", + ] + IDLE_KEYWORDS = [ + "waiting", + "idle", + ] + ERROR_KEYWORDS = [ + "error", + "failed", + "crash", + "exception", + "fatal", + "critical", + "refused", + ] + DEBUG_KEYWORDS = [ + "debug", + ] + INFO_KEYWORDS = [ + "info", + "status", + "running", + ] + + HIGHLIGHT_PATTERNS = [ + (r"returning to clash main", "magenta"), + (r"back to main", "magenta"), + (r"calculated play", "cyan"), + (r"identified card", "blue"), + (r"\bunknown\b", "yellow"), + (r"\bunidentified\b", "yellow"), + (r"\bwaiting\b", "gray"), + (r"\belixer\b", "gray"), + (r"\bplay\b", "cyan"), + (r"\bcard\b", "blue"), + (r"\bselecting\b|\bchosen\b|\bchoosing\b", "light_blue"), + (r"\bdetected\b|\bfound\b", "green"), + (r"\bretrying\b|\bfailed\b|\berror\b", "red"), + (r"\bstart(?:ing|ed)?\b", "green"), + (r"\bstopping\b|\bstopped\b|\boutside\b", "magenta"), + (r"\bbattle\b|\bfight\b|\bmatch\b|\barena\b", "orange"), + (r"\bcannon\b", "green"), + (r"\b[a-z]+_[a-z_]+\b", "green"), + ] + + @staticmethod + def detect_level(message: str) -> LogLevel: + """Detect appropriate log level based on message content. + + Args: + message: Log message + + Returns: + Appropriate LogLevel + """ + lower_msg = message.lower() + + for keyword in LogColorMap.ERROR_KEYWORDS: + if keyword in lower_msg: + return LogLevel.ERROR + + for keyword in LogColorMap.WARNING_KEYWORDS: + if keyword in lower_msg: + return LogLevel.WARNING + + for keyword in LogColorMap.SUCCESS_KEYWORDS: + if keyword in lower_msg: + return LogLevel.SUCCESS + + for keyword in LogColorMap.STOP_KEYWORDS: + if keyword in lower_msg: + return LogLevel.NOTICE + + for keyword in LogColorMap.IDLE_KEYWORDS: + if keyword in lower_msg: + return LogLevel.IDLE + + for keyword in LogColorMap.DEBUG_KEYWORDS: + if keyword in lower_msg: + return LogLevel.DEBUG + + for keyword in LogColorMap.INFO_KEYWORDS: + if keyword in lower_msg: + return LogLevel.INFO + + return LogLevel.INFO + + @staticmethod + def auto_format(message: str) -> str: + """Automatically format a message based on its content. + + Args: + message: Message to format + + Returns: + Formatted message with appropriate color + """ + level = LogColorMap.detect_level(message) + highlighted = LogColorMap._apply_highlights(message) + return ColoredFormatter.format_message(level, highlighted) + + @staticmethod + def _apply_highlights(message: str) -> str: + for pattern, color_name in LogColorMap.HIGHLIGHT_PATTERNS: + color = LogColorMap.COLOR_CODES[color_name] + message = re.sub( + pattern, + lambda match: f"{color}{match.group(0)}{LogLevel.RESET.color}", + message, + flags=re.IGNORECASE, + ) + return message diff --git a/pyclashbot/utils/logger.py b/pyclashbot/utils/logger.py index 02ebed8b5..facfffc66 100644 --- a/pyclashbot/utils/logger.py +++ b/pyclashbot/utils/logger.py @@ -10,6 +10,7 @@ from os import listdir, makedirs, remove from os.path import exists, getmtime, join +from pyclashbot.utils.colored_logging import LogColorMap from pyclashbot.utils.machine_info import MACHINE_INFO from pyclashbot.utils.platform import get_log_dir from pyclashbot.utils.versioning import __version__ @@ -209,7 +210,9 @@ def log(self, message) -> None: log_message = f"[{self.current_state}] {message}" logging.info(log_message) time_string = self.calc_time_since_start() - print(f"[{self.current_state}] [{time_string}] {message}") + console_message = f"[{self.current_state}] [{time_string}] {message}" + colored_message = LogColorMap.auto_format(console_message) + print(colored_message) def calc_time_since_start(self) -> str: """Calculate time since start of bot using logger's @@ -348,9 +351,9 @@ def error(self, message: str) -> None: self.errored = True logging.error(message) - @_updates_gui - def add_card_mastery_reward_collection(self) -> None: - """Increment logger's card mastery reward collection counter by 1""" + # Print colored error to console + error_message = f"[ERROR] {message}" + colored_message = LogColorMap.auto_format(error_message) self.card_mastery_reward_collections += 1 @_updates_gui diff --git a/pyclashbot/utils/scheduler.py b/pyclashbot/utils/scheduler.py new file mode 100644 index 000000000..549a94d34 --- /dev/null +++ b/pyclashbot/utils/scheduler.py @@ -0,0 +1,83 @@ +"""Scheduler for running the bot at specific times.""" + +import datetime +from dataclasses import dataclass + + +@dataclass +class ScheduleConfig: + """Configuration for bot scheduling.""" + + enabled: bool = False + start_hour: int = 8 # 0-23 + start_minute: int = 0 # 0-59 + end_hour: int = 22 # 0-23 + end_minute: int = 0 # 0-59 + + @property + def start_time(self) -> datetime.time: + """Get start time as time object.""" + return datetime.time(self.start_hour, self.start_minute) + + @property + def end_time(self) -> datetime.time: + """Get end time as time object.""" + return datetime.time(self.end_hour, self.end_minute) + + def is_within_schedule(self) -> bool: + """Check if current time is within schedule. + + Returns: + True if within schedule, False otherwise + """ + if not self.enabled: + return True + + now = datetime.datetime.now().time() + + # If start time < end time (same day) + if self.start_time <= self.end_time: + return self.start_time <= now <= self.end_time + + # If start time > end time (crosses midnight) + return now >= self.start_time or now <= self.end_time + + def time_until_start(self) -> datetime.timedelta: + """Get time remaining until schedule starts. + + Returns: + Timedelta object representing time until start + """ + if not self.enabled or self.is_within_schedule(): + return datetime.timedelta(0) + + now = datetime.datetime.now() + start_today = now.replace(hour=self.start_hour, minute=self.start_minute, second=0, microsecond=0) + + if start_today > now: + return start_today - now + + # Start is tomorrow + start_tomorrow = start_today + datetime.timedelta(days=1) + return start_tomorrow - now + + def to_dict(self) -> dict: + """Convert to dictionary for serialization.""" + return { + "enabled": self.enabled, + "start_hour": self.start_hour, + "start_minute": self.start_minute, + "end_hour": self.end_hour, + "end_minute": self.end_minute, + } + + @classmethod + def from_dict(cls, data: dict) -> "ScheduleConfig": + """Create from dictionary.""" + return cls( + enabled=data.get("enabled", False), + start_hour=data.get("start_hour", 8), + start_minute=data.get("start_minute", 0), + end_hour=data.get("end_hour", 22), + end_minute=data.get("end_minute", 0), + )