diff --git a/module/alas.py b/module/alas.py new file mode 100644 index 000000000..56c019371 --- /dev/null +++ b/module/alas.py @@ -0,0 +1,314 @@ +import os +import re +import threading +import time +from datetime import datetime, timedelta + +import inflection +from cached_property import cached_property + +from module.base.decorator import del_cached_property +from module.config.config import AzurLaneConfig, TaskEnd +from module.config.utils import deep_get, deep_set +from module.exception import * +from module.logger import logger +from module.notify import handle_notify + + +class AzurLaneAutoScript: + stop_event: threading.Event = None + + def __init__(self, config_name='alas'): + logger.hr('Start', level=0) + self.config_name = config_name + # Skip first restart + self.is_first_task = True + # Failure count of tasks + # Key: str, task name, value: int, failure count + self.failure_record = {} + + @cached_property + def config(self): + try: + config = AzurLaneConfig(config_name=self.config_name) + return config + except RequestHumanTakeover: + logger.critical('Request human takeover') + exit(1) + except Exception as e: + logger.exception(e) + exit(1) + + @cached_property + def device(self): + try: + from module.device.device import Device + device = Device(config=self.config) + return device + except RequestHumanTakeover: + logger.critical('Request human takeover') + exit(1) + except Exception as e: + logger.exception(e) + exit(1) + + @cached_property + def checker(self): + try: + from module.server_checker import ServerChecker + checker = ServerChecker(server=self.config.Emulator_PackageName) + return checker + except Exception as e: + logger.exception(e) + exit(1) + + def run(self, command): + try: + self.device.screenshot() + self.__getattribute__(command)() + return True + except TaskEnd: + return True + except GameNotRunningError as e: + logger.warning(e) + self.config.task_call('Restart') + return True + except (GameStuckError, GameTooManyClickError) as e: + logger.error(e) + self.save_error_log() + logger.warning(f'Game stuck, {self.device.package} will be restarted in 10 seconds') + logger.warning('If you are playing by hand, please stop Alas') + self.config.task_call('Restart') + self.device.sleep(10) + return False + except GameBugError as e: + logger.warning(e) + self.save_error_log() + logger.warning('An error has occurred in Azur Lane game client, Alas is unable to handle') + logger.warning(f'Restarting {self.device.package} to fix it') + self.config.task_call('Restart') + self.device.sleep(10) + return False + except GamePageUnknownError: + logger.info('Game server may be under maintenance or network may be broken, check server status now') + self.checker.check_now() + if self.checker.is_available(): + logger.critical('Game page unknown') + self.save_error_log() + handle_notify( + self.config.Error_OnePushConfig, + title=f"Alas <{self.config_name}> crashed", + content=f"<{self.config_name}> GamePageUnknownError", + ) + exit(1) + else: + self.checker.wait_until_available() + return False + except ScriptError as e: + logger.critical(e) + logger.critical('This is likely to be a mistake of developers, but sometimes just random issues') + handle_notify( + self.config.Error_OnePushConfig, + title=f"Alas <{self.config_name}> crashed", + content=f"<{self.config_name}> ScriptError", + ) + exit(1) + except RequestHumanTakeover: + logger.critical('Request human takeover') + handle_notify( + self.config.Error_OnePushConfig, + title=f"Alas <{self.config_name}> crashed", + content=f"<{self.config_name}> RequestHumanTakeover", + ) + exit(1) + except Exception as e: + logger.exception(e) + self.save_error_log() + handle_notify( + self.config.Error_OnePushConfig, + title=f"Alas <{self.config_name}> crashed", + content=f"<{self.config_name}> Exception occured", + ) + exit(1) + + def save_error_log(self): + """ + Save last 60 screenshots in ./log/error/ + Save logs to ./log/error//log.txt + """ + from module.base.utils import save_image + from module.handler.sensitive_info import (handle_sensitive_image, handle_sensitive_logs) + if self.config.Error_SaveError: + if not os.path.exists('./log/error'): + os.mkdir('./log/error') + folder = f'./log/error/{int(time.time() * 1000)}' + logger.warning(f'Saving error: {folder}') + os.mkdir(folder) + for data in self.device.screenshot_deque: + image_time = datetime.strftime(data['time'], '%Y-%m-%d_%H-%M-%S-%f') + image = handle_sensitive_image(data['image']) + save_image(image, f'{folder}/{image_time}.png') + with open(logger.log_file, 'r', encoding='utf-8') as f: + lines = f.readlines() + start = 0 + for index, line in enumerate(lines): + line = line.strip(' \r\t\n') + if re.match('^═{15,}$', line): + start = index + lines = lines[start - 2:] + lines = handle_sensitive_logs(lines) + with open(f'{folder}/log.txt', 'w', encoding='utf-8') as f: + f.writelines(lines) + + def wait_until(self, future): + """ + Wait until a specific time. + + Args: + future (datetime): + + Returns: + bool: True if wait finished, False if config changed. + """ + future = future + timedelta(seconds=1) + self.config.start_watching() + while 1: + if datetime.now() > future: + return True + if self.stop_event is not None: + if self.stop_event.is_set(): + logger.info("Update event detected") + logger.info(f"[{self.config_name}] exited. Reason: Update") + exit(0) + + time.sleep(5) + + if self.config.should_reload(): + return False + + def get_next_task(self): + """ + Returns: + str: Name of the next task. + """ + while 1: + task = self.config.get_next() + self.config.task = task + self.config.bind(task) + + from module.base.resource import release_resources + if self.config.task.command != 'Alas': + release_resources(next_task=task.command) + + if task.next_run > datetime.now(): + logger.info(f'Wait until {task.next_run} for task `{task.command}`') + self.is_first_task = False + method = self.config.Optimization_WhenTaskQueueEmpty + if method == 'close_game': + logger.info('Close game during wait') + self.device.app_stop() + release_resources() + self.device.release_during_wait() + if not self.wait_until(task.next_run): + del_cached_property(self, 'config') + continue + self.run('start') + elif method == 'goto_main': + logger.info('Goto main page during wait') + self.run('goto_main') + release_resources() + self.device.release_during_wait() + if not self.wait_until(task.next_run): + del_cached_property(self, 'config') + continue + elif method == 'stay_there': + logger.info('Stay there during wait') + release_resources() + self.device.release_during_wait() + if not self.wait_until(task.next_run): + del_cached_property(self, 'config') + continue + else: + logger.warning(f'Invalid Optimization_WhenTaskQueueEmpty: {method}, fallback to stay_there') + release_resources() + self.device.release_during_wait() + if not self.wait_until(task.next_run): + del_cached_property(self, 'config') + continue + break + + AzurLaneConfig.is_hoarding_task = False + return task.command + + def loop(self): + logger.set_file_logger(self.config_name) + logger.info(f'Start scheduler loop: {self.config_name}') + + while 1: + # Check update event from GUI + if self.stop_event is not None: + if self.stop_event.is_set(): + logger.info("Update event detected") + logger.info(f"Alas [{self.config_name}] exited.") + break + # Check game server maintenance + self.checker.wait_until_available() + if self.checker.is_recovered(): + # There is an accidental bug hard to reproduce + # Sometimes, config won't be updated due to blocking + # even though it has been changed + # So update it once recovered + del_cached_property(self, 'config') + logger.info('Server or network is recovered. Restart game client') + self.config.task_call('Restart') + # Get task + task = self.get_next_task() + # Init device and change server + _ = self.device + # Skip first restart + if self.is_first_task and task == 'Restart': + logger.info('Skip task `Restart` at scheduler start') + self.config.task_delay(server_update=True) + del_cached_property(self, 'config') + continue + + # Run + logger.info(f'Scheduler: Start task `{task}`') + self.device.stuck_record_clear() + self.device.click_record_clear() + logger.hr(task, level=0) + success = self.run(inflection.underscore(task)) + logger.info(f'Scheduler: End task `{task}`') + self.is_first_task = False + + # Check failures + failed = deep_get(self.failure_record, keys=task, default=0) + failed = 0 if success else failed + 1 + deep_set(self.failure_record, keys=task, value=failed) + if failed >= 3: + logger.critical(f"Task `{task}` failed 3 or more times.") + logger.critical("Possible reason #1: You haven't used it correctly. " + "Please read the help text of the options.") + logger.critical("Possible reason #2: There is a problem with this task. " + "Please contact developers or try to fix it yourself.") + logger.critical('Request human takeover') + handle_notify( + self.config.Error_OnePushConfig, + title=f"Alas <{self.config_name}> crashed", + content=f"<{self.config_name}> RequestHumanTakeover\nTask `{task}` failed 3 or more times.", + ) + exit(1) + + if success: + del_cached_property(self, 'config') + continue + else: + # self.config.task_delay(success=False) + del_cached_property(self, 'config') + self.checker.check_now() + continue + + +if __name__ == '__main__': + alas = AzurLaneAutoScript() + alas.loop() diff --git a/module/base/resource.py b/module/base/resource.py index 02977f28c..40d930ed7 100644 --- a/module/base/resource.py +++ b/module/base/resource.py @@ -19,19 +19,13 @@ class PreservedAssets: def ui(self): assets = set() assets |= get_assets_from_file( - file='./module/ui/assets.py', + file='./tasks/base/assets/assets_base_page.py', regex=re.compile(r'^([A-Za-z][A-Za-z0-9_]+) = ') ) assets |= get_assets_from_file( - file='./module/ui/ui.py', - regex=re.compile(r'\(([A-Z][A-Z0-9_]+),') + file='./tasks/base/assets/assets_base_popup.py', + regex=re.compile(r'^([A-Za-z][A-Za-z0-9_]+) = ') ) - assets |= get_assets_from_file( - file='./module/handler/info_handler.py', - regex=re.compile(r'\(([A-Z][A-Z0-9_]+),') - ) - # MAIN_CHECK == MAIN_GOTO_CAMPAIGN - assets.add('MAIN_GOTO_CAMPAIGN') return assets @@ -65,25 +59,8 @@ class Resource: continue logger.info(f'{obj}: {key}') -def release_resources(next_task=''): - # Release all OCR models - # Usually to have 2 models loaded and each model takes about 20MB - # This will release 20-40MB - from module.webui.setting import State - if not State.deploy_config.UseOcrServer: - # Release only when using per-instance OCR - from module.ocr.ocr import OCR_MODEL - if 'Opsi' in next_task or 'commission' in next_task: - # OCR models will be used soon, don't release - models = [] - elif next_task: - # Release OCR models except 'azur_lane' - models = ['cnocr', 'jp', 'tw'] - else: - models = ['azur_lane', 'cnocr', 'jp', 'tw'] - for model in models: - del_cached_property(OCR_MODEL, model) +def release_resources(next_task=''): # Release assets cache # module.ui has about 80 assets and takes about 3MB # Alas has about 800 assets, but they are not all loaded. @@ -96,20 +73,5 @@ def release_resources(next_task=''): # logger.info(f'Release {obj}') obj.resource_release() - # Release cached images for map detection - from module.map_detection.utils_assets import ASSETS - attr_list = [ - 'ui_mask', - 'ui_mask_os', - 'ui_mask_stroke', - 'ui_mask_in_map', - 'ui_mask_os_in_map', - 'tile_center_image', - 'tile_corner_image', - 'tile_corner_image_list' - ] - for attr in attr_list: - del_cached_property(ASSETS, attr) - # Useless in most cases, but just call it # gc.collect() diff --git a/module/handler/sensitive_info.py b/module/handler/sensitive_info.py new file mode 100644 index 000000000..6f3cadc7f --- /dev/null +++ b/module/handler/sensitive_info.py @@ -0,0 +1,15 @@ +def handle_sensitive_image(image): + """ + Args: + image: + + Returns: + np.ndarray: + """ + # Paint UID to black + image[680:720, 0:180, :] = 0 + return image + + +def handle_sensitive_logs(logs): + return logs diff --git a/module/notify.py b/module/notify.py new file mode 100644 index 000000000..4f8b470ac --- /dev/null +++ b/module/notify.py @@ -0,0 +1,4 @@ +from module.logger import logger + +def handle_notify(*args, **kwargs): + logger.error('Error notify is not supported yet') diff --git a/module/server_checker.py b/module/server_checker.py new file mode 100644 index 000000000..90c879efc --- /dev/null +++ b/module/server_checker.py @@ -0,0 +1,16 @@ +class ServerChecker: + # Create a fake server check since server check is not supported yet. + def __init__(self, server): + pass + + def check_now(self): + pass + + def is_available(self): + return True + + def wait_until_available(self): + pass + + def is_recovered(self): + return False diff --git a/module/webui/process_manager.py b/module/webui/process_manager.py index 1f0aacdab..b0690c059 100644 --- a/module/webui/process_manager.py +++ b/module/webui/process_manager.py @@ -141,11 +141,12 @@ class ProcessManager: try: # Run alas if func == "alas": - from alas import AzurLaneAutoScript + from module.alas import AzurLaneAutoScript + from src import StarRailCopilot if e is not None: AzurLaneAutoScript.stop_event = e - AzurLaneAutoScript(config_name=config_name).loop() + StarRailCopilot(config_name=config_name).loop() else: logger.critical(f"No function matched: {func}") logger.info(f"[{config_name}] exited. Reason: Finish\n") diff --git a/src.py b/src.py new file mode 100644 index 000000000..af1ebf9d7 --- /dev/null +++ b/src.py @@ -0,0 +1,28 @@ +from module.alas import AzurLaneAutoScript +from module.logger import logger + + +class StarRailCopilot(AzurLaneAutoScript): + def restart(self): + from tasks.login.login import Login + Login(self.config, device=self.device).app_restart() + + def start(self): + from tasks.login.login import Login + Login(self.config, device=self.device).app_start() + + def goto_main(self): + from tasks.login.login import Login + from tasks.base.ui import UI + if self.device.app_is_running(): + logger.info('App is already running, goto main page') + UI(self.config, device=self.device).ui_goto_main() + else: + logger.info('App is not running, start app and goto main page') + Login(self.config, device=self.device).app_start() + UI(self.config, device=self.device).ui_goto_main() + + +if __name__ == '__main__': + src = StarRailCopilot('src') + src.loop()