Skip to content
Open
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ recordings
.claude/
.claude/settings.local.json
.DS_Store
*.egg-info
*.egg-info
.vscode/settings.json
7 changes: 5 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,8 @@
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
"python.terminal.activateEnvironment": true,
"python-envs.pythonProjects": [],
"python-envs.defaultEnvManager": "ms-python.python:system"
}
"python-envs.defaultEnvManager": "ms-python.python:system",
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"files.trimFinalNewlines": true
}
40 changes: 40 additions & 0 deletions pyclashbot/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}")



Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extra blank lines. There should only be one blank line between method definitions and two blank lines before top-level function definitions according to PEP 8 style guidelines.

Suggested change

Copilot uses AI. Check for mistakes.
def main_gui(start_on_run: bool = False, settings: dict[str, Any] | None = None) -> None:
app = BotApplication(settings)
Expand Down
63 changes: 62 additions & 1 deletion pyclashbot/bot/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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"
Expand All @@ -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
Expand Down
137 changes: 130 additions & 7 deletions pyclashbot/emulators/bluestacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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:<port>"
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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',
Expand All @@ -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:
Comment on lines +705 to +706
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback logic that returns True if any BlueStacks player is running will incorrectly report that the specific instance is running even when it is not. This defeats the purpose of instance-specific checking and could cause the bot to attempt to connect to the wrong instance. Consider removing this fallback or making it conditional on whether a specific instance name was requested.

Suggested change
# Fallback: if any BlueStacks player is running, assume ours to avoid hanging
if any_player:
# Fallback: if any BlueStacks player is running, assume ours only when no specific title is requested
if any_player and not title:

Copilot uses AI. Check for mistakes.
return True
except Exception:
return False
return False
Expand Down Expand Up @@ -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
Comment on lines +770 to 775
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The soft_wait check will always break the loop after 15 seconds, making the boot_timeout check at line 768 unreachable. The condition order should be reversed so that boot_timeout is checked first to ensure proper timeout behavior. The logic should fail after boot_timeout but proceed optimistically after soft_wait if the instance hasn't been detected yet.

Suggested change
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
if elapsed > boot_timeout:
self.logger.change_status("Timeout waiting for pyclashbot instance to start - retrying...")
return False
if elapsed > soft_wait:
self.logger.log("[BlueStacks 5] Proceeding without window title match; assuming launched.")
break

Copilot uses AI. Check for mistakes.
interruptible_sleep(0.5)
Expand Down
6 changes: 6 additions & 0 deletions pyclashbot/interface/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {
Expand Down
Loading