diff --git a/assets/cn/combat_ui/PAUSE_Nurse.png b/assets/cn/combat_ui/PAUSE_Nurse.png new file mode 100644 index 000000000..7147350ae Binary files /dev/null and b/assets/cn/combat_ui/PAUSE_Nurse.png differ diff --git a/assets/cn/combat_ui/QUIT_Nurse.png b/assets/cn/combat_ui/QUIT_Nurse.png new file mode 100644 index 000000000..98484586a Binary files /dev/null and b/assets/cn/combat_ui/QUIT_Nurse.png differ diff --git a/assets/cn/event_hospital/TEMPLATE_INVEST2.png b/assets/cn/event_hospital/TEMPLATE_INVEST2.png new file mode 100644 index 000000000..c7fba8b89 Binary files /dev/null and b/assets/cn/event_hospital/TEMPLATE_INVEST2.png differ diff --git a/assets/cn/war_archives/TEMPLATE_VIRTUAL_TOWER.png b/assets/cn/war_archives/TEMPLATE_VIRTUAL_TOWER.png new file mode 100644 index 000000000..5edc39da9 Binary files /dev/null and b/assets/cn/war_archives/TEMPLATE_VIRTUAL_TOWER.png differ diff --git a/assets/en/event_hospital/TEMPLATE_INVEST.png b/assets/en/event_hospital/TEMPLATE_INVEST.png index fe9f7ccf9..374a22557 100644 Binary files a/assets/en/event_hospital/TEMPLATE_INVEST.png and b/assets/en/event_hospital/TEMPLATE_INVEST.png differ diff --git a/assets/gui/css/alas.css b/assets/gui/css/alas.css index 10a711145..824f006f1 100644 --- a/assets/gui/css/alas.css +++ b/assets/gui/css/alas.css @@ -322,6 +322,16 @@ pre.rich-traceback-code { margin: 0; } +.anim-rotate { + animation: rotate-keyframes 0.4s linear infinite +} + +@keyframes rotate-keyframes { + 100% { + transform: rotate(360deg) + } +} + #pywebio-scope-contents { margin-top: 0; overflow-y: auto; diff --git a/assets/tw/combat_ui/QUIT_New.png b/assets/tw/combat_ui/QUIT_New.png new file mode 100644 index 000000000..076a00e63 Binary files /dev/null and b/assets/tw/combat_ui/QUIT_New.png differ diff --git a/campaign/Readme.md b/campaign/Readme.md index d85e5337c..72ca4f42c 100644 --- a/campaign/Readme.md +++ b/campaign/Readme.md @@ -50,6 +50,7 @@ To add a new event, add a new row in here, and run `python -m module.config.conf | 20240829 | war archives 20210422 cn | Daedalian Hymn | 复兴的赞美诗 | Daedalian Hymn | 讃える復興の迷路 | 復興的讚美詩 | | 20241017 | war archives 20211229 cn | Tower of Transcendence | 逆转彩虹之塔 | Tower of Transcendence | 遡望せし虹彩の塔 | 逆轉彩虹之塔 | | 20250109 | war archives 20220224 cn | Abyssal Refrain | 深度回音 | Abyssal Refrain | 鳴動せし星霜の淵 | 深度回音 | +| 20250320 | war archives 20220324 cn | Virtual Tower | 虚像构筑之塔 | Virtual Tower | 幻像の塔 | 虛像構築之塔 | | 20200227 | event 20200227 cn | Northern Overture | 北境序曲 | Northern Overture | 凍絶の北海 | - | | 20200312 | event 20200312 cn | The Solomon Ranger | 复刻斯图尔特的硝烟 | The Solomon Ranger Rerun | 南洋に靡く硝煙(復刻) | - | | 20200326 | event 20200326 cn | Microlayer Medley | 微层混合 | Microlayer Medley | 闇靄払う銀翼 | - | diff --git a/campaign/war_archives_20220324_cn/sp1.py b/campaign/war_archives_20220324_cn/sp1.py new file mode 100644 index 000000000..c321f4bce --- /dev/null +++ b/campaign/war_archives_20220324_cn/sp1.py @@ -0,0 +1,97 @@ +from module.logger import logger +from module.map.map_base import CampaignMap +from module.map.map_grids import RoadGrids, SelectedGrids + +from ..campaign_war_archives.campaign_base import CampaignBase + +MAP = CampaignMap('SP1') +MAP.shape = 'H7' +MAP.camera_data = ['D2', 'D5', 'E2', 'E5'] +MAP.camera_data_spawn_point = ['D2'] +MAP.map_data = """ + SP SP -- -- -- -- -- -- + -- -- -- ++ ++ -- -- -- + ME -- -- MB MB -- -- ME + -- -- MS -- -- MS -- -- + Me ++ ++ __ __ ++ ++ Me + -- ME ++ ME ME ++ ME -- + ME -- Me -- -- Me -- ME +""" +MAP.weight_data = """ + 50 50 50 50 50 50 50 50 + 50 50 50 50 50 50 50 50 + 50 50 50 50 50 50 50 50 + 50 50 50 50 50 50 50 50 + 50 50 50 50 50 50 50 50 + 50 50 50 50 50 50 50 50 + 50 50 50 50 50 50 50 50 +""" +MAP.spawn_data = [ + {'battle': 0, 'enemy': 2, 'siren': 1}, + {'battle': 1, 'enemy': 2}, + {'battle': 2, 'enemy': 1}, + {'battle': 3, 'enemy': 1}, + {'battle': 4, 'boss': 1}, +] +A1, B1, C1, D1, E1, F1, G1, H1, \ +A2, B2, C2, D2, E2, F2, G2, H2, \ +A3, B3, C3, D3, E3, F3, G3, H3, \ +A4, B4, C4, D4, E4, F4, G4, H4, \ +A5, B5, C5, D5, E5, F5, G5, H5, \ +A6, B6, C6, D6, E6, F6, G6, H6, \ +A7, B7, C7, D7, E7, F7, G7, H7, \ + = MAP.flatten() + + +class Config: + # ===== Start of generated config ===== + MAP_SIREN_TEMPLATE = ['DD', 'CL'] + MOVABLE_ENEMY_TURN = (2,) + MAP_HAS_SIREN = True + MAP_HAS_MOVABLE_ENEMY = True + MAP_HAS_MAP_STORY = True + MAP_HAS_FLEET_STEP = True + MAP_HAS_AMBUSH = False + MAP_HAS_MYSTERY = False + # ===== End of generated config ===== + + INTERNAL_LINES_FIND_PEAKS_PARAMETERS = { + 'height': (150, 255 - 10), + 'width': (0.9, 10), + 'prominence': 10, + 'distance': 35, + } + EDGE_LINES_FIND_PEAKS_PARAMETERS = { + 'height': (255 - 10, 255), + 'prominence': 10, + 'distance': 50, + 'wlen': 1000 + } + HOMO_CANNY_THRESHOLD = (75, 150) + HOMO_EDGE_COLOR_RANGE = (0, 10) + MAP_SWIPE_MULTIPLY = (1.245, 1.268) + MAP_SWIPE_MULTIPLY_MINITOUCH = (1.204, 1.226) + MAP_SWIPE_MULTIPLY_MAATOUCH = (1.169, 1.190) + MAP_ENEMY_GENRE_DETECTION_SCALING = { + 'DD': 1.111, + 'CL': 1.111, + 'CA': 1.111, + 'CV': 1.111, + 'BB': 1.111, + } + + +class Campaign(CampaignBase): + MAP = MAP + ENEMY_FILTER = '1L > 1M > 1E > 1C > 2L > 2M > 2E > 2C > 3L > 3M > 3E > 3C' + + def battle_0(self): + if self.clear_siren(): + return True + if self.clear_filter_enemy(self.ENEMY_FILTER, preserve=0): + return True + + return self.battle_default() + + def battle_4(self): + return self.clear_boss() diff --git a/campaign/war_archives_20220324_cn/sp2.py b/campaign/war_archives_20220324_cn/sp2.py new file mode 100644 index 000000000..40bb775bb --- /dev/null +++ b/campaign/war_archives_20220324_cn/sp2.py @@ -0,0 +1,80 @@ +from module.logger import logger +from module.map.map_base import CampaignMap +from module.map.map_grids import RoadGrids, SelectedGrids + +from ..campaign_war_archives.campaign_base import CampaignBase +from .sp1 import Config as ConfigBase + +MAP = CampaignMap('SP2') +MAP.shape = 'H8' +MAP.camera_data = ['D2', 'D5', 'E2', 'E5'] +MAP.camera_data_spawn_point = ['D2'] +MAP.map_data = """ + -- ME ++ MB MB ++ ME -- + SP -- -- __ __ -- -- ME + SP -- MS ME ME MS -- ME + -- Me -- ++ ++ -- Me -- + -- Me -- ++ ++ -- Me -- + -- -- Me -- -- Me -- -- + ++ -- -- ME ME -- -- ++ + -- -- -- ++ ++ -- -- -- +""" +MAP.weight_data = """ + 50 50 50 50 50 50 50 50 + 50 50 50 50 50 50 50 50 + 50 50 50 50 50 50 50 50 + 50 50 50 50 50 50 50 50 + 50 50 50 50 50 50 50 50 + 50 50 50 50 50 50 50 50 + 50 50 50 50 50 50 50 50 + 50 50 50 50 50 50 50 50 +""" +MAP.spawn_data = [ + {'battle': 0, 'enemy': 3, 'siren': 1}, + {'battle': 1, 'enemy': 2}, + {'battle': 2, 'enemy': 1}, + {'battle': 3, 'enemy': 1}, + {'battle': 4, 'boss': 1}, +] +A1, B1, C1, D1, E1, F1, G1, H1, \ +A2, B2, C2, D2, E2, F2, G2, H2, \ +A3, B3, C3, D3, E3, F3, G3, H3, \ +A4, B4, C4, D4, E4, F4, G4, H4, \ +A5, B5, C5, D5, E5, F5, G5, H5, \ +A6, B6, C6, D6, E6, F6, G6, H6, \ +A7, B7, C7, D7, E7, F7, G7, H7, \ +A8, B8, C8, D8, E8, F8, G8, H8, \ + = MAP.flatten() + + +class Config(ConfigBase): + # ===== Start of generated config ===== + MAP_SIREN_TEMPLATE = ['CL', 'CA'] + MOVABLE_ENEMY_TURN = (2,) + MAP_HAS_SIREN = True + MAP_HAS_MOVABLE_ENEMY = True + MAP_HAS_MAP_STORY = False + MAP_HAS_FLEET_STEP = True + MAP_HAS_AMBUSH = False + MAP_HAS_MYSTERY = False + # ===== End of generated config ===== + + MAP_SWIPE_MULTIPLY = (1.210, 1.233) + MAP_SWIPE_MULTIPLY_MINITOUCH = (1.170, 1.192) + MAP_SWIPE_MULTIPLY_MAATOUCH = (1.136, 1.156) + + +class Campaign(CampaignBase): + MAP = MAP + ENEMY_FILTER = '1L > 1M > 1E > 1C > 2L > 2M > 2E > 2C > 3L > 3M > 3E > 3C' + + def battle_0(self): + if self.clear_siren(): + return True + if self.clear_filter_enemy(self.ENEMY_FILTER, preserve=0): + return True + + return self.battle_default() + + def battle_4(self): + return self.clear_boss() diff --git a/campaign/war_archives_20220324_cn/sp3.py b/campaign/war_archives_20220324_cn/sp3.py new file mode 100644 index 000000000..bf0a19c53 --- /dev/null +++ b/campaign/war_archives_20220324_cn/sp3.py @@ -0,0 +1,81 @@ +from module.logger import logger +from module.map.map_base import CampaignMap +from module.map.map_grids import RoadGrids, SelectedGrids + +from ..campaign_war_archives.campaign_base import CampaignBase +from .sp1 import Config as ConfigBase + +MAP = CampaignMap('SP3') +MAP.shape = 'H8' +MAP.camera_data = ['D2', 'D6', 'E2', 'E6'] +MAP.camera_data_spawn_point = ['D6'] +MAP.map_data = """ + -- -- ME -- -- -- ME -- + -- Me ++ ME -- ME -- -- + Me -- ++ Me -- ++ ++ -- + ++ -- -- __ -- -- ++ -- + SP -- MS -- Me -- ME -- + SP -- -- ++ ++ ME -- ME + -- -- MS ++ ++ -- ME ++ + ++ ME -- MB MB -- -- -- +""" +MAP.weight_data = """ + 50 50 50 50 50 50 50 50 + 50 50 50 50 50 50 50 50 + 50 50 50 50 50 50 50 50 + 50 50 50 50 50 50 50 50 + 50 50 50 50 50 50 50 50 + 50 50 50 50 50 50 50 50 + 50 50 50 50 50 50 50 50 + 50 50 50 50 50 50 50 50 +""" +MAP.spawn_data = [ + {'battle': 0, 'enemy': 3, 'siren': 2}, + {'battle': 1, 'enemy': 2}, + {'battle': 2, 'enemy': 1}, + {'battle': 3, 'enemy': 1}, + {'battle': 4}, + {'battle': 5, 'boss': 1}, +] +A1, B1, C1, D1, E1, F1, G1, H1, \ +A2, B2, C2, D2, E2, F2, G2, H2, \ +A3, B3, C3, D3, E3, F3, G3, H3, \ +A4, B4, C4, D4, E4, F4, G4, H4, \ +A5, B5, C5, D5, E5, F5, G5, H5, \ +A6, B6, C6, D6, E6, F6, G6, H6, \ +A7, B7, C7, D7, E7, F7, G7, H7, \ +A8, B8, C8, D8, E8, F8, G8, H8, \ + = MAP.flatten() + + +class Config(ConfigBase): + # ===== Start of generated config ===== + MAP_SIREN_TEMPLATE = ['CA', 'BB'] + MOVABLE_ENEMY_TURN = (2,) + MAP_HAS_SIREN = True + MAP_HAS_MOVABLE_ENEMY = True + MAP_HAS_MAP_STORY = True + MAP_HAS_FLEET_STEP = True + MAP_HAS_AMBUSH = False + MAP_HAS_MYSTERY = False + # ===== End of generated config ===== + + MAP_SWIPE_MULTIPLY = (1.037, 1.056) + MAP_SWIPE_MULTIPLY_MINITOUCH = (1.002, 1.021) + MAP_SWIPE_MULTIPLY_MAATOUCH = (0.973, 0.991) + + +class Campaign(CampaignBase): + MAP = MAP + ENEMY_FILTER = '1L > 1M > 1E > 1C > 2L > 2M > 2E > 2C > 3L > 3M > 3E > 3C' + + def battle_0(self): + if self.clear_siren(): + return True + if self.clear_filter_enemy(self.ENEMY_FILTER, preserve=0): + return True + + return self.battle_default() + + def battle_5(self): + return self.fleet_boss.clear_boss() diff --git a/campaign/war_archives_20220324_cn/sp4.py b/campaign/war_archives_20220324_cn/sp4.py new file mode 100644 index 000000000..a62f66764 --- /dev/null +++ b/campaign/war_archives_20220324_cn/sp4.py @@ -0,0 +1,81 @@ +from module.logger import logger +from module.map.map_base import CampaignMap +from module.map.map_grids import RoadGrids, SelectedGrids + +from ..campaign_war_archives.campaign_base import CampaignBase +from .sp1 import Config as ConfigBase + +MAP = CampaignMap('SP4') +MAP.shape = 'H8' +MAP.camera_data = ['D3', 'D6', 'E3', 'E6'] +MAP.camera_data_spawn_point = ['D6'] +MAP.map_data = """ + ++ -- -- -- -- ++ -- -- + -- Me ++ ++ ME ++ ME -- + ME -- ++ ++ -- ME -- ++ + Me -- MB MB -- -- -- ME + ++ MS -- __ -- MS -- -- + SP -- -- Me ++ ++ -- ME + SP -- -- Me -- ++ ME -- + ++ -- MS -- ME -- -- -- +""" +MAP.weight_data = """ + 50 50 50 50 50 50 50 50 + 50 50 50 50 50 50 50 50 + 50 50 50 50 50 50 50 50 + 50 50 50 50 50 50 50 50 + 50 50 50 50 50 50 50 50 + 50 50 50 50 50 50 50 50 + 50 50 50 50 50 50 50 50 + 50 50 50 50 50 50 50 50 +""" +MAP.spawn_data = [ + {'battle': 0, 'enemy': 3, 'siren': 2}, + {'battle': 1, 'enemy': 2, 'siren': 1}, + {'battle': 2, 'enemy': 1}, + {'battle': 3, 'enemy': 1}, + {'battle': 4}, + {'battle': 5, 'boss': 1}, +] +A1, B1, C1, D1, E1, F1, G1, H1, \ +A2, B2, C2, D2, E2, F2, G2, H2, \ +A3, B3, C3, D3, E3, F3, G3, H3, \ +A4, B4, C4, D4, E4, F4, G4, H4, \ +A5, B5, C5, D5, E5, F5, G5, H5, \ +A6, B6, C6, D6, E6, F6, G6, H6, \ +A7, B7, C7, D7, E7, F7, G7, H7, \ +A8, B8, C8, D8, E8, F8, G8, H8, \ + = MAP.flatten() + + +class Config(ConfigBase): + # ===== Start of generated config ===== + MAP_SIREN_TEMPLATE = ['BB', 'CV'] + MOVABLE_ENEMY_TURN = (2,) + MAP_HAS_SIREN = True + MAP_HAS_MOVABLE_ENEMY = True + MAP_HAS_MAP_STORY = False + MAP_HAS_FLEET_STEP = True + MAP_HAS_AMBUSH = False + MAP_HAS_MYSTERY = False + # ===== End of generated config ===== + + MAP_SWIPE_MULTIPLY = (1.037, 1.056) + MAP_SWIPE_MULTIPLY_MINITOUCH = (1.002, 1.021) + MAP_SWIPE_MULTIPLY_MAATOUCH = (0.973, 0.991) + + +class Campaign(CampaignBase): + MAP = MAP + ENEMY_FILTER = '1L > 1M > 1E > 1C > 2L > 2M > 2E > 2C > 3L > 3M > 3E > 3C' + + def battle_0(self): + if self.clear_siren(): + return True + if self.clear_filter_enemy(self.ENEMY_FILTER, preserve=0): + return True + + return self.battle_default() + + def battle_5(self): + return self.fleet_boss.clear_boss() diff --git a/config/template.json b/config/template.json index f1fb19049..5d699c633 100644 --- a/config/template.json +++ b/config/template.json @@ -1358,7 +1358,7 @@ }, "Campaign": { "Name": "D3", - "Event": "war_archives_20220224_cn", + "Event": "war_archives_20220324_cn", "Mode": "normal", "UseClearMode": true, "UseFleetLock": true, diff --git a/deploy/atomic.py b/deploy/atomic.py index 41b6ca76b..b64ce8a53 100644 --- a/deploy/atomic.py +++ b/deploy/atomic.py @@ -5,8 +5,13 @@ import string import time from typing import Union +# Max attempt if another process is reading/writing, effective only on Windows +WINDOWS_MAX_ATTEMPT = 5 +# Base time to wait between retries (seconds) +WINDOWS_RETRY_DELAY = 0.05 -def random_id(length=6): + +def random_id(length: int = 6) -> str: """ Args: length (int): 6 random letter (62^6 combinations) would be enough @@ -17,11 +22,44 @@ def random_id(length=6): return ''.join(random.sample(string.ascii_letters + string.digits, length)) +def is_tmp_file(filename: str) -> bool: + """ + Check if a filename is tmp file + """ + # Check suffix first to reduce regex calls + if not filename.endswith('.tmp'): + return False + # Check temp file format + res = re.match(r'.*\.[a-zA-Z0-9]{6,}\.tmp$', filename) + if not res: + return False + return True + + +def to_tmp_file(filename: str) -> str: + """ + Convert a filename or directory name to tmp + """ + suffix = random_id(6) + return f'{filename}.{suffix}.tmp' + + +def windows_attempt_delay(attempt: int) -> float: + """ + Exponential Backoff if file is in use on Windows + + Args: + attempt: Current attempt, starting from 0 + + Returns: + float: Seconds to wait + """ + return 2 ** attempt * WINDOWS_RETRY_DELAY + + def atomic_write( file: str, data: Union[str, bytes], - max_attempt=5, - retry_delay=0.05, ): """ Atomic file write with minimal IO operation @@ -33,12 +71,8 @@ def atomic_write( Args: file: data: - max_attempt: Max attempt if another process is reading, - effective only on Windows - retry_delay: Base time to wait between retries (seconds) """ - suffix = random_id(6) - temp = f'{file}.{suffix}.tmp' + temp = to_tmp_file(file) if isinstance(data, str): mode = 'w' encoding = 'utf-8' @@ -74,9 +108,7 @@ def atomic_write( if os.name == 'nt': # PermissionError on Windows if another process is reading last_error = None - if max_attempt < 1: - max_attempt = 1 - for trial in range(max_attempt): + for attempt in range(WINDOWS_MAX_ATTEMPT): try: # Atomic operation os.replace(temp, file) @@ -84,7 +116,111 @@ def atomic_write( return except PermissionError as e: last_error = e - delay = 2 ** trial * retry_delay + delay = windows_attempt_delay(attempt) + time.sleep(delay) + continue + except Exception as e: + last_error = e + break + else: + # Linux and Mac allow existing reading + try: + # Atomic operation + os.replace(temp, file) + # success + return + except Exception as e: + last_error = e + + # Clean up temp file on failure + try: + os.unlink(temp) + except: + pass + if last_error is not None: + raise last_error from None + + +def atomic_stream_write( + file: str, + data_generator, +): + """ + Atomic file write with streaming data support. + Handles cases where file might be read by another process. + os.replace() is an atomic operation among all OS, + we write to temp file then do os.replace() + + Only creates a file if the generator yields at least one data chunk. + Automatically determines write mode based on the type of first chunk. + + Args: + file: Target file path + data_generator: An iterable that yields data chunks (str or bytes) + """ + # Convert generator to iterator to ensure we can peek at first chunk + data_iter = iter(data_generator) + + # Try to get the first chunk + try: + first_chunk = next(data_iter) + except StopIteration: + # Generator is empty, no file will be created + return + + # Create temp file path + temp = to_tmp_file(file) + + # Determine mode, encoding and newline from first chunk + if isinstance(first_chunk, str): + mode = 'w' + encoding = 'utf-8' + newline = '' + elif isinstance(first_chunk, bytes): + mode = 'wb' + encoding = None + newline = None + else: + # Default to text mode for other types + mode = 'w' + encoding = 'utf-8' + newline = '' + + try: + # Write temp file + with open(temp, mode=mode, encoding=encoding, newline=newline) as f: + f.write(first_chunk) + for chunk in data_iter: + f.write(chunk) + # Ensure data flush to disk + f.flush() + os.fsync(f.fileno()) + except FileNotFoundError: + # Create parent directory + directory = os.path.dirname(file) + if directory: + os.makedirs(directory, exist_ok=True) + # Write again + with open(temp, mode=mode, encoding=encoding, newline=newline) as f: + f.write(first_chunk) + for chunk in data_iter: + f.write(chunk) + # Ensure data flush to disk + f.flush() + os.fsync(f.fileno()) + + last_error = None + if os.name == 'nt': + # PermissionError on Windows if another process is reading + for attempt in range(WINDOWS_MAX_ATTEMPT): + try: + # Atomic operation + os.replace(temp, file) + # success + return + except PermissionError as e: + last_error = e + delay = windows_attempt_delay(attempt) time.sleep(delay) continue except Exception as e: @@ -113,8 +249,6 @@ def atomic_read( file: str, mode: str = 'r', errors: str = 'strict', - max_attempt=5, - retry_delay=0.05, ): """ Atomic file read with minimal IO operation @@ -124,9 +258,6 @@ def atomic_read( file: mode: 'r' or 'rb' errors: 'strict', 'ignore', 'replace' and any other errors mode in open() - max_attempt: Max attempt if another process is reading, - effective only on Windows - retry_delay: Base time to wait between retries (seconds) Returns: str if mode is 'r' @@ -141,9 +272,7 @@ def atomic_read( if os.name == 'nt': # PermissionError on Windows if another process is replacing last_error = None - if max_attempt < 1: - max_attempt = 1 - for trial in range(max_attempt): + for attempt in range(WINDOWS_MAX_ATTEMPT): try: with open(file, mode=mode, encoding=encoding, errors=errors) as f: # success @@ -152,12 +281,9 @@ def atomic_read( return '' except PermissionError as e: last_error = e - delay = 2 ** trial * retry_delay + delay = windows_attempt_delay(attempt) time.sleep(delay) continue - except Exception as e: - last_error = e - break if last_error is not None: raise last_error from None else: @@ -170,7 +296,39 @@ def atomic_read( return '' -def atomic_failure_cleanup(path: str): +def atomic_remove(file: str): + """ + Atomic file remove + + Args: + file: + """ + if os.name == 'nt': + # PermissionError on Windows if another process is replacing + last_error = None + for attempt in range(WINDOWS_MAX_ATTEMPT): + try: + os.unlink(file) + except FileNotFoundError: + return + except PermissionError as e: + last_error = e + delay = windows_attempt_delay(attempt) + time.sleep(delay) + continue + if last_error is not None: + raise last_error from None + else: + # Linux and Mac allow deleting while another process is reading + # The directory entry is removed but the storage allocated to the file is not made available + # until the original file is no longer in use. + try: + os.unlink(file) + except FileNotFoundError: + return + + +def atomic_failure_cleanup(directory: str, recursive: bool = False): """ Cleanup remaining temp file under given path. In most cases there should be no remaining temp files unless write process get interrupted. @@ -178,24 +336,33 @@ def atomic_failure_cleanup(path: str): This method should only be called at startup to avoid deleting temp files that another process is writing. """ - with os.scandir(path) as entries: - for entry in entries: - if not entry.is_file(): - continue - # Check suffix first to reduce regex calls - name = entry.name - if not name.endswith('.tmp'): - continue - # Check temp file format - res = re.match(r'.*\.[a-zA-Z0-9]{6,}\.tmp$', name) - if not res: - continue - # Delete temp file - file = f'{path}{os.sep}{name}' - try: - os.unlink(file) - except PermissionError: - # Another process is reading/writing - pass - except: - pass + try: + with os.scandir(directory) as entries: + for entry in entries: + if is_tmp_file(entry.name): + # Delete temp file or directory + if entry.is_dir(follow_symlinks=False): + import shutil + shutil.rmtree(entry.path, ignore_errors=True) + else: + try: + os.unlink(entry.path) + except PermissionError: + # Another process is reading/writing + pass + except FileNotFoundError: + # Another process removed current file while iterating + pass + except: + pass + else: + if entry.is_dir(follow_symlinks=False): + # Normal directory + if recursive: + atomic_failure_cleanup(entry.path, recursive=True) + # Normal file + # else: + # pass + except FileNotFoundError: + # directory to clean up does not exist, no need to clean up + pass diff --git a/module/combat/combat.py b/module/combat/combat.py index 0b62b6e9b..97a6d74a7 100644 --- a/module/combat/combat.py +++ b/module/combat/combat.py @@ -106,6 +106,8 @@ class Combat(Level, HPBalancer, Retirement, SubmarineCall, CombatAuto, CombatMan # PAUSE_Pharaoh has random animation, assets should avoid the area in the middle and use match_luma if PAUSE_Pharaoh.match_luma(self.device.image, offset=(10, 10)): return PAUSE_Pharaoh + if PAUSE_Nurse.match_luma(self.device.image, offset=(10, 10)): + return PAUSE_Nurse return False def handle_combat_quit(self, offset=(20, 20), interval=3): @@ -135,6 +137,10 @@ class Combat(Level, HPBalancer, Retirement, SubmarineCall, CombatAuto, CombatMan self.device.click(QUIT_Pharaoh) timer.reset() return True + if QUIT_Nurse.match_luma(self.device.image, offset=offset): + self.device.click(QUIT_Nurse) + timer.reset() + return True return False def ensure_combat_oil_loaded(self): diff --git a/module/combat_ui/assets.py b/module/combat_ui/assets.py index 4b19da285..9e076c42f 100644 --- a/module/combat_ui/assets.py +++ b/module/combat_ui/assets.py @@ -12,9 +12,11 @@ PAUSE_HolyLight = Button(area={'cn': (1233, 35, 1250, 57), 'en': (1233, 35, 1250 PAUSE_Iridescent_Fantasy = Button(area={'cn': (1232, 33, 1252, 57), 'en': (1232, 33, 1252, 57), 'jp': (1232, 33, 1252, 57), 'tw': (1232, 33, 1252, 57)}, color={'cn': (124, 139, 190), 'en': (124, 139, 190), 'jp': (124, 139, 190), 'tw': (124, 139, 190)}, button={'cn': (1232, 33, 1252, 57), 'en': (1232, 33, 1252, 57), 'jp': (1232, 33, 1252, 57), 'tw': (1232, 33, 1252, 57)}, file={'cn': './assets/cn/combat_ui/PAUSE_Iridescent_Fantasy.png', 'en': './assets/en/combat_ui/PAUSE_Iridescent_Fantasy.png', 'jp': './assets/jp/combat_ui/PAUSE_Iridescent_Fantasy.png', 'tw': './assets/tw/combat_ui/PAUSE_Iridescent_Fantasy.png'}) PAUSE_Neon = Button(area={'cn': (1228, 32, 1250, 59), 'en': (1228, 32, 1250, 59), 'jp': (1228, 32, 1250, 59), 'tw': (1228, 32, 1250, 59)}, color={'cn': (106, 137, 80), 'en': (106, 137, 80), 'jp': (106, 137, 80), 'tw': (106, 137, 80)}, button={'cn': (1228, 32, 1250, 59), 'en': (1228, 32, 1250, 59), 'jp': (1228, 32, 1250, 59), 'tw': (1228, 32, 1250, 59)}, file={'cn': './assets/cn/combat_ui/PAUSE_Neon.png', 'en': './assets/cn/combat_ui/PAUSE_Neon.png', 'jp': './assets/cn/combat_ui/PAUSE_Neon.png', 'tw': './assets/cn/combat_ui/PAUSE_Neon.png'}) PAUSE_New = Button(area={'cn': (1231, 29, 1253, 56), 'en': (1231, 29, 1253, 56), 'jp': (1231, 29, 1253, 56), 'tw': (1231, 29, 1253, 56)}, color={'cn': (156, 158, 166), 'en': (156, 158, 166), 'jp': (156, 158, 166), 'tw': (156, 158, 166)}, button={'cn': (1231, 29, 1253, 56), 'en': (1231, 29, 1253, 56), 'jp': (1231, 29, 1253, 56), 'tw': (1231, 29, 1253, 56)}, file={'cn': './assets/cn/combat_ui/PAUSE_New.png', 'en': './assets/en/combat_ui/PAUSE_New.png', 'jp': './assets/jp/combat_ui/PAUSE_New.png', 'tw': './assets/tw/combat_ui/PAUSE_New.png'}) +PAUSE_Nurse = Button(area={'cn': (1236, 33, 1251, 50), 'en': (1236, 33, 1251, 50), 'jp': (1236, 33, 1251, 50), 'tw': (1236, 33, 1251, 50)}, color={'cn': (200, 206, 209), 'en': (200, 206, 209), 'jp': (200, 206, 209), 'tw': (200, 206, 209)}, button={'cn': (1236, 33, 1251, 50), 'en': (1236, 33, 1251, 50), 'jp': (1236, 33, 1251, 50), 'tw': (1236, 33, 1251, 50)}, file={'cn': './assets/cn/combat_ui/PAUSE_Nurse.png', 'en': './assets/cn/combat_ui/PAUSE_Nurse.png', 'jp': './assets/cn/combat_ui/PAUSE_Nurse.png', 'tw': './assets/cn/combat_ui/PAUSE_Nurse.png'}) PAUSE_Pharaoh = Button(area={'cn': (1229, 55, 1259, 62), 'en': (1229, 55, 1259, 62), 'jp': (1229, 55, 1259, 62), 'tw': (1229, 55, 1259, 62)}, color={'cn': (164, 119, 78), 'en': (164, 119, 78), 'jp': (164, 119, 78), 'tw': (164, 119, 78)}, button={'cn': (1229, 55, 1259, 62), 'en': (1229, 55, 1259, 62), 'jp': (1229, 55, 1259, 62), 'tw': (1229, 55, 1259, 62)}, file={'cn': './assets/cn/combat_ui/PAUSE_Pharaoh.png', 'en': './assets/cn/combat_ui/PAUSE_Pharaoh.png', 'jp': './assets/cn/combat_ui/PAUSE_Pharaoh.png', 'tw': './assets/cn/combat_ui/PAUSE_Pharaoh.png'}) QUIT = Button(area={'cn': (420, 490, 593, 548), 'en': (473, 508, 567, 532), 'jp': (433, 490, 606, 547), 'tw': (433, 490, 606, 547)}, color={'cn': (199, 122, 114), 'en': (216, 168, 164), 'jp': (196, 120, 113), 'tw': (200, 126, 118)}, button={'cn': (420, 490, 593, 548), 'en': (473, 508, 567, 532), 'jp': (433, 490, 606, 547), 'tw': (433, 490, 606, 547)}, file={'cn': './assets/cn/combat_ui/QUIT.png', 'en': './assets/en/combat_ui/QUIT.png', 'jp': './assets/jp/combat_ui/QUIT.png', 'tw': './assets/tw/combat_ui/QUIT.png'}) QUIT_Christmas = Button(area={'cn': (400, 506, 477, 525), 'en': (410, 507, 469, 524), 'jp': (400, 506, 477, 525), 'tw': (400, 506, 477, 525)}, color={'cn': (195, 139, 166), 'en': (207, 166, 185), 'jp': (195, 139, 166), 'tw': (195, 139, 166)}, button={'cn': (400, 506, 477, 525), 'en': (410, 507, 469, 524), 'jp': (400, 506, 477, 525), 'tw': (400, 506, 477, 525)}, file={'cn': './assets/cn/combat_ui/QUIT_Christmas.png', 'en': './assets/en/combat_ui/QUIT_Christmas.png', 'jp': './assets/cn/combat_ui/QUIT_Christmas.png', 'tw': './assets/cn/combat_ui/QUIT_Christmas.png'}) QUIT_Iridescent_Fantasy = Button(area={'cn': (391, 522, 464, 540), 'en': (402, 507, 460, 523), 'jp': (391, 522, 464, 540), 'tw': (391, 522, 464, 540)}, color={'cn': (121, 73, 79), 'en': (255, 174, 164), 'jp': (108, 60, 70), 'tw': (121, 73, 79)}, button={'cn': (391, 522, 464, 540), 'en': (402, 507, 460, 523), 'jp': (391, 522, 464, 540), 'tw': (391, 522, 464, 540)}, file={'cn': './assets/cn/combat_ui/QUIT_Iridescent_Fantasy.png', 'en': './assets/en/combat_ui/QUIT_Iridescent_Fantasy.png', 'jp': './assets/jp/combat_ui/QUIT_Iridescent_Fantasy.png', 'tw': './assets/cn/combat_ui/QUIT_Iridescent_Fantasy.png'}) -QUIT_New = Button(area={'cn': (394, 506, 467, 524), 'en': (404, 506, 463, 523), 'jp': (394, 506, 467, 524), 'tw': (394, 506, 467, 524)}, color={'cn': (255, 180, 171), 'en': (255, 195, 187), 'jp': (255, 180, 171), 'tw': (255, 180, 171)}, button={'cn': (394, 506, 467, 524), 'en': (404, 506, 463, 523), 'jp': (394, 506, 467, 524), 'tw': (394, 506, 467, 524)}, file={'cn': './assets/cn/combat_ui/QUIT_New.png', 'en': './assets/en/combat_ui/QUIT_New.png', 'jp': './assets/cn/combat_ui/QUIT_New.png', 'tw': './assets/cn/combat_ui/QUIT_New.png'}) +QUIT_New = Button(area={'cn': (394, 506, 467, 524), 'en': (404, 506, 463, 523), 'jp': (394, 506, 467, 524), 'tw': (393, 506, 470, 524)}, color={'cn': (255, 180, 171), 'en': (255, 195, 187), 'jp': (255, 180, 171), 'tw': (255, 198, 190)}, button={'cn': (394, 506, 467, 524), 'en': (404, 506, 463, 523), 'jp': (394, 506, 467, 524), 'tw': (393, 506, 470, 524)}, file={'cn': './assets/cn/combat_ui/QUIT_New.png', 'en': './assets/en/combat_ui/QUIT_New.png', 'jp': './assets/cn/combat_ui/QUIT_New.png', 'tw': './assets/tw/combat_ui/QUIT_New.png'}) +QUIT_Nurse = Button(area={'cn': (400, 507, 477, 525), 'en': (400, 507, 477, 525), 'jp': (400, 507, 477, 525), 'tw': (400, 507, 477, 525)}, color={'cn': (254, 193, 170), 'en': (254, 193, 170), 'jp': (254, 193, 170), 'tw': (254, 193, 170)}, button={'cn': (400, 507, 477, 525), 'en': (400, 507, 477, 525), 'jp': (400, 507, 477, 525), 'tw': (400, 507, 477, 525)}, file={'cn': './assets/cn/combat_ui/QUIT_Nurse.png', 'en': './assets/cn/combat_ui/QUIT_Nurse.png', 'jp': './assets/cn/combat_ui/QUIT_Nurse.png', 'tw': './assets/cn/combat_ui/QUIT_Nurse.png'}) QUIT_Pharaoh = Button(area={'cn': (400, 507, 477, 525), 'en': (400, 507, 477, 525), 'jp': (400, 507, 477, 525), 'tw': (400, 507, 477, 525)}, color={'cn': (204, 132, 108), 'en': (204, 132, 108), 'jp': (204, 132, 108), 'tw': (204, 132, 108)}, button={'cn': (400, 507, 477, 525), 'en': (400, 507, 477, 525), 'jp': (400, 507, 477, 525), 'tw': (400, 507, 477, 525)}, file={'cn': './assets/cn/combat_ui/QUIT_Pharaoh.png', 'en': './assets/cn/combat_ui/QUIT_Pharaoh.png', 'jp': './assets/cn/combat_ui/QUIT_Pharaoh.png', 'tw': './assets/cn/combat_ui/QUIT_Pharaoh.png'}) diff --git a/module/config/argument/args.json b/module/config/argument/args.json index 8f8004690..cd8aa9e22 100644 --- a/module/config/argument/args.json +++ b/module/config/argument/args.json @@ -7298,15 +7298,16 @@ "war_archives_20211229_cn", "war_archives_20220210_cn", "war_archives_20220224_cn", + "war_archives_20220324_cn", "war_archives_20220414_cn" ], "option_bold": [ - "war_archives_20220224_cn" + "war_archives_20220324_cn" ], - "cn": "war_archives_20220224_cn", - "en": "war_archives_20220224_cn", - "jp": "war_archives_20220224_cn", - "tw": "war_archives_20220224_cn" + "cn": "war_archives_20220324_cn", + "en": "war_archives_20220324_cn", + "jp": "war_archives_20220324_cn", + "tw": "war_archives_20220324_cn" }, "Mode": { "type": "select", diff --git a/module/config/i18n/en-US.json b/module/config/i18n/en-US.json index 6d9e0d175..fcc141986 100644 --- a/module/config/i18n/en-US.json +++ b/module/config/i18n/en-US.json @@ -1050,6 +1050,7 @@ "war_archives_20211229_cn": "archives Tower of Transcendence", "war_archives_20220210_cn": "archives Northern Overture", "war_archives_20220224_cn": "archives Abyssal Refrain", + "war_archives_20220324_cn": "archives Virtual Tower", "war_archives_20220414_cn": "archives Aurora Noctis" }, "Mode": { diff --git a/module/config/i18n/ja-JP.json b/module/config/i18n/ja-JP.json index 26bd68b7e..5a6536c08 100644 --- a/module/config/i18n/ja-JP.json +++ b/module/config/i18n/ja-JP.json @@ -1050,6 +1050,7 @@ "war_archives_20211229_cn": "檔案 遡望せし虹彩の塔", "war_archives_20220210_cn": "檔案 凍絶の北海", "war_archives_20220224_cn": "檔案 鳴動せし星霜の淵", + "war_archives_20220324_cn": "檔案 幻像の塔", "war_archives_20220414_cn": "檔案 極夜照らす幻光" }, "Mode": { diff --git a/module/config/i18n/zh-CN.json b/module/config/i18n/zh-CN.json index fb91a3ee0..e30cb663c 100644 --- a/module/config/i18n/zh-CN.json +++ b/module/config/i18n/zh-CN.json @@ -1050,6 +1050,7 @@ "war_archives_20211229_cn": "档案 逆转彩虹之塔", "war_archives_20220210_cn": "档案 北境序曲", "war_archives_20220224_cn": "档案 深度回音", + "war_archives_20220324_cn": "档案 虚像构筑之塔", "war_archives_20220414_cn": "档案 永夜幻光" }, "Mode": { diff --git a/module/config/i18n/zh-TW.json b/module/config/i18n/zh-TW.json index fc21e6d2b..c6b6bfbc2 100644 --- a/module/config/i18n/zh-TW.json +++ b/module/config/i18n/zh-TW.json @@ -1050,6 +1050,7 @@ "war_archives_20211229_cn": "檔案 逆轉彩虹之塔", "war_archives_20220210_cn": "檔案 北境序曲", "war_archives_20220224_cn": "檔案 深度回音", + "war_archives_20220324_cn": "檔案 虛像構築之塔", "war_archives_20220414_cn": "檔案 永夜幻光" }, "Mode": { diff --git a/module/device/connection.py b/module/device/connection.py index b5d058bc2..7d75b2ac2 100644 --- a/module/device/connection.py +++ b/module/device/connection.py @@ -11,6 +11,7 @@ from adbutils import AdbClient, AdbDevice, AdbTimeout, ForwardItem, ReverseItem from adbutils.errors import AdbError from module.base.decorator import Config, cached_property, del_cached_property, run_once +from module.base.timer import Timer from module.base.utils import ensure_time from module.config.server import VALID_CHANNEL_PACKAGE, VALID_PACKAGE, set_server from module.device.connection_attr import ConnectionAttr @@ -113,7 +114,7 @@ class Connection(ConnectionAttr): self.detect_device() # Connect - self.adb_connect() + self.adb_connect(wait_device=False) logger.attr('AdbDevice', self.adb) # Package @@ -673,8 +674,40 @@ class Connection(ConnectionAttr): cmd = ['push', local, remote] return self.adb_command(cmd, timeout=timeout) + def _wait_device_appear(self, serial, first_devices=None): + """ + Args: + serial: + first_devices (list[AdbDeviceWithStatus]): + + Returns: + bool: If appear + """ + # Wait a little longer than 5s + timeout = Timer(5.2).start() + first_log = True + while 1: + if first_devices is not None: + devices = first_devices + first_devices = None + else: + devices = self.list_device() + # Check if device appear + for device in devices: + if device.serial == serial and device.status == 'device': + return True + # Delay and check later + if timeout.reached(): + break + if first_log: + logger.info(f'Waiting device appear: {serial}') + first_log = False + time.sleep(0.05) + + return False + @Config.when(DEVICE_OVER_HTTP=False) - def adb_connect(self): + def adb_connect(self, wait_device=True): """ Connect to a serial, try 3 times at max. If there's an old ADB server running while Alas is using a newer one, which happens on Chinese emulators, @@ -682,12 +715,14 @@ class Connection(ConnectionAttr): Args: serial (str): + wait_device: True to wait emulator-* and android devices appear Returns: bool: If success """ # Disconnect offline device before connecting - for device in self.list_device(): + devices = self.list_device() + for device in devices: if device.status == 'offline': logger.warning(f'Device {device.serial} is offline, disconnect it before connecting') msg = self.adb_client.disconnect(device.serial) @@ -700,11 +735,23 @@ class Connection(ConnectionAttr): else: logger.warning(f'Device {device.serial} is is having a unknown status: {device.status}') - # Skip for emulator-5554 + # Skip connecting emulator-5554 and android phones, as they should be auto connected once plugged in if 'emulator-' in self.serial: + if wait_device: + if self._wait_device_appear(self.serial, first_devices=devices): + logger.info(f'Serial {self.serial} connected') + return True + else: + logger.info(f'Serial {self.serial} is not connected') logger.info(f'"{self.serial}" is a `emulator-*` serial, skip adb connect') return True if re.match(r'^[a-zA-Z0-9]+$', self.serial): + if wait_device: + if self._wait_device_appear(self.serial, first_devices=devices): + logger.info(f'Serial {self.serial} connected') + return True + else: + logger.info(f'Serial {self.serial} is not connected') logger.info(f'"{self.serial}" seems to be a Android serial, skip adb connect') return True @@ -769,7 +816,7 @@ class Connection(ConnectionAttr): ev.close() @Config.when(DEVICE_OVER_HTTP=True) - def adb_connect(self): + def adb_connect(self, wait_device=True): # No adb connect if over http return True diff --git a/module/device/method/utils.py b/module/device/method/utils.py index 6f12a26c1..1256c8471 100644 --- a/module/device/method/utils.py +++ b/module/device/method/utils.py @@ -238,6 +238,11 @@ def handle_adb_error(e): # Raised by uiautomator2 when current adb service is killed by another version of adb service. logger.error(e) return True + elif text == 'rest': + # AdbError(rest) + # Response telling adbd service has reset, client should reconnect + logger.error(e) + return True else: # AdbError() logger.exception(e) diff --git a/module/event_hospital/assets.py b/module/event_hospital/assets.py index e94890501..a29b9505e 100644 --- a/module/event_hospital/assets.py +++ b/module/event_hospital/assets.py @@ -30,5 +30,6 @@ TAB_LOCATION = Button(area={'cn': (89, 93, 240, 121), 'en': (89, 93, 240, 121), TAB_RECORD = Button(area={'cn': (416, 93, 509, 121), 'en': (416, 93, 509, 121), 'jp': (416, 93, 509, 121), 'tw': (416, 93, 509, 121)}, color={'cn': (185, 177, 187), 'en': (185, 177, 187), 'jp': (185, 177, 187), 'tw': (185, 177, 187)}, button={'cn': (416, 93, 509, 121), 'en': (416, 93, 509, 121), 'jp': (416, 93, 509, 121), 'tw': (416, 93, 509, 121)}, file={'cn': './assets/cn/event_hospital/TAB_RECORD.png', 'en': './assets/cn/event_hospital/TAB_RECORD.png', 'jp': './assets/cn/event_hospital/TAB_RECORD.png', 'tw': './assets/cn/event_hospital/TAB_RECORD.png'}) TAB_SECRET = Button(area={'cn': (572, 95, 652, 119), 'en': (572, 95, 652, 119), 'jp': (572, 95, 652, 119), 'tw': (572, 95, 652, 119)}, color={'cn': (171, 166, 178), 'en': (171, 166, 178), 'jp': (171, 166, 178), 'tw': (171, 166, 178)}, button={'cn': (572, 95, 652, 119), 'en': (572, 95, 652, 119), 'jp': (572, 95, 652, 119), 'tw': (572, 95, 652, 119)}, file={'cn': './assets/cn/event_hospital/TAB_SECRET.png', 'en': './assets/cn/event_hospital/TAB_SECRET.png', 'jp': './assets/cn/event_hospital/TAB_SECRET.png', 'tw': './assets/cn/event_hospital/TAB_SECRET.png'}) TEMPLATE_INVEST = Template(file={'cn': './assets/cn/event_hospital/TEMPLATE_INVEST.png', 'en': './assets/en/event_hospital/TEMPLATE_INVEST.png', 'jp': './assets/jp/event_hospital/TEMPLATE_INVEST.png', 'tw': './assets/cn/event_hospital/TEMPLATE_INVEST.png'}) +TEMPLATE_INVEST2 = Template(file={'cn': './assets/cn/event_hospital/TEMPLATE_INVEST2.png', 'en': './assets/cn/event_hospital/TEMPLATE_INVEST2.png', 'jp': './assets/cn/event_hospital/TEMPLATE_INVEST2.png', 'tw': './assets/cn/event_hospital/TEMPLATE_INVEST2.png'}) TEMPLATE_REMAIN_CURRENT = Template(file={'cn': './assets/cn/event_hospital/TEMPLATE_REMAIN_CURRENT.png', 'en': './assets/en/event_hospital/TEMPLATE_REMAIN_CURRENT.png', 'jp': './assets/jp/event_hospital/TEMPLATE_REMAIN_CURRENT.png', 'tw': './assets/cn/event_hospital/TEMPLATE_REMAIN_CURRENT.png'}) TEMPLATE_REMAIN_TIMES = Template(file={'cn': './assets/cn/event_hospital/TEMPLATE_REMAIN_TIMES.png', 'en': './assets/en/event_hospital/TEMPLATE_REMAIN_TIMES.png', 'jp': './assets/jp/event_hospital/TEMPLATE_REMAIN_TIMES.png', 'tw': './assets/cn/event_hospital/TEMPLATE_REMAIN_TIMES.png'}) diff --git a/module/event_hospital/clue.py b/module/event_hospital/clue.py index 76fe0a552..c202f340f 100644 --- a/module/event_hospital/clue.py +++ b/module/event_hospital/clue.py @@ -162,6 +162,8 @@ class HospitalClue(HospitalUI): # Search INVEST buttons = TEMPLATE_INVEST.match_multi(image) + buttons += TEMPLATE_INVEST2.match_multi(image) + buttons = sorted(buttons, key=lambda b: b.area[1]) count = len(buttons) if count == 0: return None @@ -188,6 +190,7 @@ class HospitalClue(HospitalUI): return button if TEMPLATE_REMAIN_TIMES.match(image): return button + return None def clue_enter(self, skip_first_screenshot=True): """ @@ -256,6 +259,7 @@ class HospitalClue(HospitalUI): return False logger.info(f'is_in_clue -> {invest}') self.device.click(invest) + self.interval_reset(HOSIPITAL_CLUE_CHECK, interval=2) continue if self.appear_then_click(HOSPITAL_BATTLE_PREPARE, offset=(20, 20), interval=2): continue @@ -316,7 +320,8 @@ class HospitalClue(HospitalUI): area = button.area search = CLUE_LIST.area # Search if there's any cyan - area = (search[0], area[1], search[2], area[3]) + # JP has text overflowed, set right to 308 + area = (search[0], area[1], 308, area[3]) return self.image_color_count(area, color=(74, 130, 148), threshold=221, count=20) def iter_aside(self): @@ -388,6 +393,7 @@ class HospitalClue(HospitalUI): return False logger.info(f'is_in_clue -> {aside}') self.device.click(aside) + self.interval_reset(HOSIPITAL_CLUE_CHECK, interval=2) continue if self.handle_clue_exit(): continue diff --git a/module/exercise/hp_daemon.py b/module/exercise/hp_daemon.py index f9f62d6f6..3ab53d96a 100644 --- a/module/exercise/hp_daemon.py +++ b/module/exercise/hp_daemon.py @@ -1,16 +1,8 @@ from module.base.base import ModuleBase from module.base.timer import Timer from module.base.utils import color_bar_percentage -from module.combat_ui.assets import ( - PAUSE, - PAUSE_Christmas, - PAUSE_Cyber, - PAUSE_HolyLight, - PAUSE_Iridescent_Fantasy, - PAUSE_Neon, - PAUSE_New, - PAUSE_Pharaoh, -) +from module.combat_ui.assets import (PAUSE, PAUSE_Christmas, PAUSE_Cyber, PAUSE_HolyLight, PAUSE_Iridescent_Fantasy, + PAUSE_Neon, PAUSE_New, PAUSE_Nurse, PAUSE_Pharaoh) from module.exercise.assets import * from module.logger import logger @@ -78,6 +70,7 @@ class HpDaemon(ModuleBase): PAUSE_Cyber, PAUSE_HolyLight, PAUSE_Pharaoh, + PAUSE_Nurse, ]: self.attacker_hp = self._calculate_hp(image, area=ATTACKER_HP_AREA_New.area, reverse=True) self.defender_hp = self._calculate_hp(image, area=DEFENDER_HP_AREA_New.area, reverse=True) diff --git a/module/war_archives/assets.py b/module/war_archives/assets.py index 86339322f..2a62d3ab1 100644 --- a/module/war_archives/assets.py +++ b/module/war_archives/assets.py @@ -39,6 +39,7 @@ TEMPLATE_THE_WAY_HOME_IN_THE_NIGHT = Template(file={'cn': './assets/cn/war_archi TEMPLATE_TOWER_OF_TRANSCENDENCE = Template(file={'cn': './assets/cn/war_archives/TEMPLATE_TOWER_OF_TRANSCENDENCE.png', 'en': './assets/cn/war_archives/TEMPLATE_TOWER_OF_TRANSCENDENCE.png', 'jp': './assets/cn/war_archives/TEMPLATE_TOWER_OF_TRANSCENDENCE.png', 'tw': './assets/cn/war_archives/TEMPLATE_TOWER_OF_TRANSCENDENCE.png'}) TEMPLATE_UNIVERSE_IN_UNISON = Template(file={'cn': './assets/cn/war_archives/TEMPLATE_UNIVERSE_IN_UNISON.png', 'en': './assets/en/war_archives/TEMPLATE_UNIVERSE_IN_UNISON.png', 'jp': './assets/jp/war_archives/TEMPLATE_UNIVERSE_IN_UNISON.png', 'tw': './assets/cn/war_archives/TEMPLATE_UNIVERSE_IN_UNISON.png'}) TEMPLATE_UPON_THE_SHIMMERING_BLUE = Template(file={'cn': './assets/cn/war_archives/TEMPLATE_UPON_THE_SHIMMERING_BLUE.png', 'en': './assets/cn/war_archives/TEMPLATE_UPON_THE_SHIMMERING_BLUE.png', 'jp': './assets/cn/war_archives/TEMPLATE_UPON_THE_SHIMMERING_BLUE.png', 'tw': './assets/cn/war_archives/TEMPLATE_UPON_THE_SHIMMERING_BLUE.png'}) +TEMPLATE_VIRTUAL_TOWER = Template(file={'cn': './assets/cn/war_archives/TEMPLATE_VIRTUAL_TOWER.png', 'en': './assets/cn/war_archives/TEMPLATE_VIRTUAL_TOWER.png', 'jp': './assets/cn/war_archives/TEMPLATE_VIRTUAL_TOWER.png', 'tw': './assets/cn/war_archives/TEMPLATE_VIRTUAL_TOWER.png'}) TEMPLATE_VISITORS_DYED_IN_RED = Template(file={'cn': './assets/cn/war_archives/TEMPLATE_VISITORS_DYED_IN_RED.png', 'en': './assets/en/war_archives/TEMPLATE_VISITORS_DYED_IN_RED.png', 'jp': './assets/jp/war_archives/TEMPLATE_VISITORS_DYED_IN_RED.png', 'tw': './assets/tw/war_archives/TEMPLATE_VISITORS_DYED_IN_RED.png'}) TEMPLATE_WINTERS_CROWN = Template(file={'cn': './assets/cn/war_archives/TEMPLATE_WINTERS_CROWN.png', 'en': './assets/en/war_archives/TEMPLATE_WINTERS_CROWN.png', 'jp': './assets/jp/war_archives/TEMPLATE_WINTERS_CROWN.png', 'tw': './assets/tw/war_archives/TEMPLATE_WINTERS_CROWN.png'}) WAR_ARCHIVES_CAMPAIGN_CHECK = Button(area={'cn': (1150, 101, 1166, 130), 'en': (1150, 101, 1166, 130), 'jp': (1150, 101, 1166, 130), 'tw': (1150, 101, 1166, 130)}, color={'cn': (134, 175, 207), 'en': (134, 175, 207), 'jp': (134, 175, 207), 'tw': (134, 175, 207)}, button={'cn': (1150, 101, 1166, 130), 'en': (1150, 101, 1166, 130), 'jp': (1150, 101, 1166, 130), 'tw': (1150, 101, 1166, 130)}, file={'cn': './assets/cn/war_archives/WAR_ARCHIVES_CAMPAIGN_CHECK.png', 'en': './assets/en/war_archives/WAR_ARCHIVES_CAMPAIGN_CHECK.png', 'jp': './assets/jp/war_archives/WAR_ARCHIVES_CAMPAIGN_CHECK.png', 'tw': './assets/tw/war_archives/WAR_ARCHIVES_CAMPAIGN_CHECK.png'}) diff --git a/module/war_archives/dictionary.py b/module/war_archives/dictionary.py index 713e2bc83..94f81f8ea 100644 --- a/module/war_archives/dictionary.py +++ b/module/war_archives/dictionary.py @@ -37,4 +37,5 @@ dic_archives_template = { 'war_archives_20210422_cn': TEMPLATE_DAEDALIAN_HYMN, 'war_archives_20211229_cn': TEMPLATE_TOWER_OF_TRANSCENDENCE, 'war_archives_20220224_cn': TEMPLATE_ABYSSAL_REFRAIN, + 'war_archives_20220324_cn': TEMPLATE_VIRTUAL_TOWER, } diff --git a/module/webui/app.py b/module/webui/app.py index d85c692ba..5465bdd0f 100644 --- a/module/webui/app.py +++ b/module/webui/app.py @@ -142,6 +142,11 @@ class AlasGUI(Frame): self.alas_mod = "alas" self.alas_config = AzurLaneConfig("template") self.initial() + # rendered state cache + self.rendered_cache = [] + self.inst_cache = [] + self.load_home = False + self.af_flag = False @use_scope("aside", clear=True) def set_aside(self) -> None: @@ -151,12 +156,11 @@ class AlasGUI(Frame): buttons=[{"label": t("Gui.Aside.Home"), "value": "Home", "color": "aside"}], onclick=[self.ui_develop], ) - for name in alas_instance(): - put_icon_buttons( - Icon.RUN, - buttons=[{"label": name, "value": name, "color": "aside"}], - onclick=self.ui_alas, - ) + put_scope("aside_instance",[ + put_scope(f"alas-instance-{i}",[]) + for i, _ in enumerate(alas_instance()) + ]) + self.set_aside_status() put_icon_buttons( Icon.SETTING, buttons=[ @@ -169,6 +173,51 @@ class AlasGUI(Frame): onclick=[lambda: go_app("manage", new_window=False)], ) + current_date = datetime.now().date() + if current_date.month == 4 and current_date.day == 1: + self.af_flag = True + + @use_scope("aside_instance") + def set_aside_status(self) -> None: + flag = True + def update(name, seq): + with use_scope(f"alas-instance-{seq}", clear=True): + icon_html = Icon.RUN + rendered_state = ProcessManager.get_manager(inst).state + if rendered_state == 1 and self.af_flag: + icon_html = icon_html[:31] + ' anim-rotate' + icon_html[31:] + put_icon_buttons( + icon_html, + buttons=[{"label": name, "value": name, "color": "aside"}], + onclick=self.ui_alas, + ) + return rendered_state + + if not len(self.rendered_cache) or self.load_home: + # Reload when add/delete new instance | first start app.py | go to HomePage (HomePage load call force reload) + flag = False + self.inst_cache.clear() + self.inst_cache = alas_instance() + if flag: + for index, inst in enumerate(self.inst_cache): + # Check for state change + state = ProcessManager.get_manager(inst).state + if state != self.rendered_cache[index]: + self.rendered_cache[index] = update(inst, index) + flag = False + else: + self.rendered_cache.clear() + clear("aside_instance") + for index, inst in enumerate(self.inst_cache): + self.rendered_cache.append(update(inst, index)) + self.load_home = False + if not flag: + # Redraw lost focus, now focus on aside button + aside_name = get_localstorage("aside") + self.active_button("aside", aside_name) + + return + @use_scope("header_status") def set_status(self, state: int) -> None: """ @@ -1244,6 +1293,7 @@ class AlasGUI(Frame): def show(self) -> None: self._show() + self.load_home = True self.set_aside() self.init_aside(name="Home") self.dev_set_menu() @@ -1391,6 +1441,7 @@ class AlasGUI(Frame): ) self.task_handler.add(self.state_switch.g(), 2) + self.task_handler.add(self.set_aside_status, 2) self.task_handler.add(visibility_state_switch.g(), 15) self.task_handler.add(update_switch.g(), 1) self.task_handler.start()