feat: complete DirtyLeague autonomous FSM bot with fast derank and chest handling
21
.gitignore
vendored
Normal 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
@ -0,0 +1 @@
|
||||
# Assets directory for template images
|
||||
BIN
assets/anchor_dungeons.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
assets/anchor_main_menu.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
assets/anchor_tower_league.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/badge_crown_5_5.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
assets/badge_exclamation.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
assets/banner_defeat.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
BIN
assets/banner_victory.png
Normal file
|
After Width: | Height: | Size: 306 KiB |
BIN
assets/battle_screen_snapshot.png
Normal file
|
After Width: | Height: | Size: 6.1 MiB |
BIN
assets/btn_auto.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
assets/btn_back.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
assets/btn_close.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
assets/btn_close_offer.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
assets/btn_collect.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/btn_collect_text.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
assets/btn_collect_victory.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
assets/btn_exit.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
assets/btn_fight.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
assets/btn_leave.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
assets/btn_mode_tower.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
assets/btn_ok.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
assets/btn_open_chest.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
assets/btn_remove_chest.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
assets/btn_turn_all_cards.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
assets/btn_turn_all_cards_text.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/char_0.png
Normal file
|
After Width: | Height: | Size: 197 B |
BIN
assets/char_1.png
Normal file
|
After Width: | Height: | Size: 146 B |
BIN
assets/char_2.png
Normal file
|
After Width: | Height: | Size: 199 B |
BIN
assets/char_3.png
Normal file
|
After Width: | Height: | Size: 182 B |
BIN
assets/char_4.png
Normal file
|
After Width: | Height: | Size: 224 B |
BIN
assets/char_5.png
Normal file
|
After Width: | Height: | Size: 199 B |
BIN
assets/char_6.png
Normal file
|
After Width: | Height: | Size: 193 B |
BIN
assets/char_7.png
Normal file
|
After Width: | Height: | Size: 232 B |
BIN
assets/char_8.png
Normal file
|
After Width: | Height: | Size: 239 B |
BIN
assets/chars_annotated.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
assets/chest_collect_snapshot.png
Normal file
|
After Width: | Height: | Size: 4.9 MiB |
BIN
assets/chest_reward_snapshot.png
Normal file
|
After Width: | Height: | Size: 4.7 MiB |
BIN
assets/crown_5_5_up.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
assets/crown_badge_area.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
assets/crown_badge_exact.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
assets/crown_badge_filled_area.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
assets/crown_bin.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
assets/crown_chest_area.png
Normal file
|
After Width: | Height: | Size: 337 KiB |
BIN
assets/crown_chest_filled_area.png
Normal file
|
After Width: | Height: | Size: 403 KiB |
BIN
assets/crown_chest_open_snapshot.png
Normal file
|
After Width: | Height: | Size: 4.7 MiB |
BIN
assets/crown_inv.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
assets/crown_padded.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
assets/crown_text_part.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
assets/crown_thresh.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
assets/crown_up.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
assets/defeat_buttons_crop.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
assets/defeat_screen_snapshot.png
Normal file
|
After Width: | Height: | Size: 4.0 MiB |
BIN
assets/detected_close.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
assets/digit_crown_3.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
assets/digit_crown_5.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
assets/leave_screen_snapshot.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
assets/main_menu_calibrated.png
Normal file
|
After Width: | Height: | Size: 4.7 MiB |
BIN
assets/no_free_slots_buttons_crop.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
assets/no_free_slots_buttons_crop2.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
assets/no_free_slots_snapshot.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
assets/offer_screen_snapshot.png
Normal file
|
After Width: | Height: | Size: 5.4 MiB |
BIN
assets/region_full_btn.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
assets/slot_1.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
assets/slot_1_status.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/slot_1_vacant.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
assets/slot_2.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
assets/slot_2_status.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
assets/slot_3.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
assets/slot_3_status.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/slot_4.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
assets/slot_4_status.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
assets/slot_5.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
assets/slot_5_status.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/status_open.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
assets/status_vacant.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
assets/text_crown_5_5.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
assets/text_defeat.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
assets/text_no_slot_available.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
assets/text_victory.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
assets/tile_tower_label.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
assets/title_special_offer.png
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
assets/tower_lobby_after_chest.png
Normal file
|
After Width: | Height: | Size: 6.1 MiB |
BIN
assets/tower_lobby_crown_check.png
Normal file
|
After Width: | Height: | Size: 6.1 MiB |
BIN
assets/tower_lobby_crown_filled.png
Normal file
|
After Width: | Height: | Size: 6.2 MiB |
BIN
assets/tower_screen_snapshot.png
Normal file
|
After Width: | Height: | Size: 6.1 MiB |
BIN
assets/trophies_check_snapshot.png
Normal file
|
After Width: | Height: | Size: 6.3 MiB |
BIN
assets/trophy_area.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
assets/trophy_numbers.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
assets/trophy_thresh.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
assets/victory_collect_test.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
assets/victory_screen_snapshot.png
Normal file
|
After Width: | Height: | Size: 4.4 MiB |
588
bot_core.py
Normal 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
@ -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
@ -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.5–1.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.15–0.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
@ -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
@ -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
@ -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
@ -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
@ -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
|
||||
)
|
||||