Blue Archive bot
import tkinter as tk
from PIL import Image
import json
from subprocess import Popen, PIPE, STDOUT
import threading
import customtkinter
import sys
from gui import *
import random
import ctypes
import re

class Config:
    def __init__(self, linker, config_file):
        self.linker = linker
        self.config_file = config_file
        self.config_data = self.read()
        self.linker.widgets = self.set_values_to_none(self.config_data)
        linker.config = self

    def read(self):
        # Read the JSON file
        try:
            with open(self.config_file, 'r') as json_file:
                config_data = json.load(json_file)
            return config_data
        except FileNotFoundError:
            print(f"Config file '{self.config_file}' not found.")
            sys.exit(1)
        except json.JSONDecodeError:
            print(f"Invalid JSON format in '{self.config_file}'.")
            sys.exit(1)

    def set_values_to_none(self, input_dict):
        result = {}
        for key, value in input_dict.items():
            if isinstance(value, dict):
                result[key] = self.set_values_to_none(value)
            else:
                result[key] = None
        return result
    
    def load_config(self, widgets=None, config_data=None):
        if widgets == None:
            widgets = self.linker.widgets
            config_data = self.config_data
        for key in widgets:
            if isinstance(widgets[key], dict) and isinstance(config_data[key], dict):
                self.load_config(widgets[key], config_data[key])
            else:
                if widgets[key] is not None:
                    if isinstance(widgets[key], customtkinter.CTkCheckBox):
                        if config_data[key] == True:
                            widgets[key].select()
                        else:
                            widgets[key].deselect()
                    elif isinstance(widgets[key], customtkinter.CTkEntry):
                        widgets[key].insert(0, config_data[key])
                    else:                    
                        widgets[key].set(config_data[key])

    def save_to_json(self, list_keys):
        widget = self.linker.widgets
        data = self.config_data
        for i in list_keys[:-1]:
            widget = widget[i]
            data = data[i]
        widget = widget[list_keys[-1]] 
        value = widget.get()
        if isinstance(widget, customtkinter.CTkCheckBox):
            value = True if value==1 else False
        data[list_keys[-1]] = value
        self.save_file("Configuration")

    def save_file(self, name=None):
        with open("config.json", "w") as config_file:
            json.dump(self.config_data, config_file, indent=2)
        if name:    
            self.linker.show_notification(name)
    
class Linker:
    def __init__(self):
        self.capitalise = lambda word: " ".join(x.title() for x in word.split("_"))        
        self.config = None
        self.widgets = {}
        # frames
        self.sidebar = None
        self.modules_dictionary = {}
        self.logger = None
        # script.py process
        self.script = None
        self.name_to_sidebar_frame = {
            "Template": None,
            "Queue": None,
            "Configuration":None
        }

    def switch_student_list(self):
        server = self.config.config_data["login"]["server"]
        with open(f"gui/student_list/{server}.json", "r") as f:
            student_list = json.load(f)
        cafe_frame = self.modules_dictionary["cafe"]["frame"]
        cafe_frame.student_dropdown.configure(values=student_list)
        
    def start_stop(self):
        if hasattr(self, 'script') and self.script is not None:
            # If process is running, terminate it
            self.script.terminate()
            self.script = None
            self.sidebar.start_button.configure(text="Start", fg_color = ['#3B8ED0', '#1F6AA5'])
            self.switch_queue_state("normal")

        else:
            # If process is not running, start it
            self.script = Popen(['python', 'script.py'], stdout=PIPE, stderr=STDOUT)
            threading.Thread(target=self.read_output).start()
            self.sidebar.start_button.configure(text="Stop", fg_color = "crimson")
            self.switch_queue_state("disabled")

    def read_output(self):
        for line in iter(self.script.stdout.readline, b''):
            line = line.decode("utf-8")  # Decode the bytes to a string
            # Check if line contains any log level
            for level, color in self.logger.log_level_colors.items():
                if level in line:
                    # Display output in text box with color
                    self.logger.log_textbox.configure(state="normal")           
                    self.logger.log_textbox.insert("end", line, level)
                    self.logger.log_textbox.configure(state="disabled")
                    break
            if self.logger.autoscroll_enabled:
                self.logger.log_textbox.yview_moveto(1.0)
        # If process ends, change button text to 'Start'
        self.sidebar.start_button.configure(text="Start", fg_color = ['#3B8ED0', '#1F6AA5'])
        self.switch_queue_state("normal")
        self.script = None

    def switch_queue_state(self, state):
        farming_frame = self.modules_dictionary["farming"]["frame"]
        for button in farming_frame.queue_buttons:
            button.configure(state=state)
        self.update_queue()
        for frame in farming_frame.queue_frames:
            for widget in frame.winfo_children():
                widget.configure(state=state)

    def update_queue(self):
        farming_frame = self.modules_dictionary["farming"]["frame"]
        farming_frame.clear_frames(queue=True)
        new_config_data = self.config.read()
        self.config.config_data["farming"]['mission']['queue'] = new_config_data["farming"]['mission']['queue']
        for entry in self.config.config_data["farming"]['mission']['queue']:
            farming_frame.add_frame(entry, queue=True)

    def show_notification(self, name):
        sidebar_frame = self.name_to_sidebar_frame[name]
        if self.script:
            new_notification = Notification(text= f"{name} was saved but will be read by the script in the next run.", master=sidebar_frame, fg_color="orange")
        else:
            new_notification = Notification(text= f"{name} was saved successfully.", master=sidebar_frame, fg_color="green")
        new_notification.grid(row=0, column=0, sticky="nsew")
        self.sidebar.master.after(2500, new_notification.destroy)
    
class Sidebar(customtkinter.CTkFrame):
    def __init__(self, master, linker, config, **kwargs):
        self.master = master
        self.linker = linker
        self.config = config
        super().__init__(master=self.master, **kwargs)
        self.grid_rowconfigure((0, 1, 2), weight=1)
        self.grid_columnconfigure(0, weight=1)
        karin_logo = customtkinter.CTkImage(light_image=Image.open("gui/icons/karin.png"), size=(152,152))
        karin_logo_label = customtkinter.CTkLabel(self, image=karin_logo, text="")
        karin_logo_label.grid(row=0, column=0, sticky="nsew")
        self.gear_on = customtkinter.CTkImage(Image.open("gui/icons/gear_on.png"), size=(50,38))
        self.gear_off = customtkinter.CTkImage(Image.open("gui/icons/gear_off.png"), size=(50,38))
        self.create_module_frames()
        self.create_all_button_frame()
        self.create_start_button()
        self.create_notification_frames()
        self.linker.sidebar = self
        
    def create_module_frames(self):

        self.checkbox_frame = customtkinter.CTkFrame(self, fg_color="transparent", border_color="white", border_width=2)
        self.checkbox_frame.grid(row=1, column=0, columnspan=4, padx=10, pady=10, sticky="w")

        self.module_list = [["login", LoginFrame], ["cafe", CafeFrame], ["farming", FarmingFrame], ["claim_rewards", ClaimRewardsFrame]]               
        for index, sublist in enumerate(self.module_list):
            module = sublist[0]
            self.linker.modules_dictionary[module] = {}
            self.create_module_checkbox(module, index)
            self.create_module_button(module, index)
            frame = sublist[1](self.master, self.linker, self.config, fg_color="#262250") 
            self.linker.modules_dictionary[module]['frame'] = frame
        self.linker.modules_dictionary["login"]["button"].configure(image=self.gear_on)
        self.linker.modules_dictionary["login"]["checkbox"].configure(text_color="#53B9E9")   
        self.current_frame = self.linker.modules_dictionary["login"]["frame"]  # Update the current frame
        self.current_frame.grid(row=0, column=1, padx=20, pady=20, sticky="nsew")

    def create_module_checkbox(self, module, i):
        self.linker.modules_dictionary[module]['checkbox'] = customtkinter.CTkCheckBox(
            self.checkbox_frame, text=self.linker.capitalise(module), text_color="#FFFFFF", font=("Inter", 16), command=lambda x=[module, "enabled"]: self.config.save_to_json(x))
        self.linker.modules_dictionary[module]['checkbox'].grid(row=i, column=0, columnspan=2,padx=20, pady=(10, 5), sticky="nw")
        self.linker.widgets[module]['enabled'] = self.linker.modules_dictionary[module]['checkbox']

    def create_module_button(self, module, i):
        self.linker.modules_dictionary[module]['button'] = customtkinter.CTkButton(
            self.checkbox_frame, width=50, image=self.gear_off, text="", fg_color="transparent", command=lambda x=module: self.display_settings(module))
        self.linker.modules_dictionary[module]['button'].grid(row=i, column=1, padx=(40,0), pady=(2,0), sticky="nw")        

    def create_all_button_frame(self):
        self.select_all_button = customtkinter.CTkButton(self.checkbox_frame, width=100, text="Select All", fg_color="#DC621D", font=("Inter",20), command=self.select_all)
        self.select_all_button.grid(row=4, column=0, padx=10, pady=(15,20), sticky="w")
        self.clear_all_button = customtkinter.CTkButton(self.checkbox_frame, width=100, text="Clear All", fg_color="#DC621D", font=("Inter",20), command=self.clear_all)
        self.clear_all_button.grid(row=4, column=1, padx=10, pady=(15,20), sticky="w")

    def create_start_button(self):
        self.start_button = customtkinter.CTkButton(self, text="Start", width=200, height=40, command=self.linker.start_stop, font=customtkinter.CTkFont(family="Inter", size=16))
        self.start_button.grid(row=2, column=0)

    def create_notification_frames(self):
        for index, element in enumerate(["Template", "Queue", "Configuration"]):
            frame = customtkinter.CTkFrame(self, fg_color="transparent", height=50)
            if index == 0:
                top_pady=170
            else:
                top_pady=0
            frame.grid(row=3+index, column=0, sticky="s", pady=(top_pady,0))
            self.linker.name_to_sidebar_frame[element] = frame

    def select_all(self):
        for module in self.linker.modules_dictionary:
            if module != "momotalk":
                self.linker.modules_dictionary[module]["checkbox"].select()
                self.config.config_data[module]["enabled"] = True
        self.config.save_file("Configuration")

    def clear_all(self):
        for module in self.linker.modules_dictionary:
            self.linker.modules_dictionary[module]["checkbox"].deselect()
            self.config.config_data[module]["enabled"] = False
        self.config.save_file("Configuration")

    def display_settings(self, module):
        for key in self.linker.modules_dictionary:
            if key == module:
                self.linker.modules_dictionary[key]["button"].configure(image=self.gear_on)
                self.linker.modules_dictionary[key]["checkbox"].configure(text_color="#53B9E9")                
                self.current_frame.grid_remove()  # Hide the current frame
                self.current_frame = self.linker.modules_dictionary[key]["frame"]  # Update the current frame
                self.current_frame.grid(row=0, column=1, padx=20, pady=20, sticky="nsew")
            else:
                self.linker.modules_dictionary[key]["button"].configure(image=self.gear_off)
                self.linker.modules_dictionary[key]["checkbox"].configure(text_color="#FFFFFF")  

class LoginFrame(customtkinter.CTkFrame):
    def __init__(self, master, linker, config, **kwargs):
        super().__init__(master, **kwargs)
        self.linker = linker
        self.config = config
        self.create_widgets()

    def create_widgets(self):
        self.login_settings_label = customtkinter.CTkLabel(self, text="Login Settings", font=customtkinter.CTkFont(family="Inter", size=30, weight="bold"))
        self.login_settings_label.grid(row=0, column=0, sticky="nw", padx=20, pady=20)

        self.create_network_widgets()
        self.create_screenshot_widgets()
        self.create_server_widgets()
        self.create_restart_attempts_widgets()

    def create_network_widgets(self):
        self.network_label = customtkinter.CTkLabel(self, text="Connection address:", font=customtkinter.CTkFont(family="Inter", size=20, weight="bold"))
        self.network_label.grid(row=1, column=0, padx=20, pady=(20, 10))

        self.network_entry = customtkinter.CTkEntry(self)
        self.network_entry.bind("<KeyRelease>", lambda event, x=["login", "network"]: self.config.save_to_json(x))
        self.network_entry.grid(row=2, column=0, padx=20, pady=(20, 10))

        self.linker.widgets["login"]["network"] = self.network_entry

    def create_screenshot_widgets(self):
        self.screenshot_label = customtkinter.CTkLabel(self, text="Screenshot mode:", font=customtkinter.CTkFont(size=20, weight="bold"))
        self.screenshot_label.grid(row=3, column=0, padx=20, pady=(20, 10))

        self.screenshot_dropdown = customtkinter.CTkOptionMenu(self, values=["SCREENCAP_PNG", "SCREENCAP_RAW", "ASCREENCAP", "UIAUTOMATOR2"], command=lambda x, y=["login", "screenshot_mode"]: self.config.save_to_json(y))
        self.screenshot_dropdown.grid(row=4, column=0, padx=20, pady=(20, 10))

        self.linker.widgets["login"]["screenshot_mode"] = self.screenshot_dropdown

    def create_server_widgets(self):
        self.server_label = customtkinter.CTkLabel(self, text="Server:", font=customtkinter.CTkFont(size=20, weight="bold"))
        self.server_label.grid(row=5, column=0, padx=20, pady=(20, 10))

        self.server_dropdown = customtkinter.CTkOptionMenu(self, values=["EN", "CN"], command=lambda x,y=["login","server"]: (self.config.save_to_json(y), self.linker.switch_student_list()))
        self.server_dropdown.grid(row=6, column=0, padx=20, pady=(20, 10))

        self.linker.widgets["login"]["server"] = self.server_dropdown

    def create_restart_attempts_widgets(self):
        self.restart_attempts_label = customtkinter.CTkLabel(self, text="Restart attempts", font=customtkinter.CTkFont(size=20, weight="bold", underline=True))
        self.restart_attempts_label.grid(row=7, column=0, padx=20, pady=(20, 10))
        self.restart_attempts_tootltip = CTkToolTip(self.restart_attempts_label, message="Sets the number of restart attempts allowed to the script. Restart attempts are triggered when the game crashes or freezes.", wraplength=400)

        self.restart_attempts_spinbox = IntegerSpinbox(self, step_size=1, min_value=0, command=lambda x=["login", "restart_attempts"]:self.config.save_to_json(x))
        self.restart_attempts_spinbox.entry.bind("<KeyRelease>", lambda event, x=["login", "restart_attempts"]: self.config.save_to_json(x))
        self.restart_attempts_spinbox.grid(row=8, column=0, padx=20, pady=(20, 10))

        self.linker.widgets["login"]["restart_attempts"] = self.restart_attempts_spinbox

class CafeFrame(customtkinter.CTkFrame):
    def __init__(self, master, linker, config, **kwargs):
        super().__init__(master, **kwargs)
        self.linker = linker
        self.config = config
        self.create_widgets()

    def create_widgets(self):
        self.create_cafe_settings_label()
        self.create_invite_student_widgets()
        self.create_tap_students_widgets()
        self.create_claim_earnings_widgets()

    def create_cafe_settings_label(self):
        self.cafe_settings_label = customtkinter.CTkLabel(self, text="Cafe Settings", font=customtkinter.CTkFont(family="Inter", size=30, weight="bold"))
        self.cafe_settings_label.grid(row=0, column=0, sticky="nw", padx=20, pady=20)

    def create_invite_student_widgets(self):
        self.invite_checkbox = customtkinter.CTkCheckBox(self, text="Invite student", font=customtkinter.CTkFont(family="Inter", size=20), command=lambda x=["cafe", "invite_student"]: self.config.save_to_json(x))
        self.invite_checkbox.grid(row=1, column=0, pady=(20, 0), padx=20, sticky="nw")

        self.student_entry = customtkinter.CTkComboBox(self, width=180)
        self.student_entry.bind("<KeyRelease>", lambda event, x=["cafe", "student_name"]: (self.config.save_to_json(x)))
        self.student_entry.grid(row=1, column=1, padx=20, pady=(20, 0))

        save_student = lambda name: self.student_entry.set(name)
        server = self.config.config_data["login"]["server"]
        with open(f"gui/student_list/{server}.json", "r") as f:
            student_list = json.load(f)
        self.student_dropdown = CTkScrollableDropdown(self.student_entry, values=student_list, width=180, height=550, autocomplete=True, command=lambda choice, x=["cafe", "student_name"]: (save_student(choice), self.config.save_to_json(x)))
        self.linker.widgets["cafe"]["invite_student"] = self.invite_checkbox
        self.linker.widgets["cafe"]["student_name"] = self.student_entry

    def create_tap_students_widgets(self):
        self.tap_checkbox = customtkinter.CTkCheckBox(self, text="Tap Students", font=customtkinter.CTkFont(family="Inter", size=20), command=lambda x=["cafe", "tap_students"]: self.config.save_to_json(x))
        self.tap_checkbox.grid(row=2, column=0, pady=(20,0), padx=20, sticky="nw")
        self.linker.widgets["cafe"]["tap_students"] = self.tap_checkbox


    def create_claim_earnings_widgets(self):
        self.claim_checkbox = customtkinter.CTkCheckBox(self, text="Claim Earnings", font=customtkinter.CTkFont(family="Inter", size=20), command=lambda x=["cafe", "claim_earnings"]: self.config.save_to_json(x))
        self.claim_checkbox.grid(row=3, column=0, pady=(20, 0), padx=20, sticky="nw")
        self.linker.widgets["cafe"]["claim_earnings"] = self.claim_checkbox

class FarmingFrame(customtkinter.CTkScrollableFrame):
    def __init__(self, master, linker, config, **kwargs):
        super().__init__(master=master, **kwargs)
        self.linker = linker
        self.config = config
        self.create_widgets()
        # Load Template Data
        self.load_template_data()
        # Load queue Data
        self.load_queue_data()


    def create_widgets(self):
        self.farming_settings_label = customtkinter.CTkLabel(self, text="Farming Settings", font=customtkinter.CTkFont(family="Inter", size=30, weight="bold"))
        self.farming_settings_label.grid(row=0, column=0, sticky="nw", padx=20, pady=20)

        self.create_bounty_widgets()
        self.create_scrimmage_widgets()
        self.create_tactical_challenge_widgets()
        self.create_mission_commissions_widgets()

    def create_bounty_widgets(self):
        self.bounty_checkbox = customtkinter.CTkCheckBox(self, text="Bounty", command=lambda x=["farming", "bounty", "enabled"]: self.config.save_to_json(x), font=customtkinter.CTkFont(family="Inter", size=20, weight="bold"))
        self.bounty_checkbox.grid(row=1, column=0, sticky="nw", padx=20, pady=20)   
        self.linker.widgets["farming"]["bounty"]["enabled"] = self.bounty_checkbox
        values = ["0" + str(x) for x in range(1, 10)]

        for index, element in enumerate(["overpass", "desert_railroad", "classroom"]):
            label = customtkinter.CTkLabel(self, text=self.linker.capitalise(element), font=customtkinter.CTkFont(family="Inter", size=16))
            label.grid(row=index + 2, column=0, sticky="nw", padx=80)

            option_menu = customtkinter.CTkOptionMenu(self, width=80, values=values, command=lambda x, y=["farming", "bounty", element, "stage"]: self.config.save_to_json(y), font=customtkinter.CTkFont(family="Inter", size=16))
            option_menu.grid(row=index + 2, column=1)
            self.linker.widgets["farming"]["bounty"][element]["stage"] = option_menu

            spinbox = IntegerSpinbox(self, step_size=1, min_value=0, command=lambda x=["farming", "bounty", element, "run_times"]:self.config.save_to_json(x))
            spinbox.entry.bind("<KeyRelease>", lambda event, x=["farming", "bounty", element, "run_times"]: self.config.save_to_json(x))
            spinbox.grid(row=index + 2, column=2)
            self.linker.widgets["farming"]["bounty"][element]["run_times"] = spinbox

    def create_scrimmage_widgets(self):
        self.scrimmage_checkbox = customtkinter.CTkCheckBox(self, text="Scrimmage", command=lambda x=["farming", "scrimmage", "enabled"]: self.config.save_to_json(x), font=customtkinter.CTkFont(family="Inter", size=20, weight="bold"))
        self.scrimmage_checkbox.grid(row=5, column=0, sticky="nw", padx=20, pady=20)
        self.linker.widgets["farming"]["scrimmage"]["enabled"] = self.scrimmage_checkbox

        values = ["0" + str(x) for x in range(1, 10)]

        for index, element in enumerate(["trinity", "gehenna", "millennium"]):
            label = customtkinter.CTkLabel(self, text=self.linker.capitalise(element), font=customtkinter.CTkFont(family="Inter", size=16))
            label.grid(row=index + 6, column=0, sticky="nw", padx=80)

            option_menu = customtkinter.CTkOptionMenu(self, width=80, values=values[:5], command=lambda x, y=["farming", "scrimmage", element, "stage"]: self.config.save_to_json(y))
            option_menu.grid(row=index + 6, column=1)
            self.linker.widgets["farming"]["scrimmage"][element]["stage"] = option_menu

            spinbox = IntegerSpinbox(self, step_size=1, min_value=0, command=lambda x=["farming", "scrimmage", element, "run_times"]: self.config.save_to_json(x))
            spinbox.entry.bind("<KeyRelease>", lambda event, x=["farming", "scrimmage", element, "run_times"]: self.config.save_to_json(x))
            spinbox.grid(row=index + 6, column=2)
            self.linker.widgets["farming"]["scrimmage"][element]["run_times"] = spinbox

    def create_tactical_challenge_widgets(self):
        self.tactical_checkbox = customtkinter.CTkCheckBox(self, text="Tactical Challenge", command=lambda x=["farming", "tactical_challenge", "enabled"]: self.config.save_to_json(x), font=customtkinter.CTkFont(family="Inter", size=20, weight="bold"))
        self.tactical_checkbox.grid(row=9, column=0, sticky="nw", padx=20, pady=20)
        self.linker.widgets["farming"]["tactical_challenge"]["enabled"] = self.tactical_checkbox

        self.rank_label = customtkinter.CTkLabel(self, text="Rank", font=customtkinter.CTkFont(family="Inter", size=16))
        self.rank_label.grid(row=10, column=0, sticky="nw", padx=80)

        self.rank_optionmenu = customtkinter.CTkOptionMenu(self, values=["highest", "middle", "lowest"], command=lambda x, y=["farming", "tactical_challenge", "rank"]: self.config.save_to_json(y))
        self.rank_optionmenu.grid(row=10, column=1)
        self.linker.widgets["farming"]["tactical_challenge"]["rank"] = self.rank_optionmenu


    def create_mission_commissions_widgets(self):
        # Create Mission/Commissions/Event Checkbox
        self.create_mission_commissions_checkbox()

        # Create Reset Daily Widgets
        self.create_reset_daily_widgets()

        # Create Recharge AP and Event Checkboxes
        self.create_recharge_and_event_checkboxes()

        # Create Preferred Template Selection
        self.create_preferred_template_selection()

        # Create Mission Tabview with Template and Queue Tabs
        self.create_mission_tabview()

        # Create Top-Level Window for Template Editing
        self.create_template_queue_editor()

        # Create Template Frame and Queue Frame
        self.create_template_and_queue_frames()

        # Create Lists to Store Frame Widgets
        self.create_frame_lists()

        # Initialize Preferred Template and Templates List
        self.initialize_preferred_template()

        # Create OptionMenu for Selecting a Template
        self.create_template_option_menu()

        # Create Delete Template Button
        self.create_delete_template_button()

    # Helper method to create Mission/Commissions/Event Checkbox
    def create_mission_commissions_checkbox(self):
        self.mission_commissions_checkbox = customtkinter.CTkCheckBox(self, text="Mission/\nCommissions/Event", width=60, font=customtkinter.CTkFont(family="Inter", size=20, weight="bold"), command=lambda x=["farming", "mission", "enabled"]: self.config.save_to_json(x))
        self.mission_commissions_checkbox.grid(row=11, column=0, sticky="nw", padx=20, pady=20)
        self.linker.widgets["farming"]["mission"]["enabled"] = self.mission_commissions_checkbox

    # Helper method to create Reset Daily Widgets
    def create_reset_daily_widgets(self):
        self.reset_daily = customtkinter.CTkCheckBox(self, text="Reset Daily", font=customtkinter.CTkFont(family="Inter", size=16, underline=True))
        self.reset_daily.grid(row=12, column=0, sticky="nw", padx=80)
        self.reset_daily_tooltip = CTkToolTip(self.reset_daily, wraplength=400,
                                              message="If enabled and if current time >= reset time,\
                                                the queue will automatically be cleared and repopulated with preferred template stages. Only activated once a day.")

        self.reset_daily_sub_label = customtkinter.CTkLabel(self, text="hh/mm/ss", font=customtkinter.CTkFont(family="Inter", size=12))
        self.reset_daily_sub_label.grid(row=13, column=0, padx=80)

        self.reset_time = TimeEntry(self)
        self.reset_time.grid(row=12, column=1)
        self.reset_time.hour_entry.bind("<KeyRelease>", lambda event, x=["farming", "mission", "reset_time"]: self.config.save_to_json(x))
        self.reset_time.minute_entry.bind("<KeyRelease>", lambda event, x=["farming", "reset_time"]: self.config.save_to_json(x))
        self.reset_time.second_entry.bind("<KeyRelease>", lambda event, x=["farming", "reset_time"]: self.config.save_to_json(x))

        self.linker.widgets["farming"]["mission"]["reset_daily"] = self.reset_daily
        self.linker.widgets["farming"]["mission"]["reset_time"] = self.reset_time

    # Helper method to create Recharge AP and Event Checkboxes
    def create_recharge_and_event_checkboxes(self):
        self.recharge_checkbox = customtkinter.CTkCheckBox(self, text="Recharge AP", command=lambda x=["farming", "mission", "recharge_ap"]: self.config.save_to_json(x), font=customtkinter.CTkFont(family="Inter", size=16, underline=True))
        self.recharge_checkbox.grid(row=14, column=0, sticky="nw", padx=80, pady=20)
        self.linker.widgets["farming"]["mission"]["recharge_ap"] = self.recharge_checkbox
        self.recharge_tooltip = CTkToolTip(self.recharge_checkbox, wraplength=400,
                                              message="When enabled, recharge AP when low via cafe earnings and mailbox, regardless of whether they are enabled in their respective sections.")
        self.event_checkbox = customtkinter.CTkCheckBox(self, text="Sweep Event Stages", command=lambda x=["farming", "mission", "event"]: self.config.save_to_json(x), font=customtkinter.CTkFont(family="Inter", size=16, underline=True))
        self.event_tooltip = CTkToolTip(self.event_checkbox, wraplength=400, message="When enabled, the script will sweep event stages. Otherwise, it will ignore them.")
        self.event_checkbox.grid(row=15, column=0, sticky="nw", padx=80)
        self.linker.widgets["farming"]["mission"]["event"] = self.event_checkbox

    # Helper method to create Preferred Template Selection
    def create_preferred_template_selection(self):
        self.templates = self.config.config_data["farming"]["mission"]["templates"]
        self.templates_list = list(self.templates.keys())

        self.preferred_template_label = customtkinter.CTkLabel(self, text="Preferred Template:", font=customtkinter.CTkFont(family="Inter", size=16, underline=True))
        self.preferred_template_label.grid(row=16, column=0, pady=20)
        self.preferred_template_tooltip = CTkToolTip(self.preferred_template_label, wraplength=400,
                                              message="The template from which to repopulate the queue when it is empty or reset daily is activated")
        self.preferred_template_optionmenu = customtkinter.CTkOptionMenu(self, values=self.templates_list, command=lambda x, y=["farming", "mission", "preferred_template"]: self.config.save_to_json(y))
        self.preferred_template_optionmenu.grid(row=16, column=1, pady=20)
        self.linker.widgets["farming"]["mission"]["preferred_template"] = self.preferred_template_optionmenu

    # Helper method to create Mission Tabview with Template and Queue Tabs
    def create_mission_tabview(self):
        self.mission_tabview = customtkinter.CTkTabview(self, height=500)
        self.mission_tabview.grid(row=17, column=0, columnspan=3, padx=20)

        self.tab_template = self.mission_tabview.add('Template')
        self.tab_queue = self.mission_tabview.add('Queue')

    # Helper method to create Template Queue Editor 
    def create_template_queue_editor(self):
        self.queue_buttons = []

        for i in [self.tab_queue, self.tab_template]:
            queue = True if i == self.tab_queue else False

            self.template_labels = customtkinter.CTkFrame(i)
            self.template_labels.grid(row=0, column=0, sticky="ew")

            self.mode_label = customtkinter.CTkLabel(self.template_labels, text="Mode:", font=customtkinter.CTkFont(underline=True))
            self.mode_tooltip = CTkToolTip(self.mode_label, message="N:Mission Normal\nH:Mission Hard\nE:Event Quest\nBD:Base Defense\nIR:Item Retrieval\n")
            self.mode_label.grid(row=1, column=0, padx=(130, 0), pady=5)

            self.stage_label = customtkinter.CTkLabel(self.template_labels, text="Stage:", font=customtkinter.CTkFont(underline=True))
            self.stage_tooltip = CTkToolTip(self.stage_label, message="Valid format for Mission: 1-1\nValid format for Commissions/Event: 01")
            self.stage_label.grid(row=1, column=1, padx=(40, 20), pady=5)

            self.run_times_label = customtkinter.CTkLabel(self.template_labels, text="Number of Sweeps:", font=customtkinter.CTkFont(underline=True))
            self.run_times_tooltip = CTkToolTip(self.run_times_label, message="How many times do you want to sweep the stage?")
            self.run_times_label.grid(row=1, column=2, pady=5)

            self.template_buttons_frame = customtkinter.CTkFrame(i)
            self.template_buttons_frame.grid(row=3, column=0)

            self.add_button = customtkinter.CTkButton(self.template_buttons_frame , text="Add", command=lambda queue=queue: self.add_frame(queue=queue))
            self.add_button.grid(row=0, column=0, padx=5, pady=5)

            # Clear button to clear all frames
            self.clear_button = customtkinter.CTkButton(self.template_buttons_frame, text="Clear All", command=lambda queue=queue: self.clear_frames(queue=queue), fg_color="crimson")
            self.clear_button.grid(row=0, column=1, padx=5, pady=5)

            # Save button to save data
            self.save_button = customtkinter.CTkButton(self.template_buttons_frame, text="Save", command=lambda queue=queue: self.save_data(queue=queue), fg_color="#DC621D")
            self.save_button.grid(row=0, column=2, padx=5, pady=5)
            if queue:
                self.queue_buttons = [self.add_button, self.clear_button, self.save_button]

    # Helper method to create Template Frame and Queue Frame
    def create_template_and_queue_frames(self):
        self.template_frame = customtkinter.CTkScrollableFrame(self.tab_template, width=400, height=350)
        self.template_frame.grid(row=1, column=0, sticky="nsew")
        
        self.queue_frame = customtkinter.CTkScrollableFrame(self.tab_queue, width=400, height=350)
        self.queue_frame.grid(row=1, column=0, sticky="nsew")

    # Helper method to create Lists to Store Frame Widgets
    def create_frame_lists(self):
        self.template_frames = []
        self.queue_frames = []

    # Helper method to initialize Preferred Template and Templates List
    def initialize_preferred_template(self):
        self.preferred_template = self.config.config_data["farming"]["mission"]["preferred_template"]
        self.templates_list.append("Add New Template")

    # Helper method to create OptionMenu for Selecting a Template
    def create_template_option_menu(self):
        self.selected_template = tk.StringVar(self.template_frame)
        self.selected_template.set(self.preferred_template)  # Set the initial value to the preferred template

        self.template_optionmenu = customtkinter.CTkOptionMenu(self.template_labels, values=self.templates_list, variable=self.selected_template, command=lambda *args: self.load_template_data())
        self.template_optionmenu.grid(row=0, column=0, padx=5, pady=5)

    # Helper method to create Delete Template Button
    def create_delete_template_button(self):
        self.delete_template_button = customtkinter.CTkButton(self.template_labels, width=40, text="Delete", command=self.delete_template)
        self.delete_template_button.grid(row=0, column=1)

    # Helper method to add frames from Configuration Data
    def load_queue_data(self):
        for entry in self.config.config_data["farming"]['mission']['queue']:
            self.add_frame(entry, queue=True)

    # Function to load template data into frames
    def load_template_data(self):
        selected = self.selected_template.get()
        if selected == "Add New Template":
            dialog = customtkinter.CTkInputDialog(text="Type in new template name:", title="Template Name")
            template_name = dialog.get_input()
            if template_name in ["",None]:
                self.template_optionmenu.set(self.previous_selected)
                return
            elif template_name == "Add New Template":
                self.open_toplevel()
                return
            else:
                self.templates[template_name] = []
                self.templates_list.insert(-1, template_name)
                selected = template_name
                self.template_optionmenu.set(selected)
                self.template_optionmenu.configure(values=self.templates_list)
                self.preferred_template_optionmenu.configure(values=self.templates_list)
        self.clear_frames()
        for entry in self.templates[selected]:
            self.add_frame(entry)
        self.previous_selected  = selected

    def delete_template(self):
        msg = CTkMessagebox(title="Template Deletetion", message=f"Are you sure you want to delete Template {self.previous_selected}?",
                        icon="question", option_1="No", option_2="Yes")
        response = msg.get()
        if response=="Yes":
            if self.templates != 1:
                del self.templates[self.previous_selected]
                self.templates_list = list(self.templates.keys())
                self.preferred_template_optionmenu.configure(values=self.templates_list)
                if self.preferred_template == self.previous_selected:
                    self.preferred_template = random.choice(self.templates_list)
                self.config.config_data["farming"]["mission"]["preferred_template"] = self.preferred_template
                self.selected_template.set(self.preferred_template)  # Set the initial value to the preferred template
                self.preferred_template_optionmenu.set(self.preferred_template)
                self.load_template_data()
                self.config.save_file()
                self.templates_list.append("Add New Template")
                self.template_optionmenu.configure(values=self.templates_list)
                self.template_optionmenu.set(self.preferred_template)
            else:
                CTkMessagebox(title="Error", message="At least one template must exist!!!", icon="cancel")
        return

# Function to add a frame with widgets
    def add_frame(self, inner_list=None, queue=False):
        frames = self.queue_frames if queue else self.template_frames
        parent_frame = self.queue_frame if queue else self.template_frame
        row_index = len(frames) + 1  # Calculate the row for the new frame
        # Create a frame
        frame = customtkinter.CTkFrame(parent_frame)
        frame.grid(row=row_index, column=0, columnspan=4, padx=10, pady=10, sticky="w")
        frames.append(frame)
        # "Up" button to move the frame up
        up_button = customtkinter.CTkButton(frame, text="Up", width=5, command=lambda f=frame, queue=queue: self.move_frame_up(f, queue))
        up_button.grid(row=0, column=0, padx=5, pady=5, sticky="w")
        # "Down" button to move the frame down
        down_button = customtkinter.CTkButton(frame, text="Down", width=5, command=lambda f=frame, queue=queue: self.move_frame_down(f, queue))
        down_button.grid(row=0, column=1, padx=5, pady=5, sticky="w")
        # Dropdown menu for mode
        mode_optionmenu = customtkinter.CTkOptionMenu(frame, width=60, values=["N", "H", "E", "BD", "IR"])
        mode_optionmenu.set(inner_list[0] if inner_list else "N")
        mode_optionmenu.grid(row=0, column=2, padx=5, pady=5, sticky="w")
        # Entry widget for stage
        stage_var = tk.StringVar(value=inner_list[1] if inner_list else "")
        stage_entry = customtkinter.CTkEntry(frame, width=60, textvariable=stage_var)
        stage_entry.grid(row=0, column=3, padx=5, pady=5, sticky="w")
        mode_optionmenu.configure(command=lambda x=mode_optionmenu, y=stage_entry : self.check_entry(x,y))
        stage_entry.bind('<KeyRelease>', command=lambda event, x=mode_optionmenu, y=stage_entry : self.check_entry(x,y))
        self.check_entry(mode_optionmenu, stage_entry)
        # Entry widget for run times (only accepts numbers)
        run_times_spinbox = IntegerSpinbox(frame, step_size=1, min_value=1)
        run_times_spinbox.set(value=inner_list[2] if inner_list else 1)
        run_times_spinbox.grid(row=0, column=4, padx=5, pady=5, sticky="w")

        # Delete button to delete the frame
        delete_button = customtkinter.CTkButton(frame, text="Delete", width=5, command=lambda f=frame, queue=queue: self.delete_frame(f, queue))
        delete_button.grid(row=0, column=5, padx=5, pady=5, sticky="w")

    # Function to clear all frames
    def clear_frames(self, queue=False):
        frames = self.queue_frames if queue else self.template_frames
        for frame in frames:
            frame.destroy()
        frames.clear()

    # Function to save frames as data
    def save_data(self, queue=False):
        entries = []
        frames = self.queue_frames if queue else self.template_frames
        name = "Queue" if queue else "Template"
        for frame in frames:
            mode_optionmenu = frame.winfo_children()[2]
            stage_entry = frame.winfo_children()[3]
            if not self.check_entry(mode_optionmenu, stage_entry):
                CTkMessagebox(title="Error", message="Configuration not saved. Some entries are incomplete or have incorect input.", icon="cancel")
                return
            mode = frame.winfo_children()[2].get()
            stage = frame.winfo_children()[3].get()
            run_times = frame.winfo_children()[4].get()
            entries.append([mode, stage, int(run_times)])
        if queue:
            self.config.config_data["farming"]['mission']['queue'] = entries
        else:
            selected = self.selected_template.get()
            self.templates[selected] = entries
        self.config.save_file(name)

    def check_entry(self, mode_dropdown, stage_entry):
        mode = mode_dropdown.get()
        stage = stage_entry.get()
        if mode in ["N", "H"]:
            pattern = r'\d+-\d+'
        else:
            pattern = r"^\d{2}$"
        if re.match(pattern, stage):
            stage_entry.configure(border_color=['#979DA2', '#565B5E'])
            return True
        else:
            stage_entry.configure(border_color='crimson')  
            return False

    # Function to move a frame up
    def move_frame_up(self, frame, queue=False):
        frames = self.queue_frames if queue else self.template_frames
        index = frames.index(frame)
        if index > 0:
            frames[index], frames[index - 1] = frames[index - 1], frames[index]
            self.update_frame_positions(queue=queue)

    # Function to move a frame down
    def move_frame_down(self, frame, queue=False):
        frames = self.queue_frames if queue else self.template_frames
        index = frames.index(frame)
        if index < len(frames) - 1:
            frames[index], self.template_frames[index + 1] = frames[index + 1], frames[index]
            self.update_frame_positions(queue=queue)

    # Function to update frame positions in the grid
    def update_frame_positions(self, queue=False):
        frames = self.queue_frames if queue else self.template_frames
        for index, frame in enumerate(frames, start=1):
            frame.grid(row=index + 1, column=0, columnspan=4, padx=10, pady=10, sticky="w")

    # Function to delete a frame
    def delete_frame(self, frame, queue=False):
        if queue:
            self.queue_frames.remove(frame)
        else:
            self.template_frames.remove(frame)
        frame.destroy()
        # Update the positions of remaining frames
        self.update_frame_positions(queue=queue)

class ClaimRewardsFrame(customtkinter.CTkFrame):
    def __init__(self, master, linker, config, **kwargs):
        super().__init__(master, **kwargs)
        self.linker = linker
        self.config = config
        self.claim_reward_settings_label = customtkinter.CTkLabel(self, text="Claim Rewards Settings", font=customtkinter.CTkFont(family="Inter", size=30, weight="bold"))
        self.claim_reward_settings_label.grid(row=0, column=0, sticky="nw", padx=20, pady=20)
        self.claim_tasks_checkbox = customtkinter.CTkCheckBox(self, text="Taks", font=customtkinter.CTkFont(family="Inter", size=20, weight="bold"), command= lambda x=["claim_rewards", "tasks"]: self.config.save_to_json(x))
        self.claim_tasks_checkbox.grid(row=1, column=0, pady=(20, 0), padx=20, sticky="nw")
        self.claim_mail_checkbox = customtkinter.CTkCheckBox(self, text="Mailbox", font=customtkinter.CTkFont(family="Inter", size=20, weight="bold"), command= lambda x=["claim_rewards", "mailbox"]: self.config.save_to_json(x))
        self.claim_mail_checkbox.grid(row=2, column=0, pady=(20, 0), padx=20, sticky="nw")
        self.linker.widgets["claim_rewards"]["tasks"] = self.claim_tasks_checkbox
        self.linker.widgets["claim_rewards"]["mailbox"] = self.claim_mail_checkbox 

class LoggerTextBox(customtkinter.CTkFrame):
    def __init__(self, master, linker, config, **kwargs):
        super().__init__(master=master, **kwargs)
        self.linker = linker
        self.config = config
        self.grid_rowconfigure(0, weight=0)
        self.grid_rowconfigure(1, weight=1)
        self.grid_columnconfigure(0, weight=1)

        self.log_label = customtkinter.CTkLabel(self, text="Log",font=customtkinter.CTkFont(family="Inter", size=30, weight="bold"))
        self.log_label.grid(row=0, column=0, sticky="nw", padx=20, pady=(20,0))
         # Button to toggle autoscroll
        self.toggle_autoscroll_button = customtkinter.CTkButton(self, height=35, text="Autoscroll On", command=self.toggle_autoscroll, font=("Inter", 16))
        self.toggle_autoscroll_button.grid(row=0, column=1, padx=20, pady=20, sticky="nsew")
        self.autoscroll_enabled = True  # Initially, autoscroll is enabled
        self.log_textbox = customtkinter.CTkTextbox(self, state="disabled", font=("Inter", 16), wrap="word")
        self.log_textbox.grid(row=1, column=0,columnspan=4, padx=20, pady=20, sticky="nsew")  
        self.log_level_colors = {
            "[INFO]": "light blue",
            "[SUCCESS]": "light green",
            "[WARNING]": "orange",
            "[ERROR]": "red",
            "[DEBUG]": "purple"  
        }
        for level, color in self.log_level_colors.items():
            self.log_textbox.tag_config(level, foreground=color)
        self.linker.logger = self

    def toggle_autoscroll(self):
        self.autoscroll_enabled = not self.autoscroll_enabled
        if self.autoscroll_enabled:
            self.toggle_autoscroll_button.configure(text="Autoscroll On")
        else:
            self.toggle_autoscroll_button.configure(text="Autoscroll Off")

class App(customtkinter.CTk):
    def __init__(self):
        super().__init__("#18173C")
        self.configure_window()
        linker = Linker()
        config = Config(linker, "config.json")
        sidebar = Sidebar(self, linker, config, fg_color="#25224F")
        sidebar.grid(row=0, column=0, sticky="nsw")
        logger = LoggerTextBox(self, linker, config, fg_color="#262250")
        logger.grid(row=0, column=2, pady=20, sticky="nsew")
        config.load_config()

    def configure_window(self):
        self.title("BAAuto")
        self.geometry(f"{1500}x{850}")
        self.iconbitmap('gui/icons/karin.ico')
        """ 
        solution to Settings Frame and Logger Frame widths not being 
        consistent between different windows scaling factoro#s
        """
        self.scaleFactor = ctypes.windll.shcore.GetScaleFactorForDevice(0) / 100
        self.grid_columnconfigure(0, weight=0)
        self.grid_columnconfigure(1, weight=0, minsize=650*self.scaleFactor)
        self.grid_columnconfigure(2, weight=1, minsize=506*self.scaleFactor)
        self.grid_rowconfigure(0, weight=1)

if __name__ == "__main__":
    app = App()
    app.mainloop()

BAAuto.py
RedDeadDepresso/BAAuto
"""
CustomTkinter Scrollable Dropdown Menu
Author: Akash Bora
License: MIT
This is a custom dropdown menu for customtkinter.
Homepage: https://github.com/Akascape/CTkScrollableDropdown
"""

__version__ = '0.7'

from .ctk_scrollable_dropdown import CTkScrollableDropdown
from .ctk_scrollable_dropdown_frame import CTkScrollableDropdownFrame
from .ctkmessagebox import CTkMessagebox
from .ctk_tooltip import CTkToolTip
from .widgets import TimeEntry, Notification, IntegerSpinbox
gui/__init__.py
RedDeadDepresso/BAAuto
'''
Advanced Scrollable Dropdown class for customtkinter widgets
Author: Akash Bora
'''

import customtkinter
import sys
import time

class CTkScrollableDropdown(customtkinter.CTkToplevel):
    
    def __init__(self, attach, x=None, y=None, button_color=None, height: int = 200, width: int = None,
                 fg_color=None, button_height: int = 20, justify="center", scrollbar_button_color=None,
                 scrollbar=True, scrollbar_button_hover_color=None, frame_border_width=2, values=[],
                 command=None, image_values=[], alpha: float = 0.97, frame_corner_radius=20, double_click=False,
                 resize=True, frame_border_color=None, text_color=None, autocomplete=False, **button_kwargs):
        
        super().__init__(takefocus=1)
        
        self.focus()
        self.lift()
        self.alpha = alpha
        self.attach = attach
        self.corner = frame_corner_radius
        self.padding = 0
        self.focus_something = False
        self.disable = True
        self.update()
        
        if sys.platform.startswith("win"):
            self.after(100, lambda: self.overrideredirect(True))
            self.transparent_color = self._apply_appearance_mode(self._fg_color)
            self.attributes("-transparentcolor", self.transparent_color)
        elif sys.platform.startswith("darwin"):
            self.overrideredirect(True)
            self.transparent_color = 'systemTransparent'
            self.attributes("-transparent", True)
            self.focus_something = True
        else:
            self.overrideredirect(True)
            self.transparent_color = '#000001'
            self.corner = 0
            self.padding = 18
            self.withdraw()

        self.hide = True
        self.attach.bind('<Configure>', lambda e: self._withdraw() if not self.disable else None, add="+")
        self.attach.winfo_toplevel().bind('<Configure>', lambda e: self._withdraw() if not self.disable else None, add="+")
        self.attach.winfo_toplevel().bind("<Triple-Button-1>", lambda e: self._withdraw() if not self.disable else None, add="+")        
        self.attach.winfo_toplevel().bind("<Button-3>", lambda e: self._withdraw() if not self.disable else None, add="+")
        self.attach.winfo_toplevel().bind("<Button-2>", lambda e: self._withdraw() if not self.disable else None, add="+")
        
        self.attributes('-alpha', 0)
        self.disable = False
        self.fg_color = customtkinter.ThemeManager.theme["CTkFrame"]["fg_color"] if fg_color is None else fg_color
        self.scroll_button_color = customtkinter.ThemeManager.theme["CTkScrollbar"]["button_color"] if scrollbar_button_color is None else scrollbar_button_color
        self.scroll_hover_color = customtkinter.ThemeManager.theme["CTkScrollbar"]["button_hover_color"] if scrollbar_button_hover_color is None else scrollbar_button_hover_color
        self.frame_border_color = customtkinter.ThemeManager.theme["CTkFrame"]["border_color"] if frame_border_color is None else frame_border_color
        self.button_color = customtkinter.ThemeManager.theme["CTkFrame"]["top_fg_color"] if button_color is None else button_color
        self.text_color = customtkinter.ThemeManager.theme["CTkLabel"]["text_color"] if text_color is None else text_color
        
        if scrollbar is False:
            self.scroll_button_color = self.fg_color
            self.scroll_hover_color = self.fg_color
            
        self.frame = customtkinter.CTkScrollableFrame(self, bg_color=self.transparent_color, fg_color=self.fg_color,
                                        scrollbar_button_hover_color=self.scroll_hover_color,
                                        corner_radius=self.corner, border_width=frame_border_width,
                                        scrollbar_button_color=self.scroll_button_color,
                                        border_color=self.frame_border_color)
        self.frame._scrollbar.grid_configure(padx=3)
        self.frame.pack(expand=True, fill="both")
        self.dummy_entry = customtkinter.CTkEntry(self.frame, fg_color="transparent", border_width=0, height=1, width=1)
        self.no_match = customtkinter.CTkLabel(self.frame, text="No Match")
        self.height = height
        self.height_new = height
        self.width = width
        self.command = command
        self.fade = False
        self.resize = resize
        self.autocomplete = autocomplete
        self.var_update = customtkinter.StringVar()
        self.appear = False
        
        if justify.lower()=="left":
            self.justify = "w"
        elif justify.lower()=="right":
            self.justify = "e"
        else:
            self.justify = "c"
            
        self.button_height = button_height
        self.values = values
        self.button_num = len(self.values)
        self.image_values = None if len(image_values)!=len(self.values) else image_values
        
        self.resizable(width=False, height=False)
        self.transient(self.master)
        self._init_buttons(**button_kwargs)

        # Add binding for different ctk widgets
        if double_click or self.attach.winfo_name().startswith("!ctkentry") or self.attach.winfo_name().startswith("!ctkcombobox"):
            self.attach.bind('<Double-Button-1>', lambda e: self._iconify(), add="+")
        else:
            self.attach.bind('<Button-1>', lambda e: self._iconify(), add="+")

        if self.attach.winfo_name().startswith("!ctkcombobox"):
            self.attach._canvas.tag_bind("right_parts", "<Button-1>", lambda e: self._iconify())
            self.attach._canvas.tag_bind("dropdown_arrow", "<Button-1>", lambda e: self._iconify())
            if self.command is None:
                self.command = self.attach.set
              
        if self.attach.winfo_name().startswith("!ctkoptionmenu"):
            self.attach._canvas.bind("<Button-1>", lambda e: self._iconify())
            self.attach._text_label.bind("<Button-1>", lambda e: self._iconify())
            if self.command is None:
                self.command = self.attach.set
                
        self.attach.bind("<Destroy>", lambda _: self.destroy(), add="+")
        
        self.update_idletasks()
        self.x = x
        self.y = y

        if self.autocomplete:
            self.bind_autocomplete()
            
        self.deiconify()
        self.withdraw()
        
        self.attributes("-alpha", self.alpha)
        
    def _withdraw(self):
        self.event_generate("<<Closed>>")
        if self.hide is False: self.withdraw()
        self.hide = True

    def _update(self, a, b, c):
        self.live_update(self.attach._entry.get().title())
        
    def bind_autocomplete(self, ):
        def appear(x):
            self.appear = True
            
        if self.attach.winfo_name().startswith("!ctkcombobox"):
            self.attach._entry.configure(textvariable=self.var_update)
            self.attach._entry.bind("<Key>", appear)
            self.attach.set(self.values[0])
            self.var_update.trace_add('write', self._update)
            
        if self.attach.winfo_name().startswith("!ctkentry"):
            self.attach.configure(textvariable=self.var_update)
            self.attach.bind("<Key>", appear)
            self.var_update.trace_add('write', self._update)
        
    def fade_out(self):
        for i in range(100,0,-10):
            if not self.winfo_exists():
                break
            self.attributes("-alpha", i/100)
            self.update()
            time.sleep(1/100)
            
    def fade_in(self):
        for i in range(0,100,10):
            if not self.winfo_exists():
                break
            self.attributes("-alpha", i/100)
            self.update()
            time.sleep(1/100)
            
    def _init_buttons(self, **button_kwargs):
        self.i = 0
        self.widgets = {}
        for row in self.values:                                
            self.widgets[self.i] = customtkinter.CTkButton(self.frame,
                                                          text=row,
                                                          height=self.button_height,
                                                          fg_color=self.button_color,
                                                          text_color=self.text_color,
                                                          image=self.image_values[i] if self.image_values is not None else None,
                                                          anchor=self.justify,
                                                          command=lambda k=row: self._attach_key_press(k), **button_kwargs)
            self.widgets[self.i].pack(fill="x", pady=2, padx=(self.padding, 0))
            self.i+=1
             
        self.hide = False
            
    def destroy_popup(self):
        self.destroy()
        self.disable = True

    def place_dropdown(self):
        self.x_pos = self.attach.winfo_rootx() if self.x is None else self.x + self.attach.winfo_rootx()
        self.y_pos = self.attach.winfo_rooty() + self.attach.winfo_reqheight() + 5 if self.y is None else self.y + self.attach.winfo_rooty()
        self.width_new = self.attach.winfo_width() if self.width is None else self.width
        
        if self.resize:
            if self.button_num==1:      
                self.height_new = self.button_height * self.button_num + 45
            else:
                self.height_new = self.button_height * self.button_num + 35
            if self.height_new>self.height:
                self.height_new = self.height

        self.geometry('{}x{}+{}+{}'.format(self.width_new, self.height_new,
                                           self.x_pos, self.y_pos))
        self.fade_in()
        self.attributes('-alpha', self.alpha)
        self.attach.focus()

    def _iconify(self):
        if self.disable: return
        if self.hide:
            self.event_generate("<<Opened>>")
            self._deiconify()        
            self.focus()
            self.hide = False
            self.place_dropdown()
            if self.focus_something:
                self.dummy_entry.pack()
                self.dummy_entry.focus_set()
                self.after(100, self.dummy_entry.pack_forget)
        else:
            self.withdraw()
            self.hide = True
            
    def _attach_key_press(self, k):
        self.event_generate("<<Selected>>")
        self.fade = True
        if self.command:
            self.command(k)
        self.fade = False
        self.fade_out()
        self.withdraw()
        self.hide = True
            
    def live_update(self, string=None):
        if not self.appear: return
        if self.disable: return
        if self.fade: return
        if string:
            self._deiconify()
            i=1
            for key in self.widgets.keys():
                s = self.widgets[key].cget("text")
                if not s.startswith(string):
                    self.widgets[key].pack_forget()
                else:
                    self.widgets[key].pack(fill="x", pady=2, padx=(self.padding, 0))
                    i+=1
                    
            if i==1:
                self.no_match.pack(fill="x", pady=2, padx=(self.padding, 0))
            else:
                self.no_match.pack_forget()
            self.button_num = i
            self.place_dropdown()
            
        else:
            self.no_match.pack_forget()
            self.button_num = len(self.values)
            for key in self.widgets.keys():
                self.widgets[key].destroy()
            self._init_buttons()
            self.place_dropdown()
            
        self.frame._parent_canvas.yview_moveto(0.0)
        self.appear = False
        
    def insert(self, value, **kwargs):
        self.widgets[self.i] = customtkinter.CTkButton(self.frame,
                                                       text=value,
                                                       height=self.button_height,
                                                       fg_color=self.button_color,
                                                       text_color=self.text_color,
                                                       anchor=self.justify,
                                                       command=lambda k=value: self._attach_key_press(k), **kwargs)
        self.widgets[self.i].pack(fill="x", pady=2, padx=(self.padding, 0))
        self.i+=1
        self.values.append(value)
        
    def _deiconify(self):
        if len(self.values)>0:
            self.deiconify()

    def popup(self, x=None, y=None):
        self.x = x
        self.y = y
        self.hide = True
        self._iconify()
        
    def configure(self, **kwargs):
        if "height" in kwargs:
            self.height = kwargs.pop("height")
            self.height_new = self.height
            
        if "alpha" in kwargs:
            self.alpha = kwargs.pop("alpha")
            
        if "width" in kwargs:
            self.width = kwargs.pop("width")
            
        if "fg_color" in kwargs:
            self.frame.configure(fg_color=kwargs.pop("fg_color"))
            
        if "values" in kwargs:
            self.values = kwargs.pop("values")
            self.image_values = None
            for key in self.widgets.keys():
                self.widgets[key].destroy()
            self._init_buttons()
            
        if "image_values" in kwargs:
            self.image_values = kwargs.pop("image_values")
            self.image_values = None if len(self.image_values)!=len(self.values) else self.image_values
            if self.image_values is not None:
                i=0
                for key in self.widgets.keys():
                    self.widgets[key].configure(image=self.image_values[i])
                    i+=1
                    
        if "button_color" in kwargs:
            for key in self.widgets.keys():
                self.widgets[key].configure(fg_color=kwargs.pop("button_color"))
                
        for key in self.widgets.keys():
            self.widgets[key].configure(**kwargs)

gui/ctk_scrollable_dropdown.py
RedDeadDepresso/BAAuto
from bs4 import BeautifulSoup
import requests
import json
import concurrent.futures

def generate_urls():
    urls = []
    html_file = requests.get('https://ba.gamekee.com/').text
    soup = BeautifulSoup(html_file, 'lxml')
    div = soup.find_all('div',class_ = "item-wrapper icon-size-6 pc-item-group")
    a = div[1].find_all('a')
    for link in a: 
        urls.append(link.get('href'))
    return urls

def find_student_name(url):
    r = requests.get('https://ba.gamekee.com' + url).text
    soup = BeautifulSoup(r, 'lxml')
    tables = soup.find_all('table', class_='mould-table selectItemTable col-group-table')
    for table in tables:
        for span in table.find_all('span'):
            if span.text == "学生信息":
                target_row = table.find_all('tr')[2] 
                target_cell = target_row.find_all('td')[1]  

                # Get the text inside the cell
                text = target_cell.get_text()

                # Print the text
                print('https://ba.gamekee.com' + url)
                print(text.strip())
                return text.strip()
            

def main():
    urls = generate_urls()
    student_names = set()  # Create an empty list to store the results
    with concurrent.futures.ThreadPoolExecutor() as executor:
        # Use submit to schedule tasks and collect Future objects
        futures = [executor.submit(find_student_name, url) for url in urls]

        # Use as_completed to iterate over completed futures
        for future in concurrent.futures.as_completed(futures):
            result = future.result()  # Get the result from the completed future
            if result is None or any(char.isdigit() for char in result):
                print("Anomaly\n")

            elif result in student_names:
                student_names.remove(result)
                print("Anomaly\n")
            else:
                print("\n")
                student_names.add(result)  # Append the result to the list

    # Now student_names contains all the collected student names
    student_names = list(student_names)
    student_names.sort()
    with open("CN.json", "w") as f:
        json.dump(student_names, f, indent=2)

if __name__ == '__main__':
    main()

gui/student_list/CNscraper.py
RedDeadDepresso/BAAuto

import customtkinter
import tkinter as tk
import re
from typing import Callable

class TimeEntry(customtkinter.CTkFrame):
    def __init__(self, master=None, **kwargs):
        super().__init__(master, **kwargs)
        self.hour = tk.StringVar()
        self.minute = tk.StringVar()
        self.second = tk.StringVar()

        self.hour_entry = customtkinter.CTkEntry(self, width=50, textvariable=self.hour, validate="key", validatecommand=(self.register(self.validate_hour), '%P'))
        self.hour_entry.pack(side=tk.LEFT)

        self.minute_entry = customtkinter.CTkEntry(self,width=50, textvariable=self.minute, validate="key", validatecommand=(self.register(self.validate_min_sec), '%P'))
        self.minute_entry.pack(side=tk.LEFT)

        self.second_entry = customtkinter.CTkEntry(self, width=50, textvariable=self.second, validate="key", validatecommand=(self.register(self.validate_min_sec), '%P'))
        self.second_entry.pack(side=tk.LEFT)

    def validate_hour(self, P):
        return len(P) <= 2 and (P.isdigit() and int(P) <= 23 or P == "")

    def validate_min_sec(self, P):
        return len(P) <= 2 and (P.isdigit() and int(P) <= 59 or P == "")

    def set(self, time_str):
        h, m, s = map(str, time_str.split(':'))
        self.hour.set(h)
        self.minute.set(m)
        self.second.set(s)

    def get(self):
        h = self.hour.get() if self.hour.get() else "00"
        m = self.minute.get() if self.minute.get() else "00"
        s = self.second.get() if self.second.get() else "00"
        return f"{h.zfill(2)}:{m.zfill(2)}:{s.zfill(2)}"
    
class Notification(customtkinter.CTkFrame):
    def __init__(self, text, master, **kwargs):
        super().__init__(master=master, **kwargs)
        self.label = customtkinter.CTkLabel(self, text=text, width=200, wraplength=200, font=("Inter", 16))
        self.label.grid(row=0, column=0, sticky="nsew")
        self.close_button = customtkinter.CTkButton(self, width=40, text="X", command=self.destroy, fg_color="transparent")
        self.close_button.grid(row=0, column=1)
        self.progress_bar = customtkinter.CTkProgressBar(self, progress_color="white", determinate_speed=0.4)
        self.progress_bar.grid(row=1, column=0, columnspan=2, sticky="nsew")
        self.progress_bar.set(0)
        self.progress_bar.start()

class IntegerSpinbox(customtkinter.CTkFrame):
    def __init__(self, *args,
                 width: int = 100,
                 height: int = 32,
                 step_size: int = 1,
                 min_value: int = 0,
                 command: Callable = None,
                 **kwargs):
        super().__init__(*args, width=width, height=height, **kwargs)

        self.step_size = step_size
        self.min_value = min_value
        self.command = command

        self.grid_columnconfigure((0, 2), weight=0)
        self.grid_columnconfigure(1, weight=1)

        self.subtract_button = customtkinter.CTkButton(self, text="-", width=height-6, height=height-6,
                                                       command=self.subtract_button_callback)
        self.subtract_button.grid(row=0, column=0, padx=(3, 0), pady=3)

        self.entry = customtkinter.CTkEntry(self, width=width-(2*height), height=height-6, border_width=0)
        self.entry.grid(row=0, column=1, columnspan=1, padx=3, pady=3, sticky="ew")

        self.add_button = customtkinter.CTkButton(self, text="+", width=height-6, height=height-6,
                                                  command=self.add_button_callback)
        self.add_button.grid(row=0, column=2, padx=(0, 3), pady=3)

        self.entry.insert(0, "0")
        
        # Configure validatecommand to allow only integers
        vcmd = (self.entry.register(self.validate_input), '%P')
        self.entry.configure(validate='key', validatecommand=vcmd)

    def add_button_callback(self):
        try:
            value = int(self.entry.get()) + self.step_size
            self.entry.delete(0, "end")
            self.entry.insert(0, max(self.min_value, value))  # Ensure the value is not less than 1
            if self.command is not None:
                self.command()
        except ValueError:
            return

    def subtract_button_callback(self):
        try:
            value = int(self.entry.get()) - self.step_size
            self.entry.delete(0, "end")
            self.entry.insert(0, max(self.min_value, value))  # Ensure the value is not less than 0
            if self.command is not None:
                self.command()
        except ValueError:
            return

    def validate_input(self, new_value):
        # Validate that the input is a non-negative integer
        return re.match(r'^\d*$', new_value) is not None

    def get(self) -> int:
        try:
            return int(self.entry.get())
        except ValueError:
            return 0

    def set(self, value: int):
        self.entry.delete(0, "end")
        self.entry.insert(0, max(self.min_value, value))  # Ensure the value is not less than 0

    def configure(self, state):
        self.subtract_button.configure(state=state)
        self.add_button.configure(state=state)
        self.entry.configure(state=state)
gui/widgets.py
RedDeadDepresso/BAAuto
from util.logger import Logger
from util.utils import GoTo, Region, Utils


class BountyModule(object):
    def __init__(self, config):
        """Initializes the Bounty module.

        Args:
            config (Config): BAAuto Config instance
        """
        self.enabled = True
        self.config = config
        
        # Define the stages and their corresponding configuration settings
        self.stage = {
            'overpass': self.config.bounty['overpass'],
            'desert_railroad': self.config.bounty['desert_railroad'],
            'classroom': self.config.bounty['classroom']
        }

        # Define regions for various elements on the screen
        self.region = {
            'tickets': None,
            'overpass': (800,200),
            'desert_railroad': (800,310),
            'classroom': (800, 410),
            'stage_list': Region(677, 132, 747, 678)
        }
        
    def determine_tickets_region(self):
        if Utils.assets == 'EN':
            self.region['tickets'] = Region(225, 75, 45, 45)
        elif Utils.assets == 'CN':
            self.region['tickets'] = Region(155,80, 50, 40)

    def bounty_logic_wrapper(self):
        # Navigate to the Bounty campaign
        GoTo.sub_campaign('bounty')

        # Read available locations from tickets and create a queue
        locations_queue = {k:v for k,v in self.stage.items() if v["run_times"] != 0}
        if locations_queue == {}:
            Logger.log_warning = "Bounty was enabled but all locations run times were set to 0. Unable to proceed."
            return
        self.determine_tickets_region()
        if Utils.scan(self.region["tickets"],resize=True)[0]["text"].strip() == "0/6":
            Logger.log_warning("Not enough tickets to run bounty.")
            return
        # Loop through the locations queue
        for entry in locations_queue:
            while Utils.find('goto/bounty'):
                Utils.touch(*self.region[entry])
                Utils.update_screen()

            # Find the region for the current stage
            stage_region = Utils.find_stage(self.stage[entry]["stage"])

            if stage_region:
                # Find and touch the "Enter" button for the stage
                button = Utils.find_button('farming/small_enter', stage_region, self.region['stage_list'])
                if button:
                    while True:
                        Utils.wait_update_screen(1)
                        if not Utils.find('farming/sweep'):
                            Utils.touch_randomly(button)
                            continue
                        outcome = Utils.sweep(locations_queue[entry]["run_times"])  # Perform the sweep action
                        if outcome[0] == "incomplete":
                            if outcome[1] == 0:
                                Logger.log_warning("Not enough tickets to complete sweep")
                            else:
                                Logger.log_warning(f'Ran out of tickets but enough to complete stage {outcome[1]} times instead of {locations_queue[entry]["run_times"]}')
                            return
                        break
                else:
                    Logger.log_error(f'{entry.capitalize()}-{self.stage[entry]["stage"]} not unlocked')
            while not Utils.find("goto/bounty", color=True):
                Utils.touch(55,40)
                Utils.wait_update_screen(1)

modules/bounty.py
RedDeadDepresso/BAAuto
from util.logger import Logger
from util.utils import Utils, Region, GoTo
from tqdm import tqdm

class CafeModule(object):
    def __init__(self, config):
        """Initializes the Cafe module.

        Args:
            config (Config): BAAuto Config instance
        """
        self.enabled = True
        self.config = config
        
        # Define regions for various elements on the screen
        self.region = {
            'invite': Region(800, 620, 55, 70),
            'momotalk': Region(410, 187, 290, 413),
        }    

    def cafe_logic_wrapper(self):
        # Navigate to the Cafe screen
        GoTo.sub_home('cafe')
        Utils.update_screen()
        
        # Invite a student if the configuration allows and a student is available
        if self.config.cafe['invite_student'] and Utils.find("cafe/available"):
            self.find_student()

        # Tap on students if the configuration allows
        if self.config.cafe['tap_students']:
            # Define ranges for tapping students in a grid
            y_range = range(140, 543, 50)
            x_range = range(0, 1365, 50)
            total_iterations = len(y_range) * len(x_range)
            
            # Create a progress bar for tapping students
            progress_bar = tqdm(total=total_iterations, unit="taps", colour='cyan', desc='Tapping Students')
            
            # Loop through the grid and tap on students
            for y in y_range:
                for x in x_range:
                    Utils.touch(x, y)
                    progress_bar.update(1)  # Increment the progress bar

        # Claim earnings if the configuration allows
        if self.config.cafe['claim_earnings']:
            while True:
                Utils.wait_update_screen(1)
                if not Utils.find("cafe/earnings"):
                    Utils.touch(1158, 647)  # Touch the earnings button
                else:
                    Utils.touch(640, 520)  # Touch the claim button
                    break
        
        # Return to the home screen
        GoTo.home()

    def find_student(self):
        found = False
        last_student = ""
        if self.config.cafe["student_name"].replace(" ", "") == "":
            Logger.log_warning("Inviting student is turned on but student name is empty. Unable to proceed.")
            return
        Logger.log_info(f'Inviting student: {self.config.cafe["student_name"]}')
        
        # Wait for the momotalk screen to appear
        while not Utils.find("cafe/momotalk"):
            Utils.touch_randomly(self.region['invite'])
            Utils.wait_update_screen(1)
        
        # Continue searching for the student until found or no more students are available
        while not found:
            Utils.wait_update_screen(1)
            
            # Search for the student's name in the momotalk region
            result = Utils.find_word(self.config.cafe['student_name'], self.region['momotalk'])
            
            if result[0]:
                Logger.log_success("Student found!")
                found = True
                
                # Find and touch the invite button for the student
                button = Utils.find_button('cafe/invite', result[1], self.region['momotalk'])
                
                if button:
                    while True:
                        Utils.wait_update_screen(1)
                        
                        # Confirm the invitation
                        if Utils.find_and_touch('cafe/confirm'):
                            break
                        Utils.touch_randomly(button)
                else:
                    Logger.log_error('Error: Could not find button')
            elif last_student == result[1]:
                Logger.log_error('Student not found. Please check spelling.')
                break
            else:
                last_student = result[1] if not result[1].isdigit() else last_student
                Utils.swipe(600, 500, 600, 200)  # Swipe to scroll the momotalk region

modules/cafe.py
RedDeadDepresso/BAAuto
from util.logger import Logger
from util.utils import Utils, GoTo

class ClaimRewardsModule(object):
    def __init__(self, config):
        """Initializes the Claim Rewards module.

        Args:
            config (Config): BAAuto Config instance
        """
        self.enabled = True
        self.config = config

    def claim_rewards_logic_wrapper(self):
        # Check if there are tasks to claim rewards from
        if self.config.claim_rewards['tasks']:
            # Navigate to the tasks section
            GoTo.sub_home('tasks')
            
            while True:
                Utils.update_screen()
                
                # Check if the "Claim All" button is available in tasks
                if Utils.find('claim_rewards/tasks_claim_all', color=True):
                    Utils.touch(1150, 670)  # Touch the "Claim All" button
                    GoTo.sub_home('tasks')  # Return to the tasks section
                    continue
                
                # Check if individual task rewards can be claimed
                if Utils.find('claim_rewards/claim', color=True):
                    Utils.touch(970, 675)  # Touch the individual task reward
                    GoTo.sub_home('tasks')  # Return to the tasks section
                    continue
                
                break  # Exit the loop if no more rewards can be claimed in tasks

        # Check if there are rewards to claim from the mailbox
        if self.config.claim_rewards['mailbox']:
            # Navigate to the club and then the mailbox
            GoTo.sub_home('club')
            GoTo.sub_home('mailbox')
            
            while True:
                Utils.update_screen()
                
                # Check if the "Claim All" button is available in the mailbox
                if Utils.find('claim_rewards/mailbox_claim_all', color=True):
                    Utils.touch(1150, 670)  # Touch the "Claim All" button
                    GoTo.sub_home('mailbox')  # Return to the mailbox
                    continue
                
                break  # Exit the loop if no more rewards can be claimed in the mailbox
        
        # Return to the home screen and reset reward claim flags
        GoTo.home()
modules/claim_rewards.py
RedDeadDepresso/BAAuto
from util.utils import Region, Utils
from util.logger import Logger
from util.config import Config
from util.utils import Utils, GoTo
import time

class LoginModule(object):

    def __init__(self, config):
        """Initializes the Login module.

        Args:
            config (Config): BAAuto Config instance
        """
        self.enabled = True
        self.config = config

    def login_logic_wrapper(self):
        GoTo.home()




modules/login.py
RedDeadDepresso/BAAuto
from util.logger import Logger
from util.config import Config
from util.utils import Utils, Region, GoTo
from datetime import datetime, time
import json
import copy
import os 

class MissionModule(object):
    def __init__(self, config):
        """Initializes the Mission Module.

        Args:
            config (Config): BAAuto Config instance
        """
        self.enabled = True
        self.config = config
        self.acronym = {
            'N': 'Mission Normal',
            'H': 'Mission Hard',
            'BR': 'Commissions Base Defense',
            'IR': 'Commissions Item Retrieval',
            'E': "Event"
        }
        self.region = {
            'ap': Region(555, 0, 105, 45),
            'stage_list': Region(677, 132, 747, 678)
        }

    def mission_logic_wrapper(self):
        clear_queue = False
        recharged = False
        location = None
        current_ap, max_ap = self.read_ap()
        Logger.log_info(f'AP detected: {current_ap}/{max_ap}')

        # Check if there's enough AP to proceed
        if current_ap < 10:
            Logger.log_warning('Not enough AP to complete stages.')
            if self.config.mission['recharge_ap']:
                Logger.log_info('Attempting to recharge AP...')
                self.recharge_ap()
                current_ap, max_ap = self.read_ap()
                if current_ap < 10:
                    Logger.log_warning('Still not enough AP to complete stages. Unable to proceed.')
            Logger.log_info('Recharge AP disabled. Unable to complete stages.')
            return

        # Check if it's time to clear the queue or reset daily
        if self.config.mission['reset_daily']:
            clear_queue = self.check_reset_time()

        # If the queue is empty or needs clearing, populate it
        if clear_queue or self.config.mission['queue'] == []:
            preferred_template = self.config.mission['preferred_template']
            self.config.mission['queue'] = copy.deepcopy(self.config.mission["templates"][preferred_template])

        # Process each entry in the queue
        while self.config.mission['queue'] != []:
            entry = self.config.mission['queue'][0]
            if entry[0].upper() in ['N', 'H']:
                location = 'mission'
                sweep_status = self.mission(entry)
            elif entry[0].upper() in ['BD', 'IR']:
                location = 'commissions'
                sweep_status = self.commissions(entry)
            else:
                location = 'event'
                sweep_status = self.event(entry)                

            # Handle incomplete sweeps
            if sweep_status[0] == 'incomplete':
                current_ap, max_ap = self.read_ap(location)
                if not recharged and self.config.mission['recharge_ap']:
                    if current_ap < 20:
                        Logger.log_info('Attempting to recharge AP...')
                        self.config.mission['queue'][0][2] -= sweep_status[1]
                        self.update_config()
                        self.recharge_ap()
                        recharged = True
                        continue
                else:
                    self.config.mission['queue'][0][2] -= sweep_status[1]
                    self.update_config()
                    Logger.log_info(f'AP left: {current_ap} / {max_ap}. Unable to complete stages.')
                    return

            self.config.mission['queue'].pop(0)
            self.update_config()

    def mission(self, entry):
        # Logic for mission stages
        GoTo.sub_campaign('mission')
        mode, stage, run_times = entry
        Logger.log_info(f'Current stage: {self.acronym[mode]} {stage}, run {run_times} times')
        area = int(stage.split('-')[0].strip())

        if not self.find_area(area):
            Logger.log_error('Area not found, please check spelling. Skipping stage...')
            return ('failed')

        if mode.upper() == 'H':
            if run_times > 3:
                Logger.log_warning(f'Hard {stage} was set to be swept {run_times}. Reset to 3 as it surpass limit.')
                run_times = 3

            while Utils.find('farming/normal'):
                Utils.touch(1065, 160)
                Utils.update_screen()
        else:
            while not Utils.find('farming/normal'):
                Utils.touch(915, 160)
                Utils.wait_update_screen(1)

        return self.attempt_stage(mode, stage, run_times)

    def commissions(self, entry):
        # Logic for commission stages
        GoTo.sub_campaign('commissions')
        mode, stage, run_times = entry

        while Utils.find('goto/commissions'):
            if mode.upper() == 'BD':
                Utils.touch(800, 200)
            else:
                Utils.touch(800, 310)
            Utils.update_screen()

        return self.attempt_stage(mode, stage, run_times)
    
    def event(self, entry):
        if self.config.mission["event"] == True:
            event_banner_path = f"assets/{Utils.assets}/goto/event_banner.png"
            mode, stage, run_times = entry
            Logger.log_info(f'Current stage: {self.acronym[mode]} {stage}, run {run_times} times')
            if os.path.exists(event_banner_path):
                GoTo.event()
                while Utils.find_and_touch('farming/quest'):
                    Utils.wait_update_screen(1)
                return self.attempt_stage(mode, stage, run_times)
            else:
                Logger.log_warning("Event Banner not found. Unable to run event stages.")
        return ("failed")


    def find_area(self, desired_area):
        # Find and navigate to the desired area
        roi = Region(108, 178, 62, 37)
        left = (45, 360)
        right = (1240, 360)
        last_area = None

        while True:
            Utils.wait_update_screen(1)
            detected_area = int(Utils.scan(roi, resize=True)[0]['text'])

            if detected_area == last_area:
                return False
            elif detected_area < desired_area:
                Utils.touch(*right)
                last_area = detected_area
            elif detected_area > desired_area:
                Utils.touch(*left)
                last_area = detected_area
            else:
                return True

    def attempt_stage(self, mode, stage, run_times):
        # Logic for attempting a stage
        stage_region = Utils.find_stage(stage)

        if stage_region:
            if mode in ['H', 'E']:
                button = Utils.find_button('farming/big_enter', stage_region, self.region['stage_list'])
            else:
                button = Utils.find_button('farming/small_enter', stage_region, self.region['stage_list'])

            if button:
                while True:
                    Utils.update_screen()
                    if not Utils.find('farming/sweep'):
                        Utils.touch_randomly(button)
                        continue
                    outcome = Utils.sweep(run_times)
                    if outcome[0] == "incomplete" and outcome[1] != 0:
                        Logger.log_warning(f'Ran out of AP but enough to complete stage {outcome[1]} times instead of {run_times}')
                    return outcome
            else:
                Logger.log_error(f'{self.acronym[mode]} {stage} is not unlocked')
        return ('failed')

    def read_ap(self, location=None):
        # Read and return current AP and max AP
        if location is None:
            GoTo.sub_home('campaign')
        elif location == "event":
            GoTo.event()    
        else:
            GoTo.sub_campaign(location)
        waiting_time = 0    
        ap = [""]
        # solution to AP being hidden by tasks notifications
        while waiting_time <= 5 and not ap[0].isdigit():
            Utils.wait_update_screen(1)
            ap = Utils.scan(self.region['ap'])[0]['text']
            waiting_time += 1
        if not ap[0].isdigit():
            Logger.log_error("Error reading AP")
        return [int(x) for x in [x.strip() for x in ap.split('/')]]

    def recharge_ap(self):
        # Recharge AP if configured to do so
        if self.config.mission['recharge_ap']:
            GoTo.sub_home('cafe')
            while True:
                Utils.wait_update_screen(1)
                if not Utils.find("cafe/earnings"):
                    Utils.touch(1158, 647)
                else:
                    Utils.touch(640, 520)
                    break
            from modules.claim_rewards import ClaimRewardsModule
            ClaimRewardsModule(self.config).claim_rewards_logic_wrapper()

    def check_reset_time(self):
        # Check if it's time to reset the queue
        current_datetime = datetime.now().replace(microsecond=0)  # Round to the nearest second
        current_date = current_datetime.date()
        current_time = current_datetime.time()
        last_run_datetime = datetime.strptime(self.config.mission["last_run"], "%Y-%m-%d %H:%M:%S")
        reset_time = datetime.strptime(self.config.mission["reset_time"], "%H:%M:%S").time()

        if current_date >= last_run_datetime.date() and current_time >= reset_time:
            return True
        return False

    def update_config(self):
        # Update the configuration file with the current queue and last run time
        with open('config.json', 'r') as json_file:
            config_data = json.load(json_file)
        config_data["farming"]['mission']['queue'] = self.config.mission['queue']
        config_data["farming"]['mission']["last_run"] = str(datetime.now().replace(microsecond=0))
        with open("config.json", "w") as json_file:
            json.dump(config_data, json_file, indent=2)

modules/mission.py
RedDeadDepresso/BAAuto
from util.logger import Logger
from util.utils import GoTo, Region, Utils


class ScrimmageModule(object):
    def __init__(self, config):
        """Initializes the Bounty module.

        Args:
            config (Config): BAAuto Config instance
        """
        self.enabled = True
        self.config = config
        
        # Define the stages and their corresponding configuration settings
        self.stage = {
            'trinity': self.config.scrimmage['trinity'],
            'gehenna': self.config.scrimmage['gehenna'],
            'millennium': self.config.scrimmage['millennium']
        }

        # Define regions for various elements on the screen
        self.region = {
            'tickets': None,
            'trinity': (800,200),
            'gehenna': (800,310),
            'millennium': (800, 410),
            'stage_list': Region(677, 132, 747, 678)
        }

    def determine_tickets_region(self):
        if Utils.assets == 'EN':
            self.region['tickets'] = Region(225, 75, 45, 45)
        elif Utils.assets == 'CN':
            self.region['tickets'] = Region(155,80, 50, 40)

    def scrimmage_logic_wrapper(self):
        # Navigate to the Bounty campaign
        GoTo.sub_campaign('scrimmage')

        # Read available locations from tickets and create a queue
        locations_queue = {k:v for k,v in self.stage.items() if v["run_times"] != 0}
        if locations_queue == {}:
            Logger.log_warning = "Scrimmage was enabled but all locations run times were set to 0. Unable to proceed."
            return
        self.determine_tickets_region()
        if Utils.scan(self.region["tickets"],resize=True)[0]["text"].strip() == "0/6":
            Logger.log_warning("Not enough tickets to run bounty.")
            return
        # Loop through the locations queue
        for entry in locations_queue:
            while Utils.find('goto/scrimmage'):
                Utils.touch(*self.region[entry])
                Utils.update_screen()

            # Find the region for the current stage
            stage_region = Utils.find_stage(self.stage[entry]["stage"])

            if stage_region:
                # Find and touch the "Enter" button for the stage
                button = Utils.find_button('farming/small_enter', stage_region, self.region['stage_list'])
                if button:
                    while True:
                        Utils.wait_update_screen(1)
                        if not Utils.find('farming/sweep'):
                            Utils.touch_randomly(button)
                            continue
                        outcome = Utils.sweep(locations_queue[entry]["run_times"])  # Perform the sweep action
                        if outcome[0] == "incomplete":
                            if outcome[1] == 0:
                                Logger.log_warning("Not enough tickets to complete sweep")
                            else:
                                Logger.log_warning(f'Ran out of tickets but enough to complete stage {outcome[1]} times instead of {locations_queue[entry]["run_times"]}')
                            return
                        break
                else:
                    Logger.log_error(f'{entry.capitalize()}-{self.stage[entry]["stage"]} not unlocked')
            while not Utils.find("goto/scrimmage", color=True):
                Utils.touch(55,40)
                Utils.wait_update_screen(1)

modules/scrimmage.py
RedDeadDepresso/BAAuto
from util.logger import Logger
from util.utils import Utils, Region, GoTo
from tqdm import tqdm
import re

class TacticalChallengeModule(object):
    def __init__(self, config):
        """Initializes the Tactical Challenge module.

        Args:
            config (Config): BAAuto Config instance
        """
        self.enabled = True
        self.config = config
        self.rank = self.config.tactical_challenge['rank']
        self.wins = 0
        self.loses = 0

        # Define regions for various elements on the screen
        self.region = {
            'tickets' : None,
            'rival_1': Region(615, 190, 150, 60), 
            'rival_2': Region(615, 345, 150, 60),
            'rival_3': Region(615, 505, 150, 60),
            'outcome': Region(405, 240, 465, 210)
        }

    def determine_tickets_region(self):
        if Utils.assets == 'EN':
            self.region['tickets'] = Region(208, 472, 44, 33)
        elif Utils.assets == 'CN':
            self.region['tickets'] = Region(156,470, 54, 45)

    def tactical_challenge_logic_wrapper(self):
        # Go to the tactical challenge sub-campaign
        GoTo.sub_campaign('tactical_challenge')
        self.claim()
        self.determine_tickets_region()
        ticket_owned = self.read_tickets()
        
        while ticket_owned:
            match = self.find_match()
            
            while match:
                Utils.wait_update_screen()
                
                # Check if the formation screen is displayed
                if Utils.find_and_touch('tactical_challenge/formation'):
                    continue
                
                # Check if there are no tickets available
                if Utils.find_and_touch('tactical_challenge/no_tick'):
                    continue
                
                # Check if the mobilize button is available
                if Utils.find('tactical_challenge/mobilise'):
                    self.read_outcome()
                    ticket_owned = self.read_tickets()
                    
                    if not ticket_owned:
                        return
                    
                    self.progress_bar(45)
                    break
                
                # Check if there are no tickets left
                if Utils.find('tactical_challenge/no_tickets'):
                    Logger.log_warning(f'Run out of tickets')
                    return

    def claim(self):
        # Attempt to claim rewards
        for i in range(2):
            while True:
                Utils.update_screen()
                if not Utils.find_and_touch(f'tactical_challenge/claim/{i}', color=True):
                    GoTo.sub_campaign('tactical_challenge')
                    break

    def read_tickets(self):
        # Read the number of tickets owned
        Utils.update_screen()
        tickets_owned = Utils.scan(self.region['tickets'])[0]['text']
        tickets_owned = tickets_owned.strip(' ')
        Logger.log_msg(f'Tickets owned: {tickets_owned}')

        if tickets_owned == '0/5':
            Logger.log_warning(f'Run out of tickets')
            Logger.log_info(f'Total Wins: {self.wins}')
            Logger.log_info(f'Total Loses: {self.loses}')
            return False

        return tickets_owned

    def find_match(self):
        # Find and select a match based on rank preference
        Utils.touch(1160, 145)
        Utils.wait_update_screen(2)
        extract_integer = lambda input_string: int(re.search(r'\d+', input_string).group()) if re.search(r'\d+', input_string) else None
        options = [
            [self.region['rival_1'], extract_integer(Utils.scan(self.region['rival_1'])[0]['text'])],
            [self.region['rival_2'], extract_integer(Utils.scan(self.region['rival_2'])[0]['text'])],
            [self.region['rival_3'], extract_integer(Utils.scan(self.region['rival_3'])[0]['text'])]
        ]
        Logger.log_info(f'Ranks detected: {options[0][1]}, {options[1][1]}, {options[2][1]}')
        options.sort(key=lambda x: x[1], reverse=True)

        if self.rank.lower() == "highest":
            match = options[2]
            Logger.log_info(f'Ranks set to highest -> {match[1]}')
        elif self.rank.lower() == "middle":
            match = options[1]
            Logger.log_info(f'Ranks set to middle -> {match[1]}')
        else:
            match = options[0]
            Logger.log_info(f'Ranks set to lowest -> {match[1]}')

        Utils.touch_randomly(match[0])
        Utils.wait_update_screen(2)
        return match

    def progress_bar(self, total):
        # Display a progress bar while waiting
        with tqdm(total=total, ncols=100, bar_format='{l_bar}{bar}{n_fmt}/{total_fmt}s', desc='Waiting Standby Time', colour='cyan') as pbar:
            for i in range(total):
                pbar.update(1)
                Utils.script_sleep(1)

    def read_outcome(self):
        while True:
            Utils.update_screen()
            
            # Check if the mobilize button is still available
            if Utils.find_and_touch('tactical_challenge/mobilise'):
                continue                
            # Check if the battle result screen is displayed
            if Utils.find('tactical_challenge/battle_result'):
                outcome = Utils.find_word('lose', self.region['outcome'])
                
                if outcome[0]:
                    Logger.log_msg('Result Battle: Lose')
                    self.loses += 1
                else:
                    Logger.log_msg('Result Battle: Win')
                    self.wins += 1

                GoTo.sub_campaign('tactical_challenge')
                break

        while True:
            # Check if the "New Best Season" message is displayed
            if Utils.find('tactical_challenge/best'):
                Logger.log_success('New Best Season Scored!')

            GoTo.sub_campaign('tactical_challenge')
            break

modules/tactical_challenge.py
RedDeadDepresso/BAAuto
import sys
import re
import traceback
import argparse
try:
    from modules.login import LoginModule
    from modules.cafe import CafeModule
    from modules.bounty import BountyModule
    from modules.scrimmage import ScrimmageModule
    from modules.mission import MissionModule
    from modules.tactical_challenge import TacticalChallengeModule
    from modules.claim_rewards import ClaimRewardsModule
    from util.adb import Adb
    from util.config import Config
    from util.logger import Logger
    from util.utils import Utils
    from util.exceptions import GameStuckError, GameNotRunningError, ReadOCRError
    class BAAuto(object):
        modules = {
            'login': None,
            'cafe': None,
            'club': None,
            'mission': None,
            'bounty': None,
            'scrimmage': None,
            'tactical_challenge': None,
            'claim_rewards': None,
        }

        def __init__(self, config):
            """Initializes the primary azurlane-auto instance with the passed in
            Config instance; 

            Args:
                config (Config): BAAuto Config instance
            """
            self.config = config
            if self.config.login['enabled']:
                self.modules['login'] = LoginModule(self.config)
            if self.config.cafe['enabled']:
                self.modules['cafe'] = CafeModule(self.config)
            if self.config.farming['enabled'] and self.config.bounty['enabled']:
                self.modules['bounty'] = BountyModule(self.config)
            if self.config.farming['enabled'] and self.config.scrimmage['enabled']:
                self.modules['scrimmage'] = ScrimmageModule(self.config)
            if self.config.farming['enabled'] and self.config.mission['enabled']:
                self.modules['mission'] = MissionModule(self.config)
            if self.config.farming['enabled'] and self.config.tactical_challenge['enabled']:
                self.modules['tactical_challenge'] = TacticalChallengeModule(self.config)
            if self.config.claim_rewards['enabled']:
                self.modules['claim_rewards'] = ClaimRewardsModule(self.config)
                
        def run_login_cycle(self):
            """Method to run the login cycle.
            """
            if self.modules['login']:
                return self.modules['login'].login_logic_wrapper
            return None

        def run_cafe_cycle(self):
            """Method to run the cafe cycle.
            """
            if self.modules['cafe']:
                return self.modules['cafe'].cafe_logic_wrapper
            return None

        def run_bounty_cycle(self):
            """Method to run the bounty cycle.
            """
            if self.modules['bounty']:
                return self.modules['bounty'].bounty_logic_wrapper
            return None
        
        def run_scrimmage_cycle(self):
            """Method to run the scrimmage cycle.
            """
            if self.modules['scrimmage']:
                return self.modules['scrimmage'].scrimmage_logic_wrapper
            return None

        def run_mission_cycle(self):
            """Method to run the mission cycle.
            """
            if self.modules['mission']:
                return self.modules['mission'].mission_logic_wrapper
            return None
        
        def run_tactical_challenge_cycle(self):
            """Method to run the tactica challenge cycle.
            """
            if self.modules['tactical_challenge']:
                return self.modules['tactical_challenge'].tactical_challenge_logic_wrapper
            return None

        def run_claim_rewards_cycle(self):
            """Method to run the claim rewards cycle.
            """
            if self.modules['claim_rewards']:
                return self.modules['claim_rewards'].claim_rewards_logic_wrapper
            return None
        
    # check run-time args
    parser = argparse.ArgumentParser()
    parser.add_argument('-c', '--config',
                        metavar=('CONFIG_FILE'),
                        help='Use the specified configuration file instead ' +
                            'of the default config.ini')
    parser.add_argument('-d', '--debug',
                        help='Enables debugging logs.', action='store_true')
    parser.add_argument('-l', '--legacy',
                        help='Enables sed usage.', action='store_true')
    args = parser.parse_args()
    # check args, and if none provided, load default config
    if args:
        if args.config:
            config = Config(args.config)
        else:
            config = Config('config.json')
        if args.debug:
            Logger.log_info("Enabled debugging.")
            Logger.enable_debugging(Logger)
        if args.legacy:
            Logger.log_info("Enabled sed usage.")
            Adb.enable_legacy(Adb)

    with open('traceback.log', 'w') as f:
        pass

    script = BAAuto(config)

    Adb.service = config.network
    Adb.tcp = False if (Adb.service.find(':') == -1) else True
    adb = Adb()

    if adb.init():
        Logger.log_msg('Successfully connected to the service with transport_id({}).'.format(Adb.transID))
        output = Adb.exec_out('wm size').decode('utf-8').strip()

        if not re.search('1280x720|1280x720', output):
            Logger.log_error("Resolution is not 1280x720, please change it.")
            sys.exit()

        if 'com.nexon.bluearchive' not in Adb.u2device.app_list():
            Logger.log_error("Blue Archive is not installed. Unable to run script.")
            sys.exit()     

        Utils.assets = config.assets
        # screencap init
        Utils.init_screencap_mode(config.screenshot_mode)
        Utils.init_ocr_mode()
        Utils.record['restart_attempts'] = config.restart_attempts
    else:
        Logger.log_error('Unable to connect to the service.')
        sys.exit()

except:
    print(f'[ERROR] Script Initialisation Error. For more info, check the traceback.log file.')
    with open('traceback.log', 'w') as f:
        f.write(f'Script Initialisation Error')
        f.write('\n')
        traceback.print_exc(None, f, True)
        f.write('\n')
        sys.exit()

run_cycles = [
    ('Login', script.run_login_cycle),
    ('Cafe', script.run_cafe_cycle),
    ('Bounty', script.run_bounty_cycle),
    ('Scrimmage', script.run_scrimmage_cycle),
    ('Mission/Commissions', script.run_mission_cycle),
    ('Tactical Challenge', script.run_tactical_challenge_cycle),
    ('Claim Rewards', script.run_claim_rewards_cycle)
]
counter = 0
task_started = False
task_restarted = False
while counter != len(run_cycles):
    try:
        enabled = run_cycles[counter][1]()
        if enabled:
            if not task_started:
                Logger.log_info(f'Start Task: {run_cycles[counter][0]}')
                task_started = True
            enabled()
    except GameNotRunningError:
        if not Utils.record['game_started']:
            Logger.log_warning("Blue Archive is not running. Attempting to start it...")
            Adb.u2device.app_start("com.nexon.bluearchive", use_monkey=True)
        elif Utils.record['restart_attempts'] > 0:
            Logger.log_warning("Blue Archive crashed. Attempting to restart it...")
            Adb.u2device.app_start("com.nexon.bluearchive", use_monkey=True)
            Utils.record['restart_attempts'] -= 1
            Logger.log_warning(f"Restart attempts left: {Utils.record['restart_attempts']}")
        else:
            Logger.log_warning(f"Blue Archive is not running but ran out of restart attempts. Unable to restart game and run script.")
            sys.exit(1)     
        Utils.reset_record()
    except GameStuckError:
        if Utils.record['restart_attempts'] > 0:
            Logger.log_warning("Blue Archive is stuck. Attempting to restart it...")
            Adb.u2device.app_stop("com.nexon.bluearchive")
            Adb.u2device.app_start("com.nexon.bluearchive", use_monkey=True)
            Utils.record['restart_attempts'] -= 1
            Logger.log_warning(f"Restart attempts left: {Utils.record['restart_attempts']}")
            Utils.reset_record()
        else:
            Logger.log_warning(f"Blue Archive is stuck but ran out of restart attempts. Unable restart game and run script.")
            sys.exit(1)     
    except ReadOCRError:
        if not task_restarted:
            Logger.log_warning("Failed to read OCR. Did you change page? Restarting task...")
            task_restarted = True
        else:
            Logger.log_error("Failed to read OCR again. Skipping task...")
            counter += 1
            task_started = False
            task_restarted = False
    except KeyboardInterrupt:
        # handling ^C from user
        Logger.log_msg("Received keyboard interrupt from user. Closing...")
        sys.exit(0)
    except SystemExit:
        pass
    except:
        Logger.log_error(f'Task error: {run_cycles[counter][0]}. For more info, check the traceback.log file.')
        with open('traceback.log', 'a') as f:
            f.write(f'[{run_cycles[counter][0]}]')
            f.write('\n')
            traceback.print_exc(None, f, True)
            f.write('\n')
        counter += 1
        task_started = False
    else:
        if enabled:
            Logger.log_success(f'Task completed: {run_cycles[counter][0]}')
        counter += 1
        task_started = False

        


        

script.py
RedDeadDepresso/BAAuto
import subprocess
import uiautomator2 as u2
from util.logger import Logger

class Adb(object):

    legacy = False
    service = ''
    transID = ''
    tcp = False
    u2device = None

    def init(self):
        """Kills and starts a new ADB server
        """
        self.kill_server()
        return self.start_server() 


    def enable_legacy(self):
        """Method to enable legacy adb usage.
        """
        self.legacy = True
        return

    def start_server(self):
        """
        Starts the ADB server and makes sure the android device (emulator) is attached.

        Returns:
            (boolean): True if everything is ready, False otherwise.
        """
        cmd = ['adb', 'start-server']
        subprocess.call(cmd)
        """ hooking onto here, previous implementation of get-state
         is pointless since the script kills the ADB server in advance,
         now seperately connect via usb or tcp, tcp variable is set by main script"""
        if self.tcp:
            Adb.u2device = u2.connect_adb_wifi(Adb.service)
            return self.connect_tcp()
        else:
            Adb.u2device = u2.connect_usb(Adb.service)
            return self.connect_usb()

    def connect_tcp(self):
        cmd = ['adb', 'connect', self.service]
        response = subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode('utf-8')
        if (response.find('connected') == 0) or (response.find('already') == 0):
            self.assign_serial()
            if (self.transID is not None) and self.transID:
                return True
            Logger.log_error('Failure to assign transport_id.')  
            Logger.log_error('Please try updating your ADB installation. Current ADB version:')
            self.print_adb_version()
        return False

    def connect_usb(self):
        self.assign_serial()
        if (self.transID is not None) and self.transID:
            cmd = ['adb', '-t', self.transID, 'wait-for-device']
            Logger.log_msg('Waiting for device [' + self.service + '] to be authorized...')
            subprocess.call(cmd)
            Logger.log_msg('Device [' + self.service + '] authorized and connected.')
            return True
        Logger.log_error('Failure to assign transport_id. Is your device connected? Or is "transport_id" not supported in current ADB version? ')
        Logger.log_error('Try updating ADB if "transport_id:" does not exist in the info of your device when running "adb devices -l" in cmd.')
        Logger.log_error('Current ADB version:')
        self.print_adb_version()
        return False


    @staticmethod
    def kill_server():
        """Kills the ADB server
        """
        if Adb.u2device:
            Adb.u2device.disconnect()
        cmd = ['adb', 'kill-server']
        subprocess.call(cmd)

    @staticmethod
    def exec_out(args):
        """Executes the command via exec-out

        Args:
            args (string): Command to execute.

        Returns:
            tuple: A tuple containing stdoutdata and stderrdata
        """
        cmd = ['adb', '-t', Adb.transID , 'exec-out'] + args.split(' ')
        process = subprocess.Popen(cmd, stdout = subprocess.PIPE)
        return process.communicate()[0]

    @staticmethod
    def shell(args):
        """Executes the command via adb shell

        Args:
            args (string): Command to execute.
        """
        cmd = ['adb', '-t', Adb.transID ,'shell'] + args.split(' ')
        Logger.log_debug(str(cmd))
        subprocess.call(cmd)

    @staticmethod
    def cmd(args):
        """Executes a general command of ADB

        Args:
            args (string): Command to execute.
        """
        cmd = ['adb', '-t', Adb.transID] + args.split(' ')
        Logger.log_debug(str(cmd))
        process = subprocess.Popen(cmd, stdout = subprocess.PIPE)
        return process.communicate()[0]

    @classmethod
    def assign_serial(cls):
        cmd = ['adb', 'devices', '-l']
        response = subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode('utf-8').splitlines()
        cls.sanitize_device_info(response)
        if not response:
            Logger.log_error('adb devices -l yielded no lines with "transport_id:"')
        cls.transID = cls.get_serial_trans(cls.service, response)

    @staticmethod
    def sanitize_device_info(string_list):
        for index in range(len(string_list) - 1, -1, -1):
            if 'transport_id:' not in string_list[index]:
                string_list.pop(index)

    @staticmethod
    def get_serial_trans(device, string_list):
        for index in range(len(string_list)):
            if device in string_list[index]:
                return string_list[index][string_list[index].index('transport_id:') + 13:]

    @staticmethod
    def print_adb_version():
        cmd = ['adb', '--version']
        response = subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode('utf-8').splitlines()
        for version in response:
            Logger.log_error(version)

util/adb.py
RedDeadDepresso/BAAuto
import sys
from copy import deepcopy
from util.logger import Logger
import util.config_consts
import json

class Config(object):
    """Config module that reads and validates the config to be passed to
    azurlane-auto
    """

    def __init__(self, config_file):
        """Initializes the config file by changing the working directory to the
        root azurlane-auto folder and reading the passed-in config file.

        Args:
            config_file (string): Name of config file.
        """
        Logger.log_msg("Initializing config module")
        self.config_file = config_file
        self.ok = False
        self.initialized = False
        self.login = {'enabled': False}
        self.cafe = {'enabled': False}
        self.farming = {'enabled': False}
        self.tactical_challenge = {'enabled': False}
        self.bounty = {'enabled': False}
        self.scrimmage = {'enabled': False}
        self.mission = {'enabled': False}
        self.claim_rewards = {'enabled': False}
        self.network = None
        self.assets = None
        self.screenshot_mode = None
        self.restart_attempts = 0
        self.read()

    def read(self):
        backup_config = deepcopy(self.__dict__)

        # Read the JSON file
        try:
            with open(self.config_file, 'r') as json_file:
                config_data = json.load(json_file)
        except FileNotFoundError:
            Logger.log_error(f"Config file '{self.config_file}' not found.")
            sys.exit(1)
        except json.JSONDecodeError:
            Logger.log_error(f"Invalid JSON format in '{self.config_file}'.")
            sys.exit(1)

        self.login = config_data.get('login', {'enabled': False})
        self.network = config_data["login"]["network"]
        self.assets = config_data["login"]["server"]
        self.restart_attempts = config_data["login"]["restart_attempts"]
        self.cafe = config_data.get('cafe', {'enabled': False})
        self.farming = config_data.get('farming', {'enabled':False})
        self.tactical_challenge = config_data["farming"]["tactical_challenge"]
        self.bounty = config_data["farming"]["bounty"]
        self.scrimmage = config_data["farming"]["scrimmage"]
        self.mission = config_data["farming"]["mission"]
        self.claim_rewards = config_data.get('claim_rewards', {'enabled': False})

        consts = util.config_consts.UtilConsts.ScreenCapMode
        screenshot_mode = config_data.get('login', {}).get('screenshot_mode', '').upper()
        vals = {'SCREENCAP_PNG':consts.SCREENCAP_PNG, 'SCREENCAP_RAW':consts.SCREENCAP_RAW, 
                'UIAUTOMATOR2': consts.UIAUTOMATOR2, 'ASCREENCAP':consts.ASCREENCAP}
        
        if screenshot_mode not in ['SCREENCAP_PNG', 'SCREENCAP_RAW', 'UIAUTOMATOR2', 'ASCREENCAP']:
            raise ValueError("Invalid screenshot mode")
        
        self.screenshot_mode = vals[screenshot_mode]

        self.validate()

        if (self.ok and not self.initialized):
            Logger.log_msg("Starting BAAuto!")
            self.initialized = True
            self.changed = True
        elif (not self.ok and not self.initialized):
            Logger.log_error("Invalid config. Please check your config file.")
            sys.exit(1)
        elif (not self.ok and self.initialized):
            Logger.log_warning("Config change detected, but with problems. Rolling back config.")
            self._rollback_config(backup_config)
        elif (self.ok and self.initialized):
            if backup_config != self.__dict__:
                Logger.log_warning("Config change detected. Hot-reloading.")
                self.changed = True
        
    def validate(self):
        """Method to validate the passed-in config file
        """
        if not self.initialized:
            Logger.log_msg("Validating config")
        self.ok = True

        valid_servers = ['EN', 'CN']
        if self.assets not in valid_servers:
            if len(valid_servers) < 2:
                Logger.log_error("Invalid server assets configured. Only {} is supported.".format(''.join(valid_servers)))
            else:
                Logger.log_error("Invalid server assets configured. Only {} and {} are supported.".format(', '.join(valid_servers[:-1]), valid_servers[-1]))
            self.ok = False

        if not any(module['enabled'] for module in [self.login, self.cafe, self.farming, self.claim_rewards]):
            Logger.log_error("All modules are disabled, consider checking your config.")
            self.ok = False

    def _rollback_config(self, config):
        """Method to roll back the config to the passed-in config's.
        Args:
            config (dict): previously backed up config
        """
        for key in config:
            setattr(self, key, config['key'])

util/config.py
RedDeadDepresso/BAAuto
import enum

class UtilConsts(object):

    def __new__(cls):
        return cls

    class ScreenCapMode(enum.Enum):
        SCREENCAP_PNG = enum.auto()
        SCREENCAP_RAW = enum.auto()
        UIAUTOMATOR2 = enum.auto()
        ASCREENCAP = enum.auto()



util/config_consts.py
RedDeadDepresso/BAAuto
class GameStuckError(Exception):
    pass

class GameNotRunningError(Exception):
    pass

class ReadOCRError(Exception):
    pass
util/exceptions.py
RedDeadDepresso/BAAuto
# kcauto  Copyright (C) 2017  Minyoung Choi

from time import strftime
import platform
import subprocess

class Logger(object):

    debug = False

    if platform.system().lower() == 'windows':
        subprocess.call('', shell=True)

    CLR_MSG = '\033[94m'
    CLR_SUCCESS = '\033[92m'
    CLR_WARNING = '\033[93m'
    CLR_ERROR = '\033[91m'
    CLR_INFO = '\u001b[35m'
    CLR_END = '\033[0m'

    def enable_debugging(self):
        """Method to enable debugging logs.
        """
        self.debug = True
        return

    @staticmethod
    def log_format(msg):
        """Method to add a timestamp to a log message

        Args:
            msg (string): log msg

        Returns:
            str: log msg with timestamp appended
        """
        return "[{}] {}".format(strftime("%Y-%m-%d %H:%M:%S"), msg)

    @classmethod
    def log_msg(cls, msg):
        """Method to print a log message to the console, with the 'msg' colors

        Args:
            msg (string): log msg
        """
        print("{}[INFO] {}{}".format(
            cls.CLR_INFO, cls.log_format(msg), cls.CLR_END))

    @classmethod
    def log_success(cls, msg):
        """Method to print a log message to the console, with the 'success'
        colors

        Args:
            msg (string): log msg
        """
        print("{}[SUCCESS] {}{}".format(
            cls.CLR_SUCCESS, cls.log_format(msg), cls.CLR_END))

    @classmethod
    def log_warning(cls, msg):
        """Method to print a log message to the console, with the 'warning'
        colors

        Args:
            msg (string): log msg
        """
        print("{}[WARNING] {}{}".format(
            cls.CLR_WARNING, cls.log_format(msg), cls.CLR_END))

    @classmethod
    def log_error(cls, msg):
        """Method to print a log message to the console, with the 'error'
        colors

        Args:
            msg (string): log msg
        """
        print("{}[ERROR] {}{}".format(
            cls.CLR_ERROR, cls.log_format(msg), cls.CLR_END))

    @classmethod
    def log_info(cls, msg):
        """Method to print a log message to the console, with the 'info'
        colors

        Args:
            msg (string): log msg
        """
        print("{}[INFO] {}{}".format(
            cls.CLR_INFO, cls.log_format(msg), cls.CLR_END))

    @classmethod
    def log_debug(cls, msg):
        """Method to print a debug message to the console, with the 'msg'
        colors

        Args:
            msg (string): log msg
        """
        if not cls.debug:
            return
        print("{}[DEBUG] {}{}".format(
            cls.CLR_INFO, cls.log_format(msg), cls.CLR_END))

util/logger.py
RedDeadDepresso/BAAuto
import cv2
import numpy
import time
import struct
import os
import lz4.block
from imutils import contours, grab_contours
from datetime import datetime, timedelta
from random import uniform, gauss, randint
from scipy import spatial
from util.adb import Adb
from util.logger import Logger
from util.config_consts import UtilConsts
from threading import Thread
from pponnxcr import TextSystem
from util.exceptions import GameStuckError, GameNotRunningError, ReadOCRError

class Region(object):
    x, y, w, h = 0, 0, 0, 0

    def __init__(self, x, y, w, h):
        """Initializes a region.

        Args:
            x (int): Initial x coordinate of the region (top-left).
            y (int): Initial y coordinate of the region (top-left).
            w (int): Width of the region.
            h (int): Height of the region.
        """
        self.x = x
        self.y = y
        self.w = w
        self.h = h

    def equal_approximated(self, region, tolerance=15):
        """Compares this region to the one received and establishes if they are the same
        region tolerating a difference in pixels up to the one prescribed by tolerance.

        Args:
            region (Region): The region to compare to.
            tolerance (int, optional): Defaults to 15.
                Highest difference of pixels tolerated to consider the two Regions equal.
                If set to 0, the method becomes a "strict" equal.
        """
        valid_x = (self.x - tolerance, self.x + tolerance)
        valid_y = (self.y - tolerance, self.y + tolerance)
        valid_w = (self.w - tolerance, self.w + tolerance)
        valid_h = (self.h - tolerance, self.h + tolerance)
        return (valid_x[0] <= region.x <= valid_x[1] and valid_y[0] <= region.y <= valid_y[1] and
                valid_w[0] <= region.w <= valid_w[1] and valid_h[0] <= region.h <= valid_h[1])


    def intersection(self, other):
        """Checks if there is an intersection between the two regions,
        and if that is the case, returns it.
        Taken from https://stackoverflow.com/a/25068722

        Args:
            other (Region): The region to intersect with.

        Returns:
            intersection (Region) or None.
        """
        a = (self.x, self.y, self.x+self.w, self.y+self.h)
        b = (other.x, other.y, other.x+other.w, other.y+other.h)
        x1 = max(min(a[0], a[2]), min(b[0], b[2]))
        y1 = max(min(a[1], a[3]), min(b[1], b[3]))
        x2 = min(max(a[0], a[2]), max(b[0], b[2]))
        y2 = min(max(a[1], a[3]), max(b[1], b[3]))
        if x1<x2 and y1<y2:
            return type(self)(x1, y1, x2-x1, y2-y1)

    def get_center(self):
        """Calculate and returns the center of this region."""
        return [(self.x * 2 + self.w)//2, (self.y * 2 + self.h)//2]

    def contains(self, coords):
        """Checks if the specified coordinates are inside the region.
        Args:
            coords (list or tuple of two elements): x and y coordinates.

        Returns:
            (bool): whether the point is inside the region.
        """
        return (self.x <= coords[0] <= (self.x + self.w)) and (self.y <= coords[1] <= (self.y + self.h))

screen = None
last_ocr = ''
bytepointer = 0


class Utils(object):
    screen = None
    color_screen = None
    small_boss_icon = False
    screencap_mode = None

    DEFAULT_SIMILARITY = 0.9
    assets = ''
    locations = ()
    ocr = None
    record = {
        'last_touch':[None, 0],
        'last_swipe':[None, 0],
        'game_started':False,
        'restart_attempts':0
    }

    @classmethod
    def init_screencap_mode(cls,mode):
        consts = UtilConsts.ScreenCapMode

        cls.screencap_mode = mode

        if cls.screencap_mode == consts.ASCREENCAP:
            # Prepare for ascreencap, push the required libraries
            Adb.exec_out('rm /data/local/tmp/ascreencap')
            cpuArc = Adb.exec_out('getprop ro.product.cpu.abi').decode('utf-8').strip()
            sdkVer = int(Adb.exec_out('getprop ro.build.version.sdk').decode('utf-8').strip())
            ascreencaplib = 'ascreencap_{}'.format(cpuArc)
            if sdkVer in range(21, 26) and os.path.isfile(ascreencaplib):
                Adb.cmd('push {} /data/local/tmp/ascreencap'.format(ascreencaplib))
            else:
                Logger.log_warning(
                    'No suitable version of aScreenCap lib is available locally, using ascreencap_local...')
                if os.path.isfile('ascreencap_local'):
                    Adb.cmd('push ascreencap_local /data/local/tmp/ascreencap')
                else:
                    Logger.log_error(
                        'File "ascreencap_local" not found. Please download the appropriate version of aScreenCap for your device from github.com/ClnViewer/Android-fast-screen-capture and save it as "ascreencap_local"')
                    Logger.log_warning('Since aScreenCap is not ready, falling back to normal adb screencap')
                    Utils.useAScreenCap = False
            Adb.shell('chmod 0777 /data/local/tmp/ascreencap')

    @classmethod
    def init_ocr_mode(cls):
        # https://github.com/hgjazhgj/pponnxcr
        server_to_ocr = {   
        "ZHS": 'zhs',
        "CN":'zht',
        "JP":'ja',
        "EN":'en'
        }
        cls.ocr = TextSystem(server_to_ocr[cls.assets])

    @staticmethod
    def reposition_byte_pointer(byteArray):
        """Method to return the sanitized version of ascreencap stdout for devices
            that suffers from linker warnings. The correct pointer location will be saved
            for subsequent screen refreshes
        """
        global bytepointer
        while(byteArray[bytepointer:bytepointer + 4] != b'BMZ1'):
            bytepointer += 1
            if bytepointer >= len(byteArray):
                raise Exception('Repositioning byte pointer failed, corrupted aScreenCap data received')
        return byteArray[bytepointer:]

    @staticmethod
    def multithreader(threads):
        """Method for starting and threading multithreadable Threads in
        threads.

        Args:
            threads (list): List of Threads to multithread.
        """
        for thread in threads:
            thread.start()
        for thread in threads:
            thread.join()

    @staticmethod
    def script_sleep(base=None, flex=None):
        """Method for putting the program to sleep for a random amount of time.
        If base is not provided, defaults to somewhere along with 0.3 and 0.7
        seconds. If base is provided, the sleep length will be between base
        and 2*base. If base and flex are provided, the sleep length will be
        between base and base+flex. The global SLEEP_MODIFIER is than added to
        this for the final sleep length.

        Args:
            base (int, optional): Minimum amount of time to go to sleep for.
            flex (int, optional): The delta for the max amount of time to go
                to sleep for.
        """
        if base is None:
            time.sleep(uniform(0.4, 0.7))
        else:
            flex = base if flex is None else flex
            time.sleep(uniform(base, base + flex))

    @classmethod
    def update_screen(cls):
        """Uses ADB to pull a screenshot of the device and then read it via CV2
        and then stores the images in grayscale and color to screen and color_screen, respectively.

        Returns:
            image: A CV2 image object containing the current device screen.
        """
        consts = UtilConsts.ScreenCapMode

        global screen
        screen = None
        color_screen = None
        while color_screen is None:
            if Adb.legacy:
                color_screen = cv2.imdecode(
                    numpy.fromstring(Adb.exec_out(r"screencap -p | sed s/\r\n/\n/"), dtype=numpy.uint8),
                    cv2.IMREAD_COLOR)
            else:
                if cls.screencap_mode == consts.SCREENCAP_PNG:
                    start_time = time.perf_counter()
                    color_screen = cv2.imdecode(numpy.frombuffer(Adb.exec_out('screencap -p'), dtype=numpy.uint8),
                                                cv2.IMREAD_COLOR)
                    elapsed_time = time.perf_counter() - start_time
                    Logger.log_debug("SCREENCAP_PNG took {} ms to complete.".format('%.2f' % (elapsed_time * 1000)))
                elif cls.screencap_mode == consts.SCREENCAP_RAW:
                    start_time = time.perf_counter()
                    pixel_size = 4

                    byte_arr = Adb.exec_out('screencap')
                    header_format = 'III'
                    header_size = struct.calcsize(header_format)
                    if len(byte_arr) < header_size:
                        continue
                    header = struct.unpack(header_format, byte_arr[:header_size])
                    width = header[0]
                    height = header[1]
                    if len(byte_arr) != header_size + width * height * pixel_size:
                        continue
                    tmp = numpy.frombuffer(byte_arr, dtype=numpy.uint8, count=width * height * 4, offset=header_size)
                    rgb_img = tmp.reshape((height, width, -1))
                    color_screen = cv2.cvtColor(rgb_img, cv2.COLOR_RGB2BGR)
                    elapsed_time = time.perf_counter() - start_time
                    Logger.log_debug("SCREENCAP_RAW took {} ms to complete.".format('%.2f' % (elapsed_time * 1000)))
                elif cls.screencap_mode == consts.UIAUTOMATOR2:
                    start_time = time.perf_counter()
                    color_screen = Adb.u2device.screenshot(format='opencv')
                    elapsed_time = time.perf_counter() - start_time
                    Logger.log_debug("uiautomator2 took {} ms to complete.".format('%.2f' % (elapsed_time * 1000)))
                elif cls.screencap_mode == consts.ASCREENCAP:
                    start_time = time.perf_counter()
                    raw_compressed_data = Utils.reposition_byte_pointer(
                        Adb.exec_out('/data/local/tmp/ascreencap --pack 2 --stdout'))
                    compressed_data_header = numpy.frombuffer(raw_compressed_data[0:20], dtype=numpy.uint32)
                    if compressed_data_header[0] != 828001602:
                        compressed_data_header = compressed_data_header.byteswap()
                        if compressed_data_header[0] != 828001602:
                            Logger.log_error('If error persists, disable aScreenCap and report traceback')
                            raise Exception(
                                'aScreenCap header verification failure, corrupted image received. HEADER IN HEX = {}'.format(
                                    compressed_data_header.tobytes().hex()))
                    uncompressed_data_size = compressed_data_header[1].item()
                    color_screen = cv2.imdecode(numpy.frombuffer(
                        lz4.block.decompress(raw_compressed_data[20:], uncompressed_size=uncompressed_data_size),
                        dtype=numpy.uint8), cv2.IMREAD_COLOR)
                    elapsed_time = time.perf_counter() - start_time
                    Logger.log_debug("aScreenCap took {} ms to complete.".format('%.2f' % (elapsed_time * 1000)))
                else:
                    raise Exception('Unknown screencap mode')

            screen = cv2.cvtColor(color_screen, cv2.COLOR_BGR2GRAY)
            cls.color_screen = color_screen
            cls.screen = screen

    @classmethod
    def wait_update_screen(cls, time=None):
        """Delayed update screen.

        Args:
            time (int, optional): seconds of delay.
        """
        if time is None:
            cls.script_sleep()
        else:
            cls.script_sleep(time)
        cls.update_screen()

    @staticmethod
    def get_color_screen():
        """Uses ADB to pull a screenshot of the device and then read it via CV2
        and then returns the read image. The image is in a BGR format.

        Returns:
            image: A CV2 image object containing the current device screen.
        """
        color_screen = None
        while color_screen is None:
            if Adb.legacy:
                color_screen = cv2.imdecode(
                    numpy.fromstring(Adb.exec_out(r"screencap -p | sed s/\r\n/\n/"), dtype=numpy.uint8), 1)
            else:
                color_screen = cv2.imdecode(numpy.fromstring(Adb.exec_out('screencap -p'), dtype=numpy.uint8), 1)
        return color_screen

    @classmethod
    def get_mask_from_alpha(cls, image):
        """Calculate the mask of the specified image from its alpha channel.
        The mask returned is a binary image, where the transparent pixels have been blacked.

        Args:
            image (string): image to load and use to calculate the mask.

        Returns:
            mask (numpy array): binary image obtained from the source image's alpha channel.
        """
        source = cv2.imread('assets/{}/{}.png'.format(cls.assets, image), cv2.IMREAD_UNCHANGED)
        # split into BGRA and get A
        alpha_channel = cv2.split(source)[3]
        ret, thresh = cv2.threshold(alpha_channel, 0, 255, cv2.THRESH_BINARY)
        return thresh

    @classmethod
    def get_enabled_ship_filters(cls, filter_categories="rarity"):
        """Method which returns the regions of all the options enabled for the current sorting filter.

        Args:
            filter_categories (string): a string of ';' separated values, which states the filters' categories
                to take into account for the detection.
        Returns:
            regions (list): a list containing the Region objects detected.
        """
        image = cls.get_color_screen()
        categories = filter_categories.split(';')

        # mask area of no interest, effectively creating a roi
        roi = numpy.full((image.shape[0], image.shape[1]), 0, dtype=numpy.uint8)
        if "rarity" in categories:
            cv2.rectangle(roi, (410, 647), (1835, 737), color=(255, 255, 255), thickness=-1)
        if "extra" in categories:
            cv2.rectangle(roi, (410, 758), (1835, 847), color=(255, 255, 255), thickness=-1)

        # preparing the ends of the interval of blue colors allowed, BGR format
        lower_blue = numpy.array([132, 97, 66], dtype=numpy.uint8)
        upper_blue = numpy.array([207, 142, 92], dtype=numpy.uint8)

        # find the colors within the specified boundaries
        mask = cv2.inRange(image, lower_blue, upper_blue)

        # apply roi, result is a black and white image where the white rectangles are the options enabled
        result = cv2.bitwise_and(roi, mask)

        # obtain countours, needed to calculate the rectangles' positions
        cnts = cv2.findContours(result, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
        cnts = grab_contours(cnts)
        # filter regions with a contour area inferior to 190x45=8550 (i.e. not a sorting option)
        cnts = list(filter(lambda x: cv2.contourArea(x) > 8550, cnts))
        # loop over the contours and extract regions
        regions = []
        for c in cnts:
            # calculates contours perimeter
            perimeter = cv2.arcLength(c, True)
            # approximates perimeter to a polygon with the specified precision
            approx = cv2.approxPolyDP(c, 0.04 * perimeter, True)

            if len(approx) == 4:
                # if approx is a rectangle, get bounding box
                x, y, w, h = cv2.boundingRect(approx)
                # print values
                Logger.log_debug("Region x:{}, y:{}, w:{}, h:{}".format(x, y, w, h))
                # appends to regions' list
                regions.append(Region(x, y, w, h))

        return regions

    @classmethod
    def show_on_screen(cls, coordinates):
        """ Shows the position of the received coordinates on a screenshot
            through green dots. It pauses the script. Useful for debugging.
        """
        color_screen = cls.get_color_screen()
        for coords in coordinates:
            cv2.circle(color_screen, tuple(coords), 5, (0, 255, 0), -1)
        cv2.imshow("targets", color_screen)
        cv2.waitKey(0)
        cv2.destroyAllWindows()
        return

    @staticmethod
    def draw_region(screen, region, color, thickness):
        """Method which draws a region (a rectangle) on the image (screen) passed as argument.

        Args:
            screen (numpy array): image to draw on.
            region (Region): rectangle which needs to be drawn.
            color (tuple): specifiy the color of the rectangle's lines.
            thickness (int): specify the thickness of the rectangle's lines (-1 for it to be filled).

        See cv2.rectangle() docs.
        """
        cv2.rectangle(screen, (region.x, region.y), (region.x+region.w, region.y+region.h), color=color, thickness=thickness)

    @staticmethod
    def read_numbers(x, y, w, h, max_digits=5):
        """ Method to ocr numbers.
            Returns int.
        """
        text = []

        crop = screen[y:y + h, x:x + w]
        crop = cv2.resize(crop, None, fx=3, fy=3, interpolation=cv2.INTER_CUBIC)
        thresh = cv2.threshold(crop, 0, 255, cv2.THRESH_OTSU)[1]

        cnts = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
        cnts = grab_contours(cnts)
        cnts = contours.sort_contours(cnts, method="left-to-right")[0]

        if len(cnts) > max_digits:
            return 0

        for c in cnts:
            scores = []

            (x, y, w, h) = cv2.boundingRect(c)
            roi = thresh[y:y + h, x:x + w]
            row, col = roi.shape[:2]

            width = round(abs((50 - col)) / 2) + 5
            height = round(abs((94 - row)) / 2) + 5
            resized = cv2.copyMakeBorder(roi, top=height, bottom=height, left=width, right=width,
                                         borderType=cv2.BORDER_CONSTANT, value=[0, 0, 0])

            for x in range(0, 10):
                template = cv2.imread("assets/numbers/{}.png".format(x), 0)

                result = cv2.matchTemplate(resized, template, cv2.TM_CCOEFF_NORMED)
                (_, score, _, _) = cv2.minMaxLoc(result)
                scores.append(score)

            text.append(str(numpy.argmax(scores)))

        text = "".join(text)
        return int(text)

    @classmethod
    def menu_navigate(cls, image):
        cls.update_screen()

        while not cls.find(image, 0.85):
            if image == "menu/button_battle":
                cls.touch_randomly(Region(54, 57, 67, 67))
                cls.wait_update_screen(1)

        return

    @classmethod
    def find(cls, image, similarity=DEFAULT_SIMILARITY, color=False):
        """Finds the specified image on the screen

        Args:
            image (string): [description]
            similarity (float, optional): Defaults to DEFAULT_SIMILARITY.
                Percentage in similarity that the image should at least match.
            color (boolean): find the image in color screen

        Returns:
            Region: region object containing the location and size of the image
        """
        Utils.check_game_status()
        if color:
            template = cv2.imread('assets/{}/{}.png'.format(cls.assets, image), cv2.IMREAD_COLOR)
            match = cv2.matchTemplate(cls.color_screen, template, cv2.TM_CCOEFF_NORMED)
        else:
            template = cv2.imread('assets/{}/{}.png'.format(cls.assets, image), 0)
            match = cv2.matchTemplate(cls.screen, template, cv2.TM_CCOEFF_NORMED)

        height, width = template.shape[:2]
        value, location = cv2.minMaxLoc(match)[1], cv2.minMaxLoc(match)[3]
        # Check if the template match is not obscured by comparing pixel darkness
        if value >= similarity and not cls.is_obscured(location, template.shape[:2], color):
            return Region(location[0], location[1], width, height)
        return None

    @classmethod
    def is_obscured(cls, location, template_shape, color):
        """Check if the template match is obscured by surrounding pixels.

        Args:
            location (tuple): Location of the template match.
            template_shape (tuple): Shape (height, width) of the template.
            color (boolean): Whether to check in color screen.

        Returns:
            bool: True if obscured, False otherwise.
        """
        if color:
            # Extract the region around the template match
            x, y = location
            region = cls.color_screen[y:y+template_shape[0], x:x+template_shape[1]]

            # Calculate the average darkness level of the region
            avg_darkness = numpy.mean(region)

            # You can adjust the darkness threshold as needed
            darkness_threshold = 100  

            return avg_darkness < darkness_threshold
        else:
            pass  

    @classmethod
    def find_in_scaling_range(cls, image, similarity=DEFAULT_SIMILARITY, lowerEnd=0.8, upperEnd=1.2):
        """Finds the location of the image on the screen. First the image is searched at its default scale,
        and if it isn't found, it will be resized using values inside the range provided until a match that satisfy
        the similarity value is found. If the image isn't found even after it has been resized, the method returns None.

        Args:
            image (string): Name of the image.
            similarity (float, optional): Defaults to DEFAULT_SIMILARITY.
                Percentage in similarity that the image should at least match
            lowerEnd (float, optional): Defaults to 0.8.
                Lowest scaling factor used for resizing.
            upperEnd (float, optional): Defaults to 1.2.
                Highest scaling factor used for resizing.

        Returns:
            Region: Coordinates or where the image appears.
        """
        template = cv2.imread('assets/{}/{}.png'.format(cls.assets, image), 0)
        # first try with default size
        width, height = template.shape[::-1]
        match = cv2.matchTemplate(screen, template, cv2.TM_CCOEFF_NORMED)
        value, location = cv2.minMaxLoc(match)[1], cv2.minMaxLoc(match)[3]
        if (value >= similarity):
            return Region(location[0], location[1], width, height)

        # resize and match using threads

        # change scaling factor if the boss icon searched is small
        # (some events has as boss fleet a shipgirl with a small boss icon at her bottom right)
        if cls.small_boss_icon and image == 'enemy/fleet_boss':
            lowerEnd = 0.4
            upperEnd = 0.6

        # preparing interpolation methods
        middle_range = (upperEnd + lowerEnd) / 2.0
        if lowerEnd < 1 and upperEnd > 1 and middle_range == 1:
            l_interpolation = cv2.INTER_AREA
            u_interpolation = cv2.INTER_CUBIC
        elif upperEnd < 1 and lowerEnd < upperEnd:
            l_interpolation = cv2.INTER_AREA
            u_interpolation = cv2.INTER_AREA
        elif lowerEnd > 1 and upperEnd > lowerEnd:
            l_interpolation = cv2.INTER_CUBIC
            u_interpolation = cv2.INTER_CUBIC
        else:
            l_interpolation = cv2.INTER_NEAREST
            u_interpolation = cv2.INTER_NEAREST

        results_list = []
        count = 0
        loop_limiter = (middle_range - lowerEnd) * 100

        thread_list = []
        while (upperEnd > lowerEnd) and (count < loop_limiter):
            thread_list.append(Thread(target=cls.resize_and_match, args=(results_list, template, lowerEnd, similarity, l_interpolation)))
            thread_list.append(Thread(target=cls.resize_and_match, args=(results_list, template, upperEnd, similarity, u_interpolation)))
            lowerEnd+=0.02
            upperEnd-=0.02
            count +=1
        cls.multithreader(thread_list)
        if results_list:
            return results_list[0]
        else:
            return None

    @classmethod
    def find_all(cls, image, similarity=DEFAULT_SIMILARITY, useMask=False):
        """Finds all locations of the image on the screen

        Args:
            image (string): Name of the image.
            similarity (float, optional): Defaults to DEFAULT_SIMILARITY.
                Percentage in similarity that the image should at least match.
            useMask (boolean, optional): Defaults to False.
                If set to True, this function uses a different comparison method and
                a mask when searching for match.

        Returns:
            array: Array of all coordinates where the image appears
        """
        del cls.locations

        if useMask:
            comparison_method = cv2.TM_CCORR_NORMED
            mask = cls.get_mask_from_alpha(image)
        else:
            comparison_method = cv2.TM_CCOEFF_NORMED
            mask = None

        template = cv2.imread('assets/{}/{}.png'.format(cls.assets, image), 0)
        match = cv2.matchTemplate(screen, template, comparison_method, mask=mask)
        cls.locations = numpy.where(match >= similarity)

        return cls.filter_similar_coords(
            list(zip(cls.locations[1], cls.locations[0])))

    @classmethod
    def find_all_with_resize(cls, image, similarity=DEFAULT_SIMILARITY, useMask=False):
        """Finds all locations of the image at default size on the screen.
        If nothing is found, the method proceeds to resize the image within the
        scaling range of (0.8, 1.2) with a step interval of 0.2 and repeats
        the template matching operation for each resized images.

        Args:
            image (string): Name of the image.
            similarity (float, optional): Defaults to DEFAULT_SIMILARITY.
                Percentage in similarity that the image should at least match.
            useMask (boolean, optional): Defaults to False.
                If set to True, this function uses a different comparison method and
                a mask when searching for match.

        Returns:
            array: Array of all coordinates where the image appears
        """
        del cls.locations

        if useMask:
            comparison_method = cv2.TM_CCORR_NORMED
            mask = cls.get_mask_from_alpha(image)
        else:
            comparison_method = cv2.TM_CCOEFF_NORMED
            mask = None

        template = cv2.imread('assets/{}/{}.png'.format(cls.assets, image), 0)
        match = cv2.matchTemplate(screen, template, comparison_method, mask=mask)
        cls.locations = numpy.where(match >= similarity)

        if len(cls.locations[0]) < 1:
            count = 1.20
            thread_list = []
            results_list = []
            while count > 0.80:
                thread_list.append(Thread(target=cls.match_resize, args=(results_list,template,count,comparison_method,similarity,useMask,mask)))
                count -= 0.02
            Utils.multithreader(thread_list)
            for i in range(0, len(results_list)):
                cls.locations = numpy.append(cls.locations, results_list[i], axis=1)

        return cls.filter_similar_coords(
            list(zip(cls.locations[1], cls.locations[0])))

    @classmethod
    def match_resize(cls, results_list, image, scale, comparison_method, similarity=DEFAULT_SIMILARITY, useMask=False, mask=None):
        template_resize = cv2.resize(image, None, fx = scale, fy = scale, interpolation = cv2.INTER_NEAREST)
        if useMask:
            mask_resize = cv2.resize(mask, None, fx = scale, fy = scale, interpolation = cv2.INTER_NEAREST)
        else:
            mask_resize = None
        match_resize = cv2.matchTemplate(screen, template_resize, comparison_method, mask=mask_resize)
        results_list.append(numpy.where(match_resize >= similarity))

    @classmethod
    def resize_and_match(cls, results_list, templateImage, scale, similarity=DEFAULT_SIMILARITY, interpolationMethod=cv2.INTER_NEAREST):
        template_resize = cv2.resize(templateImage, None, fx = scale, fy = scale, interpolation = interpolationMethod)
        width, height = template_resize.shape[::-1]
        match = cv2.matchTemplate(screen, template_resize, cv2.TM_CCOEFF_NORMED)
        value, location = cv2.minMaxLoc(match)[1], cv2.minMaxLoc(match)[3]
        if (value >= similarity):
            results_list.append(Region(location[0], location[1], width, height))

    @classmethod
    def touch(cls, x, y):
        """Sends an input command to touch the device screen at the specified
        coordinates via ADB

        Args:
            coords (array): An array containing the x and y coordinate of
                where to touch the screen
        """
        last_touch = cls.record.get('last_touch', [None, 0])

        if last_touch[0] == (x, y):
            if last_touch[1] >= 60:
                raise GameStuckError
            else:
                last_touch[1] += 1
        else:
            last_touch = [(x, y), 0]

        cls.record['last_touch'] = last_touch
        Utils.check_game_status()
        Adb.u2device.click(x, y)

    @classmethod
    def touch_randomly(cls, region=Region(0, 0, 1280, 720)):
        """Touches a random coordinate in the specified region

        Args:
            region (Region, optional): Defaults to Region(0, 0, 1280, 720).
                specified region in which to randomly touch the screen
        """
        x = cls.random_coord(region.x, region.x + region.w)
        y = cls.random_coord(region.y, region.y + region.h)
        cls.touch(x, y)

    @classmethod
    def swipe(cls, x1, y1, x2, y2, ms=0):
        """Sends an input command to swipe the device screen between the
        specified coordinates via ADB

        Args:
            x1 (int): x-coordinate to begin the swipe at.
            y1 (int): y-coordinate to begin the swipe at.
            x2 (int): x-coordinate to end the swipe at.
            y2 (int): y-coordinate to end the swipe at.
            ms (int): Duration in ms of swipe. This value shouldn't be lower than 300, better if it is higher.
        """
        last_swipe = cls.record.get('last_swipe', [None, 0])

        if last_swipe[0] == (x1, y1, x2, y2, ms):
            if last_swipe[1] >= 35:
                raise GameStuckError
            else:
                last_swipe[1] += 1
        else:
            last_swipe = [(x1, y1, x2, y2, ms), 0]

        cls.record['last_swipe'] = last_swipe
        Utils.check_game_status()
        Adb.u2device.swipe(x1, y1, x2, y2, ms)

    @classmethod
    def find_and_touch(cls, image, similarity=DEFAULT_SIMILARITY, color=False):
        """Finds the image on the screen and touches it if it exists

        Args:
            image (string): Name of the image.
            similarity (float, optional): Defaults to DEFAULT_SIMILARITY.
                Percentage in similarity that the image should at least match.
            color (boolean): find the image in color screen

        Returns:
            bool: True if the image was found and touched, false otherwise
        """
        region = cls.find(image, similarity, color)
        if region is not None:
            cls.touch_randomly(region)
            return True
        return False

    @classmethod
    def random_coord(cls, min_val, max_val):
        """Wrapper method that calls cls._randint() or cls._random_coord() to
        generate the random coordinate between min_val and max_val, depending
        on which return line is enabled.

        Args:
            min_val (int): Minimum value of the random number.
            max_val (int): Maximum value of the random number.

        Returns:
            int: The generated random number
        """
        return cls._randint(min_val, max_val)
        # return cls._randint_gauss(min_val, max_val)

    @staticmethod
    def _randint(min_val, max_val):
        """Method to generate a random value based on the min_val and max_val
        with a uniform distribution.

        Args:
            min_val (int): Minimum value of the random number.
            max_val (int): Maximum value of the random number.

        Returns:
            int: The generated random number
        """
        return randint(min_val, max_val)

    @classmethod
    def filter_similar_coords(cls, coords, distance=50):
        """Filters out coordinates that are close to each other.

        Args:
            coords (array): An array containing the coordinates to be filtered.
            distance (int): minimum distance at which two set of coordinates
                are no longer considered close.

        Returns:
            array: An array containing the filtered coordinates.
        """
        #Logger.log_debug("Coords: " + str(coords))
        filtered_coords = []
        if len(coords) > 0:
            filtered_coords.append(coords[0])
            for coord in coords:
                if cls.find_closest(filtered_coords, coord)[0] > distance:
                    filtered_coords.append(coord)
        Logger.log_debug("Filtered Coords: " + str(filtered_coords))
        return filtered_coords

    @staticmethod
    def find_closest(coords, coord):
        """Utilizes a k-d tree to find the closest coordiante to the specified
        list of coordinates.

        Args:
            coords (array): Array of coordinates to search.
            coord (array): Array containing x and y of the coordinate.

        Returns:
            array: An array containing the distance of the closest coordinate
            in the list of coordinates to the specified coordinate as well the
            index of where it is in the list of coordinates
        """
        return spatial.cKDTree(coords).query(coord)

    @classmethod
    def get_region_color_average(cls, region, hsv=True):
        """
        Get the average color in the region
        :param region: the region to average the color
        :param hsv: return color in HSV if true. BGR otherwise.
        :return:  BGR or HSV color
        """
        crop = cls.color_screen[region.y:region.y + region.h, region.x:region.x + region.w]
        bgr_avg_color = numpy.average(crop, axis=(0, 1)).astype(numpy.uint8)
        bgr_avg_color = numpy.expand_dims(bgr_avg_color, axis=(0, 1))
        if hsv:
            hsv_avg_color = cv2.cvtColor(bgr_avg_color, cv2.COLOR_BGR2HSV)[0, 0, :]
            return hsv_avg_color
        else:
            return bgr_avg_color

    @classmethod
    def reset_record(cls):
        """Reset game records for last touch and swipe."""
        cls.record['last_touch'] = [None, 0]
        cls.record['last_swipe'] = [None, 0]

    @classmethod
    def check_game_status(cls):
        """Check if the game is running; raise an exception if not."""
        if Adb.u2device.app_current()['package'] != 'com.nexon.bluearchive':
            raise GameNotRunningError
        cls.record['game_started'] = True

    @classmethod
    def scan(cls, region, resize=False, color=False, bbox=False):
        """Perform OCR scanning on a given region of the screen.

        Args:
            region (Region): The region to scan.
            resize (bool): Whether to resize the scanned region.
            color (bool): Whether to use color scanning.
            bbox (bool): Whether to include bounding box information in results.

        Returns:
            list: A list of scan results.
        """
        results = []
        crop = cls.color_screen[region.y:region.y + region.h, region.x:region.x + region.w]
        if resize:
            crop = cv2.resize(crop, None, fx=3, fy=3, interpolation=cv2.INTER_CUBIC)
        data = cls.ocr.detect_and_ocr(crop)
        if data == [[]]:
            raise ReadOCRError
        for entry in data:
            if bbox:
                top_left = entry.box[0]
                bottom_right = entry.box[2]
                # Calculate attributes
                adjusted_x = region.x + top_left[0]
                adjusted_y = region.y + top_left[1]
                adjusted_w = region.x + bottom_right[0] - (top_left[0] + adjusted_x)
                adjusted_h = region.y + bottom_right[1] - (top_left[1] + adjusted_y)
                adjusted_bbox = Region(adjusted_x, adjusted_y, adjusted_h, adjusted_w)
                results.append({'bbox': adjusted_bbox, 'text': entry.ocr_text, 'score': entry.score})
            else:
                results.append({'text': entry.ocr_text, 'score': entry.score})
        return results

    @classmethod
    def find_word(cls, word, region):
        """Find a specific word within a given region.

        Args:
            word (str): The word to search for.
            region (Region): The region to search within.

        Returns:
            tuple: A tuple containing a boolean indicating whether the word was found and the bounding box of the word.
        """
        text = None
        results = cls.scan(region, bbox=True)
        for entry in results:
            bbox, text = entry['bbox'], entry['text']
            if word.lower().replace(" ", "") == text.lower().replace(" ",""):
                return True, bbox
        last_text = text
        return False, last_text

    @classmethod
    def find_button(cls, button, text_region, stage_region, similarity=DEFAULT_SIMILARITY):
        """Find a button's location within a specified region.

        Args:
            button (str): The name of the button.
            text_region (Region): The region containing stage text.
            stage_region (Region): The region containing the stages.
            similarity (float): The similarity threshold for matching.

        Returns:
            Region: The region of the found button.
        """
        button = cv2.imread('assets/{}/{}.png'.format(Utils.assets, button), 0)
        search_region_height = stage_region.h - text_region.h
        if button.shape[0] > abs(search_region_height):
            button = button[:search_region_height, :]
        matches = cv2.matchTemplate(screen, button, cv2.TM_CCOEFF_NORMED)
        matches = numpy.where(matches >= similarity)
        matches = cls.filter_similar_coords(list(zip(matches[1], matches[0])))
        if not matches:
            return None
        match_index = cls.find_closest(matches, [text_region.x, text_region.y])[1]
        match = matches[match_index]
        match_region =  Region(int(match[0]), int(match[1]), button.shape[1], button.shape[0])
        search_region = Region(text_region.x, text_region.y, 1280, search_region_height)
        if search_region.intersection(match_region):
            return match_region
        else:
            return None

    @classmethod
    def find_stage(cls, desired_stage):
        """Find a specific game stage.

        Args:
            desired_stage (str): The name of the desired game stage.

        Returns:
            str or bool: The name of the found stage or False if not found.
        """
        stage_list = Region(677, 132, 747, 678)
        left = (900, 600, 900, 350)
        right = (900, 350, 900, 600)
        detected_stage = False
        old_last_stage = None
        while True:
            cls.wait_update_screen(1)
            detected_stage, new_last_stage = cls.find_word(desired_stage, stage_list)
            if detected_stage == True:
                return new_last_stage
            elif detected_stage == False and old_last_stage == new_last_stage:
                Logger.log_msg('Stage not found. Check spelling.')
                return False
            else:
                if new_last_stage < desired_stage:
                    cls.swipe(*left)
                else:
                    cls.swipe(*right)
                old_last_stage = new_last_stage

    @classmethod
    def sweep(cls, num):
        """Perform a sweep action in the game.

        Args:
            num (int): The number of sweeps to perform.

        Returns:
            tuple: A tuple containing the sweep status and the last current value.
        """
        minus = (835, 300)
        counter = Region(885, 280, 105, 40)
        plus = (1035, 295)
        sweep_status = ('success')
        last_current = None
        if cls.find('farming/stars_required'):
            Logger.log_error('3 stars not achieved. Unable to sweep')
            sweep_status = ('failed')
            return sweep_status
        while True:
            cls.wait_update_screen(1)
            current = cls.scan(counter, resize=True)[0]['text']
            if not current.isdigit():
                raise ReadOCRError
            current = int(current)
            if current == 0:
                sweep_status = ('incomplete', 0)
                return sweep_status
            if current < num:
                if current == last_current:
                    num = current
                    sweep_status = ('incomplete', last_current)
                else:
                    for i in range(num - current):
                        cls.touch(*plus)
                        time.sleep(0.5)
                    last_current = current
                    continue
            if current > num:
                for i in range(current - num):
                    cls.touch(*minus)
                    time.sleep(0.5)
                last_current = current
                continue
            if current == num:
                while True:
                    Utils.update_screen()
                    if Utils.find('farming/sweep'):
                        Utils.touch(940, 400)
                        continue
                    if Utils.find('farming/confirm'):
                        Utils.touch(770, 500)
                        continue
                    if Utils.find('farming/sweep_complete'):
                        if sweep_status[0] == 'success':
                            Logger.log_success('Sweep completed successfully')
                        return sweep_status

class GoTo(object):

    home_subsections = {
    'cafe': {'click_position':(90,660), 'template':'goto/cafe'},
    'club': {'click_position':(560,650), 'template':'goto/club'},
    'tasks': {'click_position':(61,235), 'template':'goto/tasks'},
    'mailbox': {'click_position':(1142,38), 'template':'goto/mailbox'},
    'campaign': {'click_position':(1220,580), 'template':'goto/campaign'}
    }

    campaign_subsections = {
        'mission': {'click_position':(785,155), 'template':'goto/mission'},
        'bounty': {'click_position':(735,430), 'template':'goto/bounty'},
        'scrimmage': {'click_position':(720,600), 'template':'goto/scrimmage'},
        'commissions': {'click_position':(700,505), 'template':'goto/commissions'},
        'tactical_challenge': {'click_position':(1100,500), 'template':'goto/tactical_challenge'}
    }

    home_button = (1235, 21)
    back_button = (55,40)
    skip_confirm_button = (765, 500)
    wake_up_coords = (475, 20) # coords to touch to show widgets/skip lobby in the homescreen
    
    @classmethod
    def home(cls):
        """Navigate to the home screen."""
        while True:
            Utils.wait_update_screen(1)
            if Utils.find('goto/home', color=True):
                break
            elif Utils.find('goto/skip'):
                Utils.touch(*cls.skip_confirm_button)
            else:
                Utils.touch(*cls.home_button)

    @classmethod
    def sub_home(cls, section):
        """Navigate to a subsection of the home screen.

        Args:
            section (str): The name of the subsection.
        """
        waiting_time = 0
        while True:
            Utils.wait_update_screen(1)
            if Utils.find(cls.home_subsections[section]['template'], color=True):
                break
            elif Utils.find('goto/home', color=True):
                Utils.touch(*cls.home_subsections[section]['click_position'])
            elif Utils.find('goto/skip'):
                Utils.touch(*cls.skip_confirm_button)
            elif waiting_time < 5 and not Utils.find('goto/settings'):
                Utils.touch(*cls.wake_up_coords)
                waiting_time += 1
                continue
            elif waiting_time == 5:
                cls.home()
            else:
                Utils.touch(*cls.back_button)
            waiting_time = 0

    @classmethod
    def sub_campaign(cls, section):
        """Navigate to a subsection of the campaign screen.

        Args:
            section (str): The name of the subsection.
        """
        waiting_time = 0
        while True:
            Utils.wait_update_screen(1)
            if Utils.find(cls.campaign_subsections[section]['template'], color=True):
                break
            elif Utils.find('goto/campaign', color=True):
                Utils.touch(*cls.campaign_subsections[section]['click_position'])
            elif Utils.find('goto/home', color=True):
                cls.sub_home('campaign')
            elif Utils.find('goto/skip'):
                Utils.touch(*cls.skip_confirm_button)
            elif waiting_time < 5 and not Utils.find('goto/settings'):
                Utils.touch(*cls.wake_up_coords)
                waiting_time += 1
                continue
            elif waiting_time == 5:
                cls.home()
            else:
                Utils.touch(*cls.back_button)
            waiting_time = 0


    @classmethod
    def event(cls):
        waiting_time = 0
        while True:
            Utils.wait_update_screen(1)
            if Utils.find('goto/event', color=True):
                break
            elif Utils.find_and_touch('goto/event_banner'):
                continue
            elif Utils.find("goto/campaign", color=True):
                Utils.swipe(40,160, 260, 40)
            elif Utils.find('goto/home', color=True):
                cls.sub_home('campaign')
            elif Utils.find('goto/skip'):
                Utils.touch(*cls.skip_confirm_button)
            elif waiting_time < 5 and not Utils.find('goto/settings'):
                Utils.touch(*cls.wake_up_coords)
                waiting_time += 1
                continue
            elif waiting_time == 5:
                cls.home()
            else:
                Utils.touch(*cls.back_button)
            waiting_time = 0
util/utils.py
RedDeadDepresso/BAAuto