Add: Alas scheduler

This commit is contained in:
LmeSzinc 2023-06-18 01:07:14 +08:00
parent 9928d5b4cc
commit 1f8a9383f7
7 changed files with 384 additions and 44 deletions

314
module/alas.py Normal file
View File

@ -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/<timestamp>
Save logs to ./log/error/<timestamp>/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()

View File

@ -19,19 +19,13 @@ class PreservedAssets:
def ui(self): def ui(self):
assets = set() assets = set()
assets |= get_assets_from_file( 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_]+) = ') regex=re.compile(r'^([A-Za-z][A-Za-z0-9_]+) = ')
) )
assets |= get_assets_from_file( assets |= get_assets_from_file(
file='./module/ui/ui.py', file='./tasks/base/assets/assets_base_popup.py',
regex=re.compile(r'\(([A-Z][A-Z0-9_]+),') 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 return assets
@ -65,25 +59,8 @@ class Resource:
continue continue
logger.info(f'{obj}: {key}') 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 # Release assets cache
# module.ui has about 80 assets and takes about 3MB # module.ui has about 80 assets and takes about 3MB
# Alas has about 800 assets, but they are not all loaded. # 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}') # logger.info(f'Release {obj}')
obj.resource_release() 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 # Useless in most cases, but just call it
# gc.collect() # gc.collect()

View File

@ -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

4
module/notify.py Normal file
View File

@ -0,0 +1,4 @@
from module.logger import logger
def handle_notify(*args, **kwargs):
logger.error('Error notify is not supported yet')

16
module/server_checker.py Normal file
View File

@ -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

View File

@ -141,11 +141,12 @@ class ProcessManager:
try: try:
# Run alas # Run alas
if func == "alas": if func == "alas":
from alas import AzurLaneAutoScript from module.alas import AzurLaneAutoScript
from src import StarRailCopilot
if e is not None: if e is not None:
AzurLaneAutoScript.stop_event = e AzurLaneAutoScript.stop_event = e
AzurLaneAutoScript(config_name=config_name).loop() StarRailCopilot(config_name=config_name).loop()
else: else:
logger.critical(f"No function matched: {func}") logger.critical(f"No function matched: {func}")
logger.info(f"[{config_name}] exited. Reason: Finish\n") logger.info(f"[{config_name}] exited. Reason: Finish\n")

28
src.py Normal file
View File

@ -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()