feat: implement resolution scaling and relative percentage coordinates

This commit is contained in:
VolandSZ
2026-09-08 22:33:48 +03:00
parent 5777748abe
commit 9b71e20e8f
7 changed files with 360 additions and 166 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@ -1,6 +1,7 @@
"""
Core FSM Bot engine for DirtyLeague.
Manages screen recognition, game state transitions, and automated inputs.
Supports resolution-independent relative coordinates and adaptive template scaling.
"""
from enum import Enum
@ -10,7 +11,6 @@ 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
@ -27,6 +27,25 @@ class GameState(Enum):
class DirtyLeagueBot:
# Reference design resolution from which templates and coordinate ratios were calibrated
BASE_DESIGN_WIDTH = 3840
BASE_DESIGN_HEIGHT = 2050
# Normalized relative ROIs (rx1, ry1, rx2, ry2) spanning 0.0 to 1.0
REL_ROI_EXIT = (0.4167, 0.9268, 0.5000, 0.9854)
REL_ROI_LEAVE = (0.4948, 0.5610, 0.6250, 0.6829)
REL_ROI_FIGHT = (0.7812, 0.3659, 0.9245, 0.4878)
REL_ROI_DEFEAT_OK = (0.2344, 0.7220, 0.3776, 0.8293)
REL_ROI_DEFEAT_TEXT = (0.1302, 0.6829, 0.3125, 0.8293)
REL_ROI_VICTORY_COLLECT = (0.1953, 0.7561, 0.3125, 0.8537)
REL_ROI_VICTORY_BANNER = (0.1042, 0.1463, 0.4167, 0.3415)
REL_ROI_NO_SLOTS_REMOVE = (0.3516, 0.8195, 0.5078, 0.9024)
REL_ROI_NO_SLOTS_OPEN = (0.4818, 0.8195, 0.6120, 0.9024)
REL_ROI_CHEST_TURN_CARDS = (0.4036, 0.6585, 0.5859, 0.7561)
REL_ROI_CHEST_COLLECT = (0.4036, 0.6585, 0.5859, 0.7561)
REL_ROI_OFFER_CLOSE = (0.8854, 0.0, 1.0, 0.1463)
REL_CROWN_CHEST_POS = (0.2279, 0.6146)
def __init__(self, config: dict):
self.config = config
self.bot_conf = config.get("bot", {})
@ -56,11 +75,20 @@ class DirtyLeagueBot:
window_title=self.target_conf.get("title", "DirtyLeague"),
process_name=self.target_conf.get("process_name", "DirtyLeague.exe")
)
self.templates: Dict[str, np.ndarray] = {}
# Template storage with dynamic scaling cache
self.base_templates: Dict[str, np.ndarray] = {}
self.scaled_templates: Dict[str, np.ndarray] = {}
self._current_scale: float = 1.0
self._load_templates()
@property
def templates(self) -> Dict[str, np.ndarray]:
"""Provides access to templates at the current resolution scale."""
return self.scaled_templates if self.scaled_templates else self.base_templates
def _load_templates(self):
"""Preloads all PNG template images from the assets directory."""
"""Preloads all base 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):
@ -69,27 +97,52 @@ class DirtyLeagueBot:
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())}")
self.base_templates[name_without_ext] = img
self.scaled_templates[name_without_ext] = img
logging.info(f"Loaded {len(self.base_templates)} base template(s) from '{self.assets_dir}'")
def find_template(self, frame: np.ndarray, template_name: str, threshold: Optional[float] = None) -> Optional[Tuple[int, int]]:
def get_scaled_template(self, template_name: str, scale: float) -> Optional[np.ndarray]:
"""
Searches for template_name in frame.
Returns client center coordinates (center_x, center_y) if matched above threshold, else None.
Retrieves template scaled for the given resolution scale factor (fw / BASE_DESIGN_WIDTH).
Caches scaled results so resizing only happens on window resolution changes.
"""
if template_name not in self.templates:
# Try reloading in case new template was added
if template_name not in self.base_templates:
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:
self.base_templates[template_name] = img
if template_name not in self.base_templates:
return None
tpl = self.templates[template_name]
th, tw = tpl.shape[:2]
# If resolution scale changed, rebuild scaled template cache
if abs(scale - self._current_scale) > 0.02:
self._current_scale = scale
self.scaled_templates.clear()
interp = cv2.INTER_AREA if scale < 1.0 else cv2.INTER_CUBIC
for name, base_img in self.base_templates.items():
if abs(scale - 1.0) <= 0.02:
self.scaled_templates[name] = base_img
else:
self.scaled_templates[name] = cv2.resize(
base_img, (0, 0), fx=scale, fy=scale, interpolation=interp
)
return self.scaled_templates.get(template_name)
def find_template(self, frame: np.ndarray, template_name: str, threshold: Optional[float] = None) -> Optional[Tuple[int, int]]:
"""
Full-frame search for template_name in frame.
Automatically scales template to match the current frame scale.
Returns client center coordinates (center_x, center_y) if matched, else None.
"""
fh, fw = frame.shape[:2]
scale = fw / float(self.BASE_DESIGN_WIDTH)
tpl = self.get_scaled_template(template_name, scale)
if tpl is None:
return None
th, tw = tpl.shape[:2]
if th > fh or tw > fw:
return None
@ -105,28 +158,32 @@ class DirtyLeagueBot:
return None
def find_template_in_roi(
def find_template_in_rel_roi(
self,
frame: np.ndarray,
template_name: str,
roi: Tuple[int, int, int, int],
rel_roi: Tuple[float, float, float, float],
threshold: Optional[float] = None
) -> Optional[Tuple[int, int]]:
"""
Fast template search restricted to ROI: (x1, y1, x2, y2).
Fast template search restricted to normalized relative ROI: (rx1, ry1, rx2, ry2).
Returns client center coordinates (cx, cy) if matched, else None.
Runs in ~2-5ms instead of ~420ms for full-frame search.
Automatically scales template to match current frame resolution. Runs in ~2-5ms!
"""
if template_name not in self.templates:
return None
tpl = self.templates[template_name]
th, tw = tpl.shape[:2]
fh, fw = frame.shape[:2]
rx1, ry1, rx2, ry2 = rel_roi
x1 = max(0, min(fw, int(rx1 * fw)))
y1 = max(0, min(fh, int(ry1 * fh)))
x2 = max(0, min(fw, int(rx2 * fw)))
y2 = max(0, min(fh, int(ry2 * fh)))
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:
scale = fw / float(self.BASE_DESIGN_WIDTH)
tpl = self.get_scaled_template(template_name, scale)
if tpl is None:
return None
th, tw = tpl.shape[:2]
if (x2 - x1) < tw or (y2 - y1) < th:
return None
crop = frame[y1:y2, x1:x2]
@ -140,6 +197,20 @@ class DirtyLeagueBot:
return cx, cy
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]]:
"""Backward-compatible pixel ROI search: converts pixel ROI to relative ROI."""
rx1 = roi[0] / float(self.BASE_DESIGN_WIDTH)
ry1 = roi[1] / float(self.BASE_DESIGN_HEIGHT)
rx2 = roi[2] / float(self.BASE_DESIGN_WIDTH)
ry2 = roi[3] / float(self.BASE_DESIGN_HEIGHT)
return self.find_template_in_rel_roi(frame, template_name, (rx1, ry1, rx2, ry2), threshold)
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)
@ -175,29 +246,29 @@ class DirtyLeagueBot:
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)):
"""Determines current game screen based on visible templates (accelerated with relative ROIs)."""
# 1. Ultra-fast relative ROI checks (~3-5ms each on any resolution)
if self.find_template_in_rel_roi(frame, "btn_remove_chest", self.REL_ROI_NO_SLOTS_REMOVE) or \
self.find_template_in_rel_roi(frame, "btn_open_chest", self.REL_ROI_NO_SLOTS_OPEN):
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):
if self.find_template_in_rel_roi(frame, "btn_ok", self.REL_ROI_DEFEAT_OK) or \
self.find_template_in_rel_roi(frame, "text_defeat", self.REL_ROI_DEFEAT_TEXT, 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)):
if self.find_template_in_rel_roi(frame, "btn_exit", self.REL_ROI_EXIT) or \
self.find_template_in_rel_roi(frame, "btn_leave", self.REL_ROI_LEAVE):
return GameState.IN_GAME
if self.find_template_in_roi(frame, "btn_fight", (3000, 750, 3550, 1000)):
if self.find_template_in_rel_roi(frame, "btn_fight", self.REL_ROI_FIGHT):
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):
if self.find_template_in_rel_roi(frame, "btn_turn_all_cards", self.REL_ROI_CHEST_TURN_CARDS) or \
self.find_template_in_rel_roi(frame, "btn_collect", self.REL_ROI_CHEST_COLLECT, 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):
if self.find_template_in_rel_roi(frame, "btn_collect_victory", self.REL_ROI_VICTORY_COLLECT) or \
self.find_template_in_rel_roi(frame, "banner_victory", self.REL_ROI_VICTORY_BANNER, threshold=0.65):
return GameState.VICTORY_SCREEN
# 2. Full-frame fallback checks if ROI didn't trigger:
@ -220,95 +291,84 @@ class DirtyLeagueBot:
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)}
Scans chest slots 1..4 in the bottom bar of TOWER_LOBBY using relative coordinates.
Supports any resolution dynamically.
"""
results = {}
slot_pitch = 213
base_x = 525
y = 1850
w = 210
h = 70
fh, fw = frame.shape[:2]
rel_base_x = 0.1367
rel_pitch_x = 0.0555
rel_y = 0.9024
rel_w = 0.0547
rel_h = 0.0341
scale = fw / float(self.BASE_DESIGN_WIDTH)
tpl_open = self.get_scaled_template("status_open", scale)
tpl_vacant = self.get_scaled_template("status_vacant", scale)
results = {}
for i in range(4):
slot_id = i + 1
sx = base_x + i * slot_pitch
status_crop = frame[y:y + h, sx:sx + w]
sx = int((rel_base_x + i * rel_pitch_x) * fw)
sy = int(rel_y * fh)
sw = int(rel_w * fw)
sh = int(rel_h * fh)
status_crop = frame[sy:sy + sh, sx:sx + sw]
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
status = "TIMER"
if tpl_open is not None and status_crop.shape[0] >= tpl_open.shape[0] and status_crop.shape[1] >= tpl_open.shape[1]:
res_open = cv2.matchTemplate(status_crop, tpl_open, cv2.TM_CCOEFF_NORMED)
_, max_v_open, _, _ = cv2.minMaxLoc(res_open)
if max_v_open >= 0.75:
status = "OPEN"
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"
if status != "OPEN" and tpl_vacant is not None and status_crop.shape[0] >= tpl_vacant.shape[0] and status_crop.shape[1] >= tpl_vacant.shape[1]:
res_vacant = cv2.matchTemplate(status_crop, tpl_vacant, cv2.TM_CCOEFF_NORMED)
_, max_v_vacant, _, _ = cv2.minMaxLoc(res_vacant)
if max_v_vacant >= 0.75:
status = "VACANT"
click_cx = sx + sw // 2
click_cy = sy - int(0.035 * fh)
results[slot_id] = {
"status": status,
"click_pos": (sx + w // 2, y - 70)
"click_pos": (click_cx, click_cy)
}
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.
Detects and dismisses promotional or purchase offer popups ('X' close button).
Uses relative ROI for the top-right corner across all resolutions.
"""
# 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)
pos = self.find_template_in_rel_roi(frame, tpl_name, self.REL_ROI_OFFER_CLOSE, threshold=0.65)
if pos:
logging.info(f"[OFFER POPUP] Detected close button '{tpl_name}' at client {pos}. Dismissing...")
self.click_client_pos(pos[0], pos[1])
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"):
turn_pos = self.find_template_in_rel_roi(frame, "btn_turn_all_cards", self.REL_ROI_CHEST_TURN_CARDS) or \
self.find_template(frame, "btn_turn_all_cards")
if turn_pos:
self.click_client_pos(turn_pos[0], turn_pos[1])
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"):
collect_pos = self.find_template_in_rel_roi(frame, "btn_collect", self.REL_ROI_CHEST_COLLECT) or \
self.find_template(frame, "btn_collect")
if collect_pos:
self.click_client_pos(collect_pos[0], collect_pos[1])
logging.info("Clicked 'Collect'. Rewards claimed, returning to lobby...")
time.sleep(1.5)
# Step 3: Check for promotional offer popup stub
# Check for promotional offer popup stub
after_frame = self.wm.capture_frame(client_only=True)
self.check_and_dismiss_offer_popup(after_frame)
@ -336,52 +396,15 @@ class DirtyLeagueBot:
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.
Polls Exit button relative ROI (~20ms per check). The millisecond battle finishes loading,
clicks Exit via fast click, then immediately polls Leave dialog relative 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
@ -393,14 +416,14 @@ class DirtyLeagueBot:
time.sleep(0.02)
continue
exit_pos = self.find_template_in_roi(frame, "btn_exit", exit_roi, threshold=0.75)
exit_pos = self.find_template_in_rel_roi(frame, "btn_exit", self.REL_ROI_EXIT, 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)
leave_pos = self.find_template_in_rel_roi(frame, "btn_leave", self.REL_ROI_LEAVE, 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.")
@ -422,7 +445,7 @@ class DirtyLeagueBot:
time.sleep(0.02)
continue
leave_pos = self.find_template_in_roi(frame, "btn_leave", leave_roi, threshold=0.75)
leave_pos = self.find_template_in_rel_roi(frame, "btn_leave", self.REL_ROI_LEAVE, 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.")
@ -437,6 +460,8 @@ class DirtyLeagueBot:
def handle_lobby(self, frame: np.ndarray):
logging.info("State: TOWER_LOBBY.")
self._result_recorded_for_match = False
fh, fw = frame.shape[:2]
# Read trophies counter above FIGHT button
trophies = ocr_reader.read_trophies(frame)
if trophies:
@ -453,8 +478,10 @@ class DirtyLeagueBot:
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)
logging.info(f"[CROWN CHEST] Goal reached ({c_current}/{c_max})! Clicking crown chest...")
cx = int(self.REL_CROWN_CHEST_POS[0] * fw)
cy = int(self.REL_CROWN_CHEST_POS[1] * fh)
self.click_client_pos(cx, cy)
time.sleep(1.5)
return
@ -471,7 +498,7 @@ class DirtyLeagueBot:
return
logging.info("Searching for 'btn_fight' / 'btn_play'...")
fight_pos = self.find_template_in_roi(frame, "btn_fight", (3000, 750, 3550, 1000)) or \
fight_pos = self.find_template_in_rel_roi(frame, "btn_fight", self.REL_ROI_FIGHT) or \
self.find_template(frame, "btn_fight") or \
self.find_template(frame, "btn_play")
if fight_pos:
@ -499,7 +526,12 @@ class DirtyLeagueBot:
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"):
collect_pos = self.find_template_in_rel_roi(frame, "btn_collect_victory", self.REL_ROI_VICTORY_COLLECT) or \
self.find_template(frame, "btn_collect_victory")
if collect_pos:
self.click_client_pos(collect_pos[0], collect_pos[1])
else:
self.click_template(frame, "btn_continue")
time.sleep(1.0)
@ -509,17 +541,26 @@ class DirtyLeagueBot:
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"):
ok_pos = self.find_template_in_rel_roi(frame, "btn_ok", self.REL_ROI_DEFEAT_OK) or \
self.find_template(frame, "btn_ok")
if ok_pos:
self.click_client_pos(ok_pos[0], ok_pos[1])
else:
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")
open_pos = self.find_template_in_rel_roi(frame, "btn_open_chest", self.REL_ROI_NO_SLOTS_OPEN) or \
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"):
remove_pos = self.find_template_in_rel_roi(frame, "btn_remove_chest", self.REL_ROI_NO_SLOTS_REMOVE) or \
self.find_template(frame, "btn_remove_chest")
if remove_pos:
self.click_client_pos(remove_pos[0], remove_pos[1])
logging.info("Clicked 'Remove chest' to discard unslotted chest. Returning to lobby...")
time.sleep(1.0)

View File

@ -62,14 +62,19 @@ class OcrReader:
"""
Extracts trophies counter (current, max) from TOWER_LOBBY screen.
Expected format: '2125/2300' -> returns (2125, 2300).
Uses relative coordinates (0.2634..0.3171 y, 0.8151..0.9115 x) to support any resolution.
"""
# 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:
if fh < 300 or fw < 500:
return None
trophy_crop = frame[540:650, 3130:3500]
y1, y2 = int(fh * 0.2634), int(fh * 0.3171)
x1, x2 = int(fw * 0.8151), int(fw * 0.9115)
trophy_crop = frame[y1:y2, x1:x2]
if trophy_crop.shape[0] < 80:
trophy_crop = cv2.resize(trophy_crop, (0, 0), fx=2.0, fy=2.0, interpolation=cv2.INTER_CUBIC)
text = self.read_text(trophy_crop)
# Clean up text and parse
@ -85,35 +90,54 @@ class OcrReader:
"""
Extracts crown chest counter (current, max) from TOWER_LOBBY screen.
Expected format: '3/5' or '5/5' -> returns (current, 5).
Uses relative coordinates and dynamically scaled templates to support any resolution.
"""
fh, fw = frame.shape[:2]
if fh < 1000 or fw < 2000:
if fh < 300 or fw < 500:
return None
scale = fw / 3840.0
# Relative ROI for crown digit badge
y1, y2 = int(fh * 0.6780), int(fh * 0.7146)
x1, x2 = int(fw * 0.2057), int(fw * 0.2214)
digit_roi = frame[y1:y2, x1:x2]
assets_dir = os.path.join(os.path.dirname(__file__), "assets")
# 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")
tpl_5_path = os.path.join(assets_dir, "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
if tpl_5 is not None:
if abs(scale - 1.0) > 0.02:
interp = cv2.INTER_AREA if scale < 1.0 else cv2.INTER_CUBIC
tpl_5 = cv2.resize(tpl_5, (0, 0), fx=scale, fy=scale, interpolation=interp)
if 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.85:
return 5, 5
# 2. Template check for 3/5
tpl_3_path = os.path.join(os.path.dirname(__file__), "assets", "digit_crown_3.png")
tpl_3_path = os.path.join(assets_dir, "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
if tpl_3 is not None:
if abs(scale - 1.0) > 0.02:
interp = cv2.INTER_AREA if scale < 1.0 else cv2.INTER_CUBIC
tpl_3 = cv2.resize(tpl_3, (0, 0), fx=scale, fy=scale, interpolation=interp)
if 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.85:
return 3, 5
# 3. Fallback to Windows native OCR
badge_crop = frame[1380:1470, 740:1000]
w = 1000 - 740
by1, by2 = int(fh * 0.6732), int(fh * 0.7171)
bx1, bx2 = int(fw * 0.1927), int(fw * 0.2604)
badge_crop = frame[by1:by2, bx1:bx2]
w = badge_crop.shape[1]
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)

114
test_resolution_scaling.py Normal file
View File

@ -0,0 +1,114 @@
"""
Automated unit & integration test for Resolution Scaling and Relative Coordinates.
Tests that detect_state, OCR, and chest scanning correctly recognize all screens
at 4K, 1440p, 1080p, and 720p resolutions.
"""
import json
import os
import cv2
from bot_core import DirtyLeagueBot, GameState
import ocr_utils
CONFIG_PATH = "config.json"
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
config = json.load(f)
bot = DirtyLeagueBot(config)
SNAPSHOTS = {
"IN_GAME (Battle)": ("assets/battle_screen_snapshot.png", GameState.IN_GAME),
"IN_GAME (Leave)": ("assets/leave_screen_snapshot.png", GameState.IN_GAME),
"TOWER_LOBBY": ("assets/tower_screen_snapshot.png", GameState.TOWER_LOBBY),
"DEFEAT_SCREEN": ("assets/defeat_screen_snapshot.png", GameState.DEFEAT_SCREEN),
"VICTORY_SCREEN": ("assets/victory_screen_snapshot.png", GameState.VICTORY_SCREEN),
"NO_FREE_SLOTS": ("assets/no_free_slots_snapshot.png", GameState.NO_FREE_SLOTS_SCREEN),
"CHEST_OPEN": ("assets/chest_reward_snapshot.png", GameState.CHEST_OPEN_SCREEN),
}
RESOLUTIONS = [
("Native 4K", 3840, 2050),
("2K / 1440p", 2560, 1366),
("FullHD / 1080p", 1920, 1025),
("HD / 720p", 1280, 683),
]
def run_tests():
print("=" * 70)
print(" TESTING RESOLUTION INDEPENDENCE AND RELATIVE COORDINATES ")
print("=" * 70)
total_passed = 0
total_tests = 0
for res_name, target_w, target_h in RESOLUTIONS:
print(f"\n--- Testing at resolution: {res_name} ({target_w}x{target_h}) ---")
for screen_name, (path, expected_state) in SNAPSHOTS.items():
if not os.path.exists(path):
print(f"[SKIP] Snapshot not found: {path}")
continue
orig_img = cv2.imread(path)
if orig_img.shape[1] == target_w and orig_img.shape[0] == target_h:
tested_frame = orig_img
else:
tested_frame = cv2.resize(orig_img, (target_w, target_h), interpolation=cv2.INTER_AREA)
detected = bot.detect_state(tested_frame)
is_ok = (detected == expected_state)
status_str = "PASS" if is_ok else "FAIL"
print(f" [{status_str}] {screen_name:18} -> detected: {detected.value:20} (expected: {expected_state.value})")
total_tests += 1
if is_ok:
total_passed += 1
# Test OCR across resolutions
print("\n--- Testing OCR Scaling ---")
tower_4k = cv2.imread("assets/tower_screen_snapshot.png")
for res_name, target_w, target_h in RESOLUTIONS:
frame = cv2.resize(tower_4k, (target_w, target_h), interpolation=cv2.INTER_AREA)
trophies = ocr_utils.ocr_reader.read_trophies(frame)
is_ok = (trophies == (1925, 2300))
status_str = "PASS" if is_ok else "FAIL"
print(f" [{status_str}] {res_name:14} Trophies OCR: {trophies} (expected: (1925, 2300))")
total_tests += 1
if is_ok:
total_passed += 1
# Test Crown Chest Badge (5/5) across resolutions
print("\n--- Testing Crown Chest 5/5 Badge Scaling ---")
crown_4k = cv2.imread("assets/tower_lobby_crown_filled.png")
for res_name, target_w, target_h in RESOLUTIONS:
frame = cv2.resize(crown_4k, (target_w, target_h), interpolation=cv2.INTER_AREA)
crown = ocr_utils.ocr_reader.read_crown_chest(frame)
is_ok = (crown == (5, 5))
status_str = "PASS" if is_ok else "FAIL"
print(f" [{status_str}] {res_name:14} Crown 5/5 Badge: {crown} (expected: (5, 5))")
total_tests += 1
if is_ok:
total_passed += 1
# Test Bottom Chests Grid across resolutions
print("\n--- Testing Bottom Chests Grid Scanning ---")
for res_name, target_w, target_h in RESOLUTIONS:
frame = cv2.resize(tower_4k, (target_w, target_h), interpolation=cv2.INTER_AREA)
chests = bot.scan_bottom_chests(frame)
statuses = {k: v["status"] for k, v in chests.items()}
# Slots 1 and 3 are OPEN on tower_screen_snapshot
is_ok = (statuses[1] == "OPEN" and statuses[3] == "OPEN")
status_str = "PASS" if is_ok else "FAIL"
print(f" [{status_str}] {res_name:14} Chests: {statuses}")
total_tests += 1
if is_ok:
total_passed += 1
print("\n" + "=" * 70)
print(f" TOTAL RESULT: {total_passed} / {total_tests} tests passed ({total_passed / total_tests * 100:.1f}%)")
print("=" * 70)
assert total_passed == total_tests, f"Only {total_passed}/{total_tests} tests passed!"
if __name__ == "__main__":
run_tests()

View File

@ -193,6 +193,21 @@ class WindowManager:
c_left, c_top, _, _ = self.get_client_rect()
return c_left + x, c_top + y
def rel_to_client(self, rx: float, ry: float) -> Tuple[int, int]:
"""Translates normalized relative coordinates (0.0..1.0) to client pixel coordinates (x, y)."""
_, _, width, height = self.get_client_rect()
return int(rx * width), int(ry * height)
def rel_to_client_rect(self, rx1: float, ry1: float, rx2: float, ry2: float) -> Tuple[int, int, int, int]:
"""Translates normalized relative ROI (rx1, ry1, rx2, ry2) to client pixel rect (x1, y1, x2, y2)."""
_, _, width, height = self.get_client_rect()
return int(rx1 * width), int(ry1 * height), int(rx2 * width), int(ry2 * height)
def rel_to_screen(self, rx: float, ry: float) -> Tuple[int, int]:
"""Translates normalized relative coordinates (0.0..1.0) directly to absolute screen coordinates."""
cx, cy = self.rel_to_client(rx, ry)
return self.client_to_screen(cx, cy)
if __name__ == "__main__":
wm = WindowManager()