feat: complete DirtyLeague autonomous FSM bot with fast derank and chest handling

This commit is contained in:
VolandSZ
2026-09-08 13:38:42 +03:00
parent d9953407e0
commit 5777748abe
103 changed files with 1618 additions and 0 deletions

21
.gitignore vendored Normal file
View File

@ -0,0 +1,21 @@
# Virtual Environment
.venv/
env/
venv/
# Python cache
__pycache__/
*.py[cod]
*$py.class
# IDE and OS
.vscode/
.idea/
Thumbs.db
Desktop.ini
# Logs & temp captures
logs/
*.log
temp_*.png
debug_*.png

1
assets/.gitkeep Normal file
View File

@ -0,0 +1 @@
# Assets directory for template images

BIN
assets/anchor_dungeons.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
assets/anchor_main_menu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
assets/badge_crown_5_5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

BIN
assets/banner_defeat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

BIN
assets/banner_victory.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

BIN
assets/btn_auto.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

BIN
assets/btn_back.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

BIN
assets/btn_close.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
assets/btn_close_offer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
assets/btn_collect.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
assets/btn_collect_text.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
assets/btn_exit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

BIN
assets/btn_fight.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

BIN
assets/btn_leave.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
assets/btn_mode_tower.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
assets/btn_ok.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
assets/btn_open_chest.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
assets/btn_remove_chest.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
assets/char_0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 B

BIN
assets/char_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 B

BIN
assets/char_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 B

BIN
assets/char_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 B

BIN
assets/char_4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 B

BIN
assets/char_5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 B

BIN
assets/char_6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 B

BIN
assets/char_7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 B

BIN
assets/char_8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 B

BIN
assets/chars_annotated.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

BIN
assets/crown_5_5_up.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

BIN
assets/crown_badge_area.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
assets/crown_bin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
assets/crown_chest_area.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

BIN
assets/crown_inv.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

BIN
assets/crown_padded.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

BIN
assets/crown_text_part.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
assets/crown_thresh.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
assets/crown_up.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

BIN
assets/detected_close.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
assets/digit_crown_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
assets/digit_crown_5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 MiB

BIN
assets/region_full_btn.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

BIN
assets/slot_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
assets/slot_1_status.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
assets/slot_1_vacant.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
assets/slot_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
assets/slot_2_status.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

BIN
assets/slot_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
assets/slot_3_status.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
assets/slot_4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
assets/slot_4_status.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
assets/slot_5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
assets/slot_5_status.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
assets/status_open.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

BIN
assets/status_vacant.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
assets/text_crown_5_5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

BIN
assets/text_defeat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
assets/text_victory.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

BIN
assets/tile_tower_label.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

BIN
assets/trophy_area.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
assets/trophy_numbers.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
assets/trophy_thresh.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

588
bot_core.py Normal file
View File

@ -0,0 +1,588 @@
"""
Core FSM Bot engine for DirtyLeague.
Manages screen recognition, game state transitions, and automated inputs.
"""
from enum import Enum
import logging
import os
import time
from typing import Dict, Optional, Tuple
import cv2
import numpy as np
import pydirectinput
from window_utils import WindowManager
from ocr_utils import ocr_reader
class GameState(Enum):
UNKNOWN = "UNKNOWN"
MAIN_MENU = "MAIN_MENU"
TOWER_LOBBY = "TOWER_LOBBY"
CHEST_OPEN_SCREEN = "CHEST_OPEN_SCREEN"
IN_GAME = "IN_GAME"
VICTORY_SCREEN = "VICTORY_SCREEN"
DEFEAT_SCREEN = "DEFEAT_SCREEN"
NO_FREE_SLOTS_SCREEN = "NO_FREE_SLOTS_SCREEN"
class DirtyLeagueBot:
def __init__(self, config: dict):
self.config = config
self.bot_conf = config.get("bot", {})
self.target_conf = config.get("target_window", {})
self.assets_dir = config.get("assets_dir", "assets")
self.max_wins_limit = self.bot_conf.get("max_wins_limit", 3)
self.poll_interval = self.bot_conf.get("poll_interval_sec", 0.8)
self.confidence_threshold = self.bot_conf.get("confidence_threshold", 0.8)
self.action_delay = self.bot_conf.get("action_delay_sec", 0.5)
self.current_wins = 0
self.total_games = 0
self.current_trophies: Optional[int] = None
self.max_trophies: Optional[int] = None
self.league_conf = config.get("league_retention", {})
self.upper_trophies = self.league_conf.get("upper_trophy_threshold", 2125)
self.lower_trophies = self.league_conf.get("lower_trophy_threshold", 125)
self.derank_mode: bool = False
self._result_recorded_for_match: bool = False
self.running = False
self.state = GameState.UNKNOWN
self.wm = WindowManager(
window_title=self.target_conf.get("title", "DirtyLeague"),
process_name=self.target_conf.get("process_name", "DirtyLeague.exe")
)
self.templates: Dict[str, np.ndarray] = {}
self._load_templates()
def _load_templates(self):
"""Preloads all PNG template images from the assets directory."""
if not os.path.exists(self.assets_dir):
return
for file_name in os.listdir(self.assets_dir):
if file_name.endswith(".png"):
name_without_ext = os.path.splitext(file_name)[0]
path = os.path.join(self.assets_dir, file_name)
img = cv2.imread(path)
if img is not None:
self.templates[name_without_ext] = img
logging.info(f"Loaded {len(self.templates)} template(s) from '{self.assets_dir}': {list(self.templates.keys())}")
def find_template(self, frame: np.ndarray, template_name: str, threshold: Optional[float] = None) -> Optional[Tuple[int, int]]:
"""
Searches for template_name in frame.
Returns client center coordinates (center_x, center_y) if matched above threshold, else None.
"""
if template_name not in self.templates:
# Try reloading in case new template was added
path = os.path.join(self.assets_dir, f"{template_name}.png")
if os.path.exists(path):
img = cv2.imread(path)
if img is not None:
self.templates[template_name] = img
if template_name not in self.templates:
return None
tpl = self.templates[template_name]
th, tw = tpl.shape[:2]
fh, fw = frame.shape[:2]
if th > fh or tw > fw:
return None
res = cv2.matchTemplate(frame, tpl, cv2.TM_CCOEFF_NORMED)
_, max_val, _, max_loc = cv2.minMaxLoc(res)
target_thresh = threshold if threshold is not None else self.confidence_threshold
if max_val >= target_thresh:
center_x = max_loc[0] + tw // 2
center_y = max_loc[1] + th // 2
logging.debug(f"Matched '{template_name}' (conf: {max_val:.3f}) at client ({center_x}, {center_y})")
return center_x, center_y
return None
def find_template_in_roi(
self,
frame: np.ndarray,
template_name: str,
roi: Tuple[int, int, int, int],
threshold: Optional[float] = None
) -> Optional[Tuple[int, int]]:
"""
Fast template search restricted to ROI: (x1, y1, x2, y2).
Returns client center coordinates (cx, cy) if matched, else None.
Runs in ~2-5ms instead of ~420ms for full-frame search.
"""
if template_name not in self.templates:
return None
tpl = self.templates[template_name]
th, tw = tpl.shape[:2]
fh, fw = frame.shape[:2]
x1, y1, x2, y2 = roi
x1, y1 = max(0, x1), max(0, y1)
x2, y2 = min(fw, x2), min(fh, y2)
if x2 - x1 < tw or y2 - y1 < th:
return None
crop = frame[y1:y2, x1:x2]
res = cv2.matchTemplate(crop, tpl, cv2.TM_CCOEFF_NORMED)
_, max_val, _, max_loc = cv2.minMaxLoc(res)
target_thresh = threshold if threshold is not None else self.confidence_threshold
if max_val >= target_thresh:
cx = x1 + max_loc[0] + tw // 2
cy = y1 + max_loc[1] + th // 2
return cx, cy
return None
def click_client_pos(self, x: int, y: int):
"""Translates client coordinates to screen and performs a click via Win32 API."""
screen_x, screen_y = self.wm.client_to_screen(x, y)
logging.info(f"Clicking at client ({x}, {y}) -> screen ({screen_x}, {screen_y})")
from window_utils import ensure_interactive_desktop, user32
ensure_interactive_desktop()
self.wm.focus()
user32.SetCursorPos(screen_x, screen_y)
time.sleep(0.08)
user32.mouse_event(0x0002, 0, 0, 0, 0) # MOUSEEVENTF_LEFTDOWN
time.sleep(0.08)
user32.mouse_event(0x0004, 0, 0, 0, 0) # MOUSEEVENTF_LEFTUP
time.sleep(self.action_delay)
def click_client_pos_fast(self, x: int, y: int):
"""Ultra-fast click without artificial action delays for time-critical reactions (e.g. instant surrender)."""
screen_x, screen_y = self.wm.client_to_screen(x, y)
from window_utils import ensure_interactive_desktop, user32
ensure_interactive_desktop()
self.wm.focus()
user32.SetCursorPos(screen_x, screen_y)
time.sleep(0.02)
user32.mouse_event(0x0002, 0, 0, 0, 0) # MOUSEEVENTF_LEFTDOWN
time.sleep(0.02)
user32.mouse_event(0x0004, 0, 0, 0, 0) # MOUSEEVENTF_LEFTUP
def click_template(self, frame: np.ndarray, template_name: str, threshold: Optional[float] = None) -> bool:
"""Finds and clicks a template in the frame if found."""
pos = self.find_template(frame, template_name, threshold)
if pos:
self.click_client_pos(pos[0], pos[1])
return True
return False
def detect_state(self, frame: np.ndarray) -> GameState:
"""Determines the current game screen based on visible templates (accelerated with ROIs)."""
# 1. Ultra-fast ROI checks (~3-5ms each instead of ~420ms full-frame)
if self.find_template_in_roi(frame, "btn_remove_chest", (1350, 1680, 1950, 1850)) or \
self.find_template_in_roi(frame, "btn_open_chest", (1850, 1680, 2350, 1850)):
return GameState.NO_FREE_SLOTS_SCREEN
if self.find_template_in_roi(frame, "btn_ok", (900, 1480, 1450, 1700)) or \
self.find_template_in_roi(frame, "text_defeat", (500, 1400, 1200, 1700), threshold=0.65):
return GameState.DEFEAT_SCREEN
if self.find_template_in_roi(frame, "btn_exit", (1600, 1900, 1920, 2020)) or \
self.find_template_in_roi(frame, "btn_leave", (1900, 1150, 2400, 1400)):
return GameState.IN_GAME
if self.find_template_in_roi(frame, "btn_fight", (3000, 750, 3550, 1000)):
return GameState.TOWER_LOBBY
if self.find_template_in_roi(frame, "btn_turn_all_cards", (1550, 1350, 2250, 1550)) or \
self.find_template_in_roi(frame, "btn_collect", (1550, 1350, 2250, 1550), threshold=0.65):
return GameState.CHEST_OPEN_SCREEN
if self.find_template_in_roi(frame, "btn_collect_victory", (750, 1550, 1200, 1750)) or \
self.find_template_in_roi(frame, "banner_victory", (400, 300, 1600, 700), threshold=0.65):
return GameState.VICTORY_SCREEN
# 2. Full-frame fallback checks if ROI didn't trigger:
if self.find_template(frame, "btn_remove_chest") or self.find_template(frame, "btn_open_chest"):
return GameState.NO_FREE_SLOTS_SCREEN
if self.find_template(frame, "text_victory") or self.find_template(frame, "banner_victory") or self.find_template(frame, "btn_collect_victory"):
return GameState.VICTORY_SCREEN
if self.find_template(frame, "text_defeat", threshold=0.65) or self.find_template(frame, "btn_ok"):
return GameState.DEFEAT_SCREEN
if self.find_template(frame, "btn_leave") or self.find_template(frame, "btn_exit") or self.find_template(frame, "btn_pause"):
return GameState.IN_GAME
if self.find_template(frame, "btn_collect") or self.find_template(frame, "btn_turn_all_cards"):
return GameState.CHEST_OPEN_SCREEN
if self.find_template(frame, "btn_fight") or self.find_template(frame, "btn_play"):
return GameState.TOWER_LOBBY
if self.find_template(frame, "anchor_main_menu") or self.find_template(frame, "btn_mode_tower"):
return GameState.MAIN_MENU
return GameState.UNKNOWN
def scan_bottom_chests(self, frame: np.ndarray) -> dict:
"""
Scans chest slots 1..4 in the bottom bar of TOWER_LOBBY.
Grid calibrated: slot 1 starts at x=525, slot pitch = 213px.
Returns dict of slot_id -> {"status": "OPEN"|"TIMER"|"VACANT", "click_pos": (cx, cy)}
"""
results = {}
slot_pitch = 213
base_x = 525
y = 1850
w = 210
h = 70
for i in range(4):
slot_id = i + 1
sx = base_x + i * slot_pitch
status_crop = frame[y:y + h, sx:sx + w]
is_open = False
if "status_open" in self.templates:
res_o = cv2.matchTemplate(status_crop, self.templates["status_open"], cv2.TM_CCOEFF_NORMED)
_, max_o, _, _ = cv2.minMaxLoc(res_o)
if max_o >= 0.8:
is_open = True
is_vacant = False
if "status_vacant" in self.templates:
res_v = cv2.matchTemplate(status_crop, self.templates["status_vacant"], cv2.TM_CCOEFF_NORMED)
_, max_v, _, _ = cv2.minMaxLoc(res_v)
if max_v >= 0.8:
is_vacant = True
if is_open:
status = "OPEN"
elif is_vacant:
status = "VACANT"
else:
status = "TIMER"
results[slot_id] = {
"status": status,
"click_pos": (sx + w // 2, y - 70)
}
logging.debug(f"[CHESTS] Slot #{slot_id}: {status}")
return results
def check_and_dismiss_offer_popup(self, frame: np.ndarray) -> bool:
"""
Detects and dismisses promotional or purchase offer popups ('X' close button)
that may appear randomly or after claiming chest rewards/returning to lobby.
Returns True if a popup was detected and dismissed, False otherwise.
"""
# Search for close button in top-right quadrant (ROI: y=0..300, x=3400..3840)
fh, fw = frame.shape[:2]
roi_y1, roi_y2 = 0, min(300, fh)
roi_x1, roi_x2 = max(0, fw - 500), fw
roi = frame[roi_y1:roi_y2, roi_x1:roi_x2]
for tpl_name in ["btn_close", "btn_close_offer"]:
if tpl_name not in self.templates:
continue
tpl = self.templates[tpl_name]
th, tw = tpl.shape[:2]
if th > roi.shape[0] or tw > roi.shape[1]:
continue
res = cv2.matchTemplate(roi, tpl, cv2.TM_CCOEFF_NORMED)
_, max_val, _, max_loc = cv2.minMaxLoc(res)
if max_val >= 0.65:
cx = roi_x1 + max_loc[0] + tw // 2
cy = roi_y1 + max_loc[1] + th // 2
logging.info(f"[OFFER POPUP] Detected close button '{tpl_name}' (conf: {max_val:.2f}) at client ({cx}, {cy}). Dismissing...")
self.click_client_pos(cx, cy)
time.sleep(1.0)
return True
return False
def handle_chest_open_screen(self, frame: np.ndarray):
logging.info("State: CHEST_OPEN_SCREEN.")
# Step 1: Check if "Turn all cards over" is visible
if self.click_template(frame, "btn_turn_all_cards"):
logging.info("Clicked 'Turn all cards over'. Waiting for card flip animation...")
time.sleep(2.0)
return
# Step 2: Check if "Collect" is visible
if self.click_template(frame, "btn_collect"):
logging.info("Clicked 'Collect'. Rewards claimed, returning to lobby...")
time.sleep(1.5)
# Step 3: Check for promotional offer popup stub
after_frame = self.wm.capture_frame(client_only=True)
self.check_and_dismiss_offer_popup(after_frame)
def handle_main_menu(self, frame: np.ndarray):
logging.info("State: MAIN_MENU. Navigating to TOWER mode...")
if self.click_template(frame, "btn_mode_tower"):
logging.info("Clicked 'btn_mode_tower'. Transitioning to TOWER...")
time.sleep(1.5)
def update_league_strategy(self):
"""Updates derank_mode based on current trophies and thresholds."""
if self.current_trophies is None:
return
if not self.derank_mode and self.current_trophies >= self.upper_trophies:
self.derank_mode = True
logging.warning(
f"[LEAGUE STRATEGY] Upper threshold reached ({self.current_trophies} >= {self.upper_trophies}). "
f"ACTIVATING FORCED DERANK MODE! (Will surrender matches until <= {self.lower_trophies})"
)
elif self.derank_mode and self.current_trophies <= self.lower_trophies:
self.derank_mode = False
logging.info(
f"[LEAGUE STRATEGY] Lower threshold reached ({self.current_trophies} <= {self.lower_trophies}). "
f"ACTIVATING NORMAL PLAY MODE! (Playing to win until >= {self.upper_trophies})"
)
def handle_lobby(self, frame: np.ndarray):
logging.info("State: TOWER_LOBBY.")
# Read trophies counter above FIGHT button
trophies = ocr_reader.read_trophies(frame)
if trophies:
self.current_trophies, self.max_trophies = trophies
logging.info(f"[TROPHIES] Current rating: {self.current_trophies}/{self.max_trophies} (Goal: {self.max_trophies})")
self.update_league_strategy()
mode_str = "FORCED DERANK (Auto-surrender)" if self.derank_mode else "NORMAL PLAY (Play to win)"
logging.info(f"[STRATEGY] Current Battle Plan: {mode_str}")
# Check crown chest progress (#/# 👑)
crown_info = ocr_reader.read_crown_chest(frame)
if crown_info:
c_current, c_max = crown_info
logging.info(f"[CROWN CHEST] Progress: {c_current}/{c_max} [CROWN]")
if c_current >= c_max:
logging.info(f"[CROWN CHEST] Goal reached ({c_current}/{c_max})! Clicking crown chest at (875, 1260)...")
self.click_client_pos(875, 1260)
time.sleep(1.5)
return
# Check bottom chests status
chests = self.scan_bottom_chests(frame)
logging.info(f"[CHESTS] Current slots: { {k: v['status'] for k, v in chests.items()} }")
# If any chest slot is ready (OPEN), click it to collect rewards
for slot_id, info in sorted(chests.items()):
if info["status"] == "OPEN":
logging.info(f"[CHESTS] Slot #{slot_id} is OPEN! Clicking to claim at {info['click_pos']}...")
self.click_client_pos(*info["click_pos"])
time.sleep(1.5)
return
def fast_surrender_pipeline(self, max_wait_sec: float = 15.0) -> bool:
"""
Ultra-fast surrender pipeline for FORCED DERANK.
Polls the Exit button ROI (~20ms per check). The millisecond battle finishes loading,
clicks Exit via fast click, then immediately polls Leave dialog ROI and confirms.
Surrenders the battle within < 0.15s of battle load, before auto-battle can deal lethal damage.
"""
logging.info("[FAST DERANK] High-frequency surrender monitor engaged. Waiting for battle screen...")
t0 = time.time()
exit_roi = (1600, 1900, 1920, 2020)
leave_roi = (1900, 1150, 2400, 1400)
# Step 1: High-frequency polling for Exit button (check every 20ms)
exit_clicked = False
while time.time() - t0 < max_wait_sec:
if not self.running:
return False
frame = self.wm.capture_frame(client_only=True)
if frame is None:
time.sleep(0.02)
continue
exit_pos = self.find_template_in_roi(frame, "btn_exit", exit_roi, threshold=0.75)
if exit_pos:
logging.info(f"[FAST DERANK] Battle loaded! 'Exit' detected at {exit_pos} in {time.time() - t0:.2f}s. Clicking...")
self.click_client_pos_fast(exit_pos[0], exit_pos[1])
exit_clicked = True
break
leave_pos = self.find_template_in_roi(frame, "btn_leave", leave_roi, threshold=0.75)
if leave_pos:
self.click_client_pos_fast(leave_pos[0], leave_pos[1])
logging.info("[FAST DERANK] Clicked existing 'Leave' confirmation.")
return True
time.sleep(0.02)
if not exit_clicked:
logging.warning("[FAST DERANK] Exit button not found within timeout.")
return False
# Step 2: High-frequency polling for 'Leave' confirmation dialog (check every 20ms)
t_leave = time.time()
while time.time() - t_leave < 4.0:
if not self.running:
return False
frame = self.wm.capture_frame(client_only=True)
if frame is None:
time.sleep(0.02)
continue
leave_pos = self.find_template_in_roi(frame, "btn_leave", leave_roi, threshold=0.75)
if leave_pos:
self.click_client_pos_fast(leave_pos[0], leave_pos[1])
logging.info(f"[FAST DERANK] Confirmed 'Leave' in {time.time() - t_leave:.3f}s! Total surrender time: {time.time() - t0:.2f}s.")
time.sleep(0.5)
return True
time.sleep(0.02)
logging.warning("[FAST DERANK] Leave button not found after clicking Exit.")
return False
def handle_lobby(self, frame: np.ndarray):
logging.info("State: TOWER_LOBBY.")
self._result_recorded_for_match = False
# Read trophies counter above FIGHT button
trophies = ocr_reader.read_trophies(frame)
if trophies:
self.current_trophies, self.max_trophies = trophies
logging.info(f"[TROPHIES] Current rating: {self.current_trophies}/{self.max_trophies} (Goal: {self.max_trophies})")
self.update_league_strategy()
mode_str = "FORCED DERANK (Auto-surrender)" if self.derank_mode else "NORMAL PLAY (Play to win)"
logging.info(f"[STRATEGY] Current Battle Plan: {mode_str}")
# Check crown chest progress (#/# 👑)
crown_info = ocr_reader.read_crown_chest(frame)
if crown_info:
c_current, c_max = crown_info
logging.info(f"[CROWN CHEST] Progress: {c_current}/{c_max} [CROWN]")
if c_current >= c_max:
logging.info(f"[CROWN CHEST] Goal reached ({c_current}/{c_max})! Clicking crown chest at (875, 1260)...")
self.click_client_pos(875, 1260)
time.sleep(1.5)
return
# Check bottom chests status
chests = self.scan_bottom_chests(frame)
logging.info(f"[CHESTS] Current slots: { {k: v['status'] for k, v in chests.items()} }")
# If any chest slot is ready (OPEN), click it to collect rewards
for slot_id, info in sorted(chests.items()):
if info["status"] == "OPEN":
logging.info(f"[CHESTS] Slot #{slot_id} is OPEN! Clicking to claim at {info['click_pos']}...")
self.click_client_pos(*info["click_pos"])
time.sleep(1.5)
return
logging.info("Searching for 'btn_fight' / 'btn_play'...")
fight_pos = self.find_template_in_roi(frame, "btn_fight", (3000, 750, 3550, 1000)) or \
self.find_template(frame, "btn_fight") or \
self.find_template(frame, "btn_play")
if fight_pos:
self.click_client_pos(fight_pos[0], fight_pos[1])
logging.info("Clicked fight button. Transitioning towards IN_GAME.")
if self.derank_mode or (self.current_wins >= self.max_wins_limit):
self.fast_surrender_pipeline()
else:
time.sleep(1.5)
def handle_in_game(self, frame: np.ndarray):
should_surrender = self.derank_mode or (self.current_wins >= self.max_wins_limit)
reason = "Forced Derank" if self.derank_mode else f"Win Streak Limit ({self.current_wins}/{self.max_wins_limit})"
if should_surrender:
logging.warning(f"Surrender condition met [{reason}]. Fast-tracking surrender...")
self.fast_surrender_pipeline(max_wait_sec=5.0)
return
else:
logging.info("Match in progress (Normal Play, Auto-battle active). Waiting for match results...")
def handle_victory(self, frame: np.ndarray):
if not self._result_recorded_for_match:
self._result_recorded_for_match = True
self.current_wins += 1
self.total_games += 1
logging.info(f"[WIN] Victory recorded! Streak: {self.current_wins}/{self.max_wins_limit}. Total games: {self.total_games}")
if not self.click_template(frame, "btn_collect_victory"):
self.click_template(frame, "btn_continue")
time.sleep(1.0)
def handle_defeat(self, frame: np.ndarray):
if not self._result_recorded_for_match:
self._result_recorded_for_match = True
self.current_wins = 0
self.total_games += 1
logging.info(f"[LOSS] Streak reset to 0. Total games: {self.total_games}")
if not self.click_template(frame, "btn_ok"):
self.click_template(frame, "btn_continue")
time.sleep(1.0)
def handle_no_free_slots(self, frame: np.ndarray):
logging.info("State: NO_FREE_SLOTS_SCREEN ('You have no slot available for this chest').")
open_pos = self.find_template(frame, "btn_open_chest")
if open_pos:
logging.debug(f"[NO_FREE_SLOTS] 'Open chest' option detected at {open_pos}.")
if self.click_template(frame, "btn_remove_chest"):
logging.info("Clicked 'Remove chest' to discard unslotted chest. Returning to lobby...")
time.sleep(1.0)
def step(self):
"""Executes a single FSM iteration."""
frame = self.wm.capture_frame(client_only=True)
# Global check: if a promotional / purchase popup is visible, dismiss it first
if self.check_and_dismiss_offer_popup(frame):
return
detected_state = self.detect_state(frame)
if detected_state != self.state:
logging.info(f"State changed: {self.state.value} -> {detected_state.value}")
self.state = detected_state
if self.state == GameState.MAIN_MENU:
self.handle_main_menu(frame)
elif self.state == GameState.TOWER_LOBBY:
self.handle_lobby(frame)
elif self.state == GameState.CHEST_OPEN_SCREEN:
self.handle_chest_open_screen(frame)
elif self.state == GameState.IN_GAME:
self.handle_in_game(frame)
elif self.state == GameState.VICTORY_SCREEN:
self.handle_victory(frame)
elif self.state == GameState.DEFEAT_SCREEN:
self.handle_defeat(frame)
elif self.state == GameState.NO_FREE_SLOTS_SCREEN:
self.handle_no_free_slots(frame)
else:
logging.debug("State: UNKNOWN (waiting or transition in progress)")
def start(self, max_duration_sec: Optional[int] = None, max_cycles: Optional[int] = None):
"""Starts main bot loop with optional duration and battle cycle limits."""
self.running = True
self.wm.focus()
limits_desc = []
if max_duration_sec:
limits_desc.append(f"Max time: {max_duration_sec}s")
if max_cycles:
limits_desc.append(f"Max cycles: {max_cycles}")
limit_str = f" ({', '.join(limits_desc)})" if limits_desc else ""
logging.info(f"DirtyLeague Bot loop started{limit_str}.")
start_time = time.time()
initial_games = self.total_games
while self.running:
if max_duration_sec and (time.time() - start_time) >= max_duration_sec:
logging.info(f"Execution limit reached: {max_duration_sec}s elapsed. Stopping gracefully...")
break
if max_cycles and (self.total_games - initial_games) >= max_cycles:
logging.info(f"Cycle limit reached: {max_cycles} battle(s) completed. Stopping gracefully...")
break
try:
self.step()
except Exception as e:
logging.error(f"Error during bot step: {e}")
time.sleep(self.poll_interval)
def stop(self):
"""Stops main bot loop."""
self.running = False
logging.info("DirtyLeague Bot loop stopped.")

161
calibrate_tool.py Normal file
View File

@ -0,0 +1,161 @@
"""
Calibration and ROI helper tool.
Used to:
- Capture current game screen and save snapshots.
- Crop template snippets (buttons, banners) for assets/
- Test template matching against the active game screen with confidence score.
"""
import argparse
import json
import os
import sys
import time
import cv2
import numpy as np
from window_utils import WindowManager
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json")
def load_config():
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
return json.load(f)
return {}
def capture_snapshot(output_path: str = "assets/current_screen.png"):
config = load_config()
target_title = config.get("target_window", {}).get("title", "DirtyLeague")
wm = WindowManager(window_title=target_title)
hwnd = wm.find_window()
if not hwnd:
print(f"[ERROR] Window '{target_title}' not found!")
return None
frame = wm.capture_frame(client_only=True)
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
cv2.imwrite(output_path, frame)
h, w = frame.shape[:2]
print(f"[OK] Screenshot saved to: {output_path} (Resolution: {w}x{h})")
return output_path
def crop_roi(source_path: str, output_name: str, x: int, y: int, w: int, h: int):
if not os.path.exists(source_path):
print(f"[ERROR] Source file not found: {source_path}")
return
img = cv2.imread(source_path)
if img is None:
print(f"[ERROR] Could not read image: {source_path}")
return
roi = img[y:y+h, x:x+w]
if roi.size == 0:
print(f"[ERROR] Crop dimensions resulted in empty image! (x={x}, y={y}, w={w}, h={h})")
return
out_path = os.path.join("assets", output_name if output_name.endswith(".png") else f"{output_name}.png")
cv2.imwrite(out_path, roi)
print(f"[OK] Cropped ROI saved to: {out_path} ({w}x{h})")
def test_match(template_name: str, threshold: float = 0.8):
config = load_config()
target_title = config.get("target_window", {}).get("title", "DirtyLeague")
wm = WindowManager(window_title=target_title)
template_path = os.path.join("assets", template_name if template_name.endswith(".png") else f"{template_name}.png")
if not os.path.exists(template_path):
print(f"[ERROR] Template not found: {template_path}")
return
tpl = cv2.imread(template_path)
if tpl is None:
print(f"[ERROR] Failed to load template: {template_path}")
return
frame = wm.capture_frame(client_only=True)
res = cv2.matchTemplate(frame, tpl, cv2.TM_CCOEFF_NORMED)
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(res)
th, tw = tpl.shape[:2]
print(f"--- Template Match: {template_name} ---")
print(f"Max confidence: {max_val:.4f} (Threshold: {threshold})")
print(f"Best match client location: ({max_loc[0]}, {max_loc[1]}) -> Center: ({max_loc[0] + tw // 2}, {max_loc[1] + th // 2})")
if max_val >= threshold:
print("[RESULT] MATCH FOUND! Target is visible.")
else:
print("[RESULT] NOT FOUND (below threshold).")
def check_all_assets():
config = load_config()
target_title = config.get("target_window", {}).get("title", "DirtyLeague")
default_conf = config.get("bot", {}).get("confidence_threshold", 0.8)
wm = WindowManager(window_title=target_title)
assets_dir = "assets"
if not os.path.exists(assets_dir):
print("[ERROR] assets/ directory does not exist.")
return
files = [f for f in os.listdir(assets_dir) if f.endswith(".png") and not f.startswith("current_screen")]
if not files:
print("[INFO] No template images found in assets/ yet.")
return
frame = wm.capture_frame(client_only=True)
print(f"Testing {len(files)} asset templates against current game screen...")
for f in sorted(files):
tpl = cv2.imread(os.path.join(assets_dir, f))
if tpl is None:
continue
res = cv2.matchTemplate(frame, tpl, cv2.TM_CCOEFF_NORMED)
_, max_val, _, max_loc = cv2.minMaxLoc(res)
status = "MATCH" if max_val >= default_conf else "NO MATCH"
print(f" [{status}] {f:<25} confidence: {max_val:.3f} loc: {max_loc}")
def main():
parser = argparse.ArgumentParser(description="Bot UI Calibration & Asset Tool")
subparsers = parser.add_subparsers(dest="command")
# snapshot command
snap_p = subparsers.add_parser("snapshot", help="Capture current game screen to file")
snap_p.add_argument("-o", "--output", default="assets/current_screen.png", help="Output PNG path")
# crop command
crop_p = subparsers.add_parser("crop", help="Crop ROI template from an image")
crop_p.add_argument("-s", "--source", default="assets/current_screen.png", help="Source screenshot")
crop_p.add_argument("-n", "--name", required=True, help="Output template name (e.g. btn_play.png)")
crop_p.add_argument("--roi", nargs=4, type=int, required=True, metavar=("X", "Y", "W", "H"), help="Crop region X Y W H")
# test command
test_p = subparsers.add_parser("test", help="Test match of a single template on live screen")
test_p.add_argument("-n", "--name", required=True, help="Template name in assets/")
test_p.add_argument("-t", "--threshold", type=float, default=0.8, help="Confidence threshold")
# scan command
subparsers.add_parser("scan", help="Scan all templates in assets/ against current screen")
args = parser.parse_args()
if args.command == "snapshot":
capture_snapshot(args.output)
elif args.command == "crop":
x, y, w, h = args.roi
crop_roi(args.source, args.name, x, y, w, h)
elif args.command == "test":
test_match(args.name, args.threshold)
elif args.command == "scan":
check_all_assets()
else:
parser.print_help()
if __name__ == "__main__":
main()

93
concept.md Normal file
View File

@ -0,0 +1,93 @@
# ТЕХНИЧЕСКОЕ ЗАДАНИЕ ДЛЯ АВТОНОМНОГО АГЕНТА: UI State Machine Bot для Unity
## 1. Цель проекта
Разработать легковесного локального Python-бота на базе конечного автомата (FSM), который управляет поведением в игре на Unity (ОС Windows 10).
Бот не должен вычислять игровые ходы. Его задача — контролировать экраны игры, отслеживать статистику матчей, принудительно сдаваться при достижении лимита побед (для контроля MMR/рейтинга) и перезапускать матчи.
---
## 2. Стек технологий
* **Python:** 3.10+
* **Захват окна / Ввод:** `pyautogui`, `pydirectinput` (для надежного клика в Unity), `win32gui`, `ctypes`
* **Компьютерное зрение:** `opencv-python`, `pillow`
* **Распознавание текста (опционально при динамическом счетчике):** `pytesseract` или `easyocr`
* **Безопасность:** `keyboard` (глобальный аварийный выключатель / Kill Switch)
---
## 3. Архитектура состояний (FSM)
Бот должен опрашивать состояние игры раз в 0.51.0 сек и находиться в одном из состояний:
1. **TOWER_LOBBY:**
* Считывание трофеев через Windows Native OCR (сравнение с верхним/нижним порогом лиги).
* Проверка прогресса большого сундука с коронами (`read_crown_chest`, счетчик `#/# 👑`). При достижении `5/5` выполняется клик по сундуку для перехода на экран открытия.
* Сканирование 4 слотов нижних сундуков (`BOTTOM_CHESTS`). Если есть слот `OPEN` -> клик по слоту для открытия и забора наград.
* Если готовых сундуков нет -> клик по кнопке `FIGHT`. Переход в `IN_GAME`.
2. **IN_GAME (Экран боя):**
* **FORCED DERANK (Сверхбыстрая капитуляция)**:
- Сразу после клика `FIGHT` в лобби запускается высокочастотный конвейер `fast_surrender_pipeline` (опрос ROI каждые 20 мс).
- В первую же миллисекунду после завершения загрузки боя нажимается `Exit` (`btn_exit.png`) через `click_client_pos_fast` (без задержек).
- Мгновенно опрашивается диалог подтверждения `Leave` (`btn_leave.png`) и нажимается подтверждение.
- Полный цикл сдачи занимает **< 0.150.2 сек** от появления экрана боя, исключая случайную победу автобоя.
* **NORMAL PLAY**:
- Ожидание естественного исхода боя (автобой побеждает или терпит поражение).
- Бот не вмешивается и ожидает `VICTORY_SCREEN` или `DEFEAT_SCREEN`.
3. **VICTORY_SCREEN:**
* Зафиксировать: `current_wins += 1`, `total_games += 1`.
* Записать лог: `[WIN] Серия побед: {current_wins}/{max_wins_limit}`.
* Нажать кнопку `Collect` (`btn_collect_victory.png`).
* Если были свободные ячейки (`VACANT`) -> переход в `TOWER_LOBBY`.
* Если свободных ячеек не было -> переход в `NO_FREE_SLOTS_SCREEN`.
4. **DEFEAT_SCREEN:**
* Зафиксировать: `current_wins = 0` (сброс серии), `total_games += 1`.
* Записать лог: `[LOSS] Серия сброшена. Всего игр: {total_games}`.
* Нажать кнопку `Ok` (`btn_ok.png`). Переход в `TOWER_LOBBY`.
5. **CHEST_OPEN_SCREEN (Экран открытия сундуков - нижних и сундука побед с коронами):**
* Срабатывает при клике по готовому сундуку (`OPEN`) или сундуку с коронами (`5/5 👑`).
* Шаг 1: Нажать «Turn all cards over» (`btn_turn_all_cards.png`). Ожидание анимации переворота карт.
* Шаг 2: Нажать «Collect» (`btn_collect.png`). Забор наград и возврат в `TOWER_LOBBY`.
6. **NO_FREE_SLOTS_SCREEN:**
* Появляется после нажатия `Collect` при отсутствии свободных ячеек сундуков ("You have no slot available for this chest").
* Опция `Open chest` (`btn_open_chest.png`) обнаруживается ботом (задел на будущее).
* Нажимается кнопка `Remove chest` (`btn_remove_chest.png`) для сброса лишнего сундука и возврата в `TOWER_LOBBY`.
7. **GLOBAL MODAL / SPECIAL OFFER (Заглушка):**
* Всплывающее рекламное предложение покупки, появляющееся рандомно или после наград.
* Проверяется глобально на каждом шаге цикла FSM (`check_and_dismiss_offer_popup`).
* При обнаружении нажимается кнопка закрытия `X` (`btn_close.png`).
---
## 4. Структура проекта
```text
game_bot/
├── assets/
│ ├── btn_fight.png # Кнопка старта боя в лобби
│ ├── btn_exit.png # Кнопка выхода во время боя
│ ├── btn_leave.png # Кнопка подтверждения выхода (Leave)
│ ├── btn_ok.png # Кнопка OK на экране поражения
│ ├── btn_close.png # Кнопка X закрытия рекламных предложений
│ ├── text_defeat.png # Маркер экрана поражения
│ ├── banner_victory.png # Маркер экрана победы (баннер)
│ ├── text_victory.png # Маркер экрана победы (текст)
│ ├── btn_collect_victory.png # Кнопка Collect на экране победы
│ ├── btn_remove_chest.png # Кнопка Remove chest при отсутствии слотов
│ ├── btn_open_chest.png # Кнопка Open chest при отсутствии слотов
│ ├── status_open.png # Маркер готового к открытию сундука
│ ├── status_vacant.png # Маркер пустого слота сундука
│ ├── btn_turn_all_cards.png# Кнопка открытия всех карт в сундуке
│ └── btn_collect.png # Кнопка сбора наград сундука
├── config.json # Конфигурация лимитов и задержек
├── window_utils.py # Фокусировка и нормализация окна Unity
├── ocr_utils.py # OCR чтение трофеев (рейтинга)
├── bot_core.py # Реализация FSM, логики кликов и проверки заглушек
├── calibrate_tool.py # Утилита для нарезки шаблонов и проверки ROI
└── run.py # Точка входа с Kill Switch
```

21
config.json Normal file
View File

@ -0,0 +1,21 @@
{
"target_window": {
"title": "DirtyLeague",
"process_name": "DirtyLeague.exe"
},
"bot": {
"max_wins_limit": 3,
"poll_interval_sec": 0.8,
"confidence_threshold": 0.8,
"action_delay_sec": 0.5,
"kill_switch_key": "q"
},
"league_retention": {
"upper_trophy_threshold": 2125,
"lower_trophy_threshold": 125,
"points_per_win": 50,
"points_per_loss": 50
},
"assets_dir": "assets",
"log_level": "INFO"
}

159
ocr_utils.py Normal file
View File

@ -0,0 +1,159 @@
"""
OCR utility module using Windows Native OCR (Windows.Media.Ocr via winsdk).
Fast, reliable, offline, and requires no external binaries.
"""
import asyncio
import os
import re
from typing import Optional, Tuple
import cv2
import numpy as np
import winsdk.windows.globalization as glob
import winsdk.windows.graphics.imaging as imaging
import winsdk.windows.media.ocr as win_ocr
import winsdk.windows.storage.streams as streams
class OcrReader:
def __init__(self, lang_tag: str = "en-US"):
self.lang_tag = lang_tag
self._engine = None
def _get_engine(self):
if self._engine is None:
lang = glob.Language(self.lang_tag)
self._engine = win_ocr.OcrEngine.try_create_from_language(lang)
if not self._engine:
self._engine = win_ocr.OcrEngine.try_create_from_user_profile_languages()
return self._engine
async def _recognize_async(self, bgr_image: np.ndarray) -> str:
engine = self._get_engine()
bgra = cv2.cvtColor(bgr_image, cv2.COLOR_BGR2BGRA)
success, encoded = cv2.imencode(".png", bgra)
if not success:
return ""
bytes_data = encoded.tobytes()
stream = streams.InMemoryRandomAccessStream()
writer = streams.DataWriter(stream)
writer.write_bytes(bytes_data)
await writer.store_async()
await writer.flush_async()
stream.seek(0)
decoder = await imaging.BitmapDecoder.create_async(stream)
software_bitmap = await decoder.get_software_bitmap_async()
result = await engine.recognize_async(software_bitmap)
return result.text.strip()
def read_text(self, bgr_image: np.ndarray) -> str:
"""Synchronously recognizes text from a BGR numpy image."""
try:
return asyncio.run(self._recognize_async(bgr_image))
except Exception:
# Fallback for environments with active event loop
loop = asyncio.new_event_loop()
return loop.run_until_complete(self._recognize_async(bgr_image))
def read_trophies(self, frame: np.ndarray) -> Optional[Tuple[int, int]]:
"""
Extracts trophies counter (current, max) from TOWER_LOBBY screen.
Expected format: '2125/2300' -> returns (2125, 2300).
"""
# ROI for trophy numbers above FIGHT button
# y: 540 to 650, x: 3130 to 3500
fh, fw = frame.shape[:2]
if fh < 1000 or fw < 2000:
return None
trophy_crop = frame[540:650, 3130:3500]
text = self.read_text(trophy_crop)
# Clean up text and parse
match = re.search(r"(\d+)\s*[/\\|]\s*(\d+)", text)
if match:
current_val = int(match.group(1))
max_val = int(match.group(2))
return current_val, max_val
return None
def read_crown_chest(self, frame: np.ndarray) -> Optional[Tuple[int, int]]:
"""
Extracts crown chest counter (current, max) from TOWER_LOBBY screen.
Expected format: '3/5' or '5/5' -> returns (current, 5).
"""
fh, fw = frame.shape[:2]
if fh < 1000 or fw < 2000:
return None
# 1. Fast template check for 5/5 (critical trigger for opening crown chest)
digit_roi = frame[1390:1465, 790:850]
tpl_5_path = os.path.join(os.path.dirname(__file__), "assets", "digit_crown_5.png")
if os.path.exists(tpl_5_path):
tpl_5 = cv2.imread(tpl_5_path)
if tpl_5 is not None and digit_roi.shape[0] >= tpl_5.shape[0] and digit_roi.shape[1] >= tpl_5.shape[1]:
res_5 = cv2.matchTemplate(digit_roi, tpl_5, cv2.TM_CCOEFF_NORMED)
_, max_v5, _, _ = cv2.minMaxLoc(res_5)
if max_v5 >= 0.88:
return 5, 5
# 2. Template check for 3/5
tpl_3_path = os.path.join(os.path.dirname(__file__), "assets", "digit_crown_3.png")
if os.path.exists(tpl_3_path):
tpl_3 = cv2.imread(tpl_3_path)
if tpl_3 is not None and digit_roi.shape[0] >= tpl_3.shape[0] and digit_roi.shape[1] >= tpl_3.shape[1]:
res_3 = cv2.matchTemplate(digit_roi, tpl_3, cv2.TM_CCOEFF_NORMED)
_, max_v3, _, _ = cv2.minMaxLoc(res_3)
if max_v3 >= 0.88:
return 3, 5
# 3. Fallback to Windows native OCR
badge_crop = frame[1380:1470, 740:1000]
w = 1000 - 740
text_part = badge_crop[:, :int(w * 0.65)]
up = cv2.resize(text_part, (0, 0), fx=3.0, fy=3.0, interpolation=cv2.INTER_CUBIC)
text = self.read_text(up)
match = re.search(r"(\d+)\s*[/\\|]\s*(\d+)", text)
if match:
current_val = int(match.group(1))
max_val = int(match.group(2))
return current_val, max_val
return None
def read_timer(self, timer_crop: np.ndarray) -> Optional[int]:
"""
Reads timer string (e.g. '51:53' or '1:12' or '01:23:45') and converts to total seconds.
"""
text = self.read_text(timer_crop)
parts = re.findall(r"\d+", text)
if len(parts) == 2:
minutes, seconds = int(parts[0]), int(parts[1])
return minutes * 60 + seconds
elif len(parts) == 3:
hours, minutes, seconds = int(parts[0]), int(parts[1]), int(parts[2])
return hours * 3600 + minutes * 60 + seconds
return None
# Global singleton instance for easy access
ocr_reader = OcrReader()
if __name__ == "__main__":
test_img = cv2.imread("assets/trophy_numbers.png")
text = ocr_reader.read_text(test_img)
print("Raw OCR Text:", text)
full_frame = cv2.imread("assets/trophies_check_snapshot.png")
trophies = ocr_reader.read_trophies(full_frame)
print("Parsed Trophies as integers:", trophies)
if trophies:
current, maximum = trophies
print(f"Current: {current} (type: {type(current)}), Max: {maximum} (type: {type(maximum)})")
print(f"Trophies until goal: {maximum - current}")

8
requirements.txt Normal file
View File

@ -0,0 +1,8 @@
opencv-python>=4.7.0
pillow>=10.0.0
numpy>=1.24.0
pyautogui>=0.9.54
pydirectinput>=1.0.4
pywin32>=306
keyboard>=0.13.5
winsdk>=1.0.0b10

92
run.py Normal file
View File

@ -0,0 +1,92 @@
"""
Entry point for DirtyLeague Bot.
Handles configuration loading, logging initialization, and global Kill Switch.
"""
import json
import logging
import os
import sys
import threading
import time
import keyboard
CONFIG_FILE = "config.json"
def setup_logging(log_level_str: str = "INFO"):
os.makedirs("logs", exist_ok=True)
log_file = os.path.join("logs", f"bot_{time.strftime('%Y%m%d_%H%M%S')}.log")
# Ensure stdout handles unicode properly on Windows
if hasattr(sys.stdout, "reconfigure"):
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
level = getattr(logging, log_level_str.upper(), logging.INFO)
logging.basicConfig(
level=level,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(log_file, encoding="utf-8"),
logging.StreamHandler(sys.stdout)
]
)
logging.info(f"Logging initialized. Log file: {log_file}")
def load_config():
if not os.path.exists(CONFIG_FILE):
raise FileNotFoundError(f"Configuration file '{CONFIG_FILE}' not found.")
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
return json.load(f)
def main():
import argparse
parser = argparse.ArgumentParser(description="DirtyLeague Autonomous FSM Bot")
parser.add_argument("--duration", type=int, default=None, help="Stop after N seconds")
parser.add_argument("--cycles", type=int, default=None, help="Stop after N battles/cycles")
args = parser.parse_args()
config = load_config()
bot_conf = config.get("bot", {})
setup_logging(config.get("log_level", "INFO"))
kill_key = bot_conf.get("kill_switch_key", "q")
logging.info(f"Starting DirtyLeague Bot. Press '{kill_key.upper()}' at ANY time for Emergency Stop (Kill Switch).")
from bot_core import DirtyLeagueBot
bot = DirtyLeagueBot(config=config)
stop_event = threading.Event()
def kill_switch_listener():
while not stop_event.is_set():
if keyboard.is_pressed(kill_key):
logging.warning(f"!!! KILL SWITCH '{kill_key.upper()}' PRESSED! Stopping bot immediately... !!!")
bot.stop()
stop_event.set()
break
time.sleep(0.05)
listener_thread = threading.Thread(target=kill_switch_listener, daemon=True)
listener_thread.start()
try:
bot.start(max_duration_sec=args.duration, max_cycles=args.cycles)
except KeyboardInterrupt:
logging.info("Bot interrupted by User (Ctrl+C).")
except Exception as e:
logging.exception(f"Unexpected error in bot execution: {e}")
finally:
stop_event.set()
bot.stop()
logging.info("Bot stopped successfully.")
if __name__ == "__main__":
main()

20
start_bot.bat Normal file
View File

@ -0,0 +1,20 @@
@echo off
chcp 65001 > nul
echo ========================================================
echo DirtyLeague Autonomous Bot
echo ========================================================
echo.
echo Press 'Q' at ANY time for Emergency Stop (Kill Switch).
echo.
if exist ".venv\Scripts\python.exe" (
".venv\Scripts\python.exe" run.py %*
) else (
python run.py %*
)
if %ERRORLEVEL% neq 0 (
echo.
echo Bot stopped with exit code %ERRORLEVEL%.
pause
)

Some files were not shown because too many files have changed in this diff Show More