From ced43f408b2eef1afec645736b1109f9f9f39e23 Mon Sep 17 00:00:00 2001 From: positnuec <93694981+positnuec@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:44:59 +0800 Subject: [PATCH 01/20] Add: page_settings --- assets/cn/ui/SETTINGS_CHECK.png | Bin 0 -> 6358 bytes module/ui/assets.py | 1 + module/ui/page.py | 4 ++++ 3 files changed, 5 insertions(+) create mode 100644 assets/cn/ui/SETTINGS_CHECK.png diff --git a/assets/cn/ui/SETTINGS_CHECK.png b/assets/cn/ui/SETTINGS_CHECK.png new file mode 100644 index 0000000000000000000000000000000000000000..fb8f489d5de38433964c1dd9a9ec150025faaa5f GIT binary patch literal 6358 zcmeHKdsGu=-j2FzyDqT7)6%L)&am~<)?9~#BoL@I4c-F9+Hz6AkW3)vY7%Y{ke0J0 zECOXwtbpQyFDM^~RRju%!Ga1xxCjyn2nYxwNI(P?2z+l8+3s%la`xYK&dHf~W}e^e zd4BJ2GVx!yyIK5p-EUDSl*P^+%)KboO0Zns{LXT4`qXaMGH`lVw!=q(LakYA{w+hL zXMO-sb%K4~N^kZaI#(*jad^@|1Q#xr0W=Eb>>4iPaD|8x6Nm%}Bn-${p@lF49s}|s zvk7e3b|hG^LnTMPRJrfts)Sq`4{~+EIET}L12LlHV8X>BiGm)^fL{5fgSGiK9>TnW zD1{7Yn>irHoBai5yHt)~XgC6vOCS(1jx-$6k;o-E^0#701QH2PpyG)XERjklkmwFD zX7PYPI603`-^*kz1_M_NC|IeK(ee1OurOR0jFZZP@I)Goh9{8lBoY=Nu!;zYk`s=V zDD2;OU?K{xTp&{lq!NtTBPUQAqGUiI)z>wMWlL@)ibb9PWB7263{S)n%r(6N^0-Sl zS%_TpDmageM?{Dikth`aOI*Urf~88SB3SwlTrWNTs{nvoHhU??ujV2aFNIJjKMw_E zEE4joXvMw=8G_%7D5N2BF7kOONYmb2jf}osj&PJx`97&s^hT*K-lU8nIp7GG&0Yct zPa3A!@(Tlm$x$K<2&hKE5-3=hu#X7S$uv62@e=}&kMJ7GmhuGrh`)zY=mdcNYbfX$ z9!JUfmtY>3&X>x?98j=8%n3s9GD#4GS)!4?T`H2wL12&_{3;7Jo4!+`P;w+(WG9mW zftqmw0gsLli3FHKM6eVZ3BmHn4hWV*MmShL3{w#rk<2GjIB)EkQf`P@S+o7?F6T+P zz{g*rQfO3QO@Ofk5-$+T<2q2W90H$!rI5)SDve7*5coAGAR7<40Bkgl=$Bs2S@D2J zY9J3G6Sy=i2_aLkyg&yBEQdq|D;|eJaimcZDv59I=2s0u-yu){=^_^Q$Ct?A#g#~a zdDRni4%gi442WwM8R0>Tw*|k#$G;VM(LO8~0Z#vk_^+cYqSij^4n* zj59BnrYpEun$L&?xR!(In`S(yL7}XRb~3l^3(tIV|4H%zz2EBb9%FaMl*>K$KI)~6 zn!|IyzZLN*@!j74YyNiE`agZ^ni}s#4MP;6_7X*b_4DW|9A`tYlWsDW{<`WzQdaX?tjn=C{n-o(?wR&AF{Iz?%@T9LWzV^H)42?X!o~u)<`)_rZ#g+nYs)n{p zYbJx3SRearLArXgrkjAVKNO!HyVa?-EaWAlf96ppo5{=zp^~_|@At111DmnkIhIwP z3O1y>S~%!j@PP73@gv_f*8)|*S9y)=JacN&SLA>9P8C2FaIY+*=CrHh>*_AfJ;-lu zZAE)}Jr(%WUGd3je|YdJ)pY^iGaFZyaI$^RS~Pmr(pDvE>b9-es4FQWX~zTXqQ`se zqPp;Yh3oz>5BmAX!j@IN*65VgSqsIcCTBfGpQK+`T!39^wOq#6Pc@x!nd;VRzglf- z{;MNLr;Pk7zDc{6U(qC6`@tgY#%)76i5 z@t6ZOZEd@?CZ||&R!{UuKu^R-oJpg({)eKWChg1Qi&@U0b0a-7g9B}ClXz`pU#wN5 zuEBfd?|{hV>HDo-9u;=_`=N{H@gEkm+OMm6kEv~)!dxy3!{X;ADvXwQq%6uQHY?S0 z_rkNfwbHB8_X|fhZQ5j6IsWalHNb~X#6(>aA30;tJ5=78@`!vEeN?aohJ{R@h!b~A za}6H&hDKJ+H`Q^*=_#{R5#pPu%zBHKBhz-G&cUae-P%ls_NvFTkjFoFbv;b5v6s(o zcQS;o#S2!UP`!l)-ZH8^^!3z^0HMI&ao%$4)&?t=J$u+mkADWV{B$dkg3@>Tp4cyJ zt4%-52`vH2l<1R!j;l%Dtjv^CY<@-uKef{?90QdXW>hGLlX}eaFUu;UVEG15xyG=+ zX>Opk_J{6krue8?sLzlZ4S!wo=k9WG=d%(qddg;qKi2r(3eX1ld&*_hz?Y|XkQ>j; zC^=*DP6xN7jZxzdPUo|Jx4WL(-``{#A(&lZm1b?!ni~Deyg{6?ch_6TjGVm?@#3A{ z4E$i?d2*cnZl-@`<%_zBe)L3hwUc)Kg4C!Qou~~qh|)#rT_oZ}pLD)m8aa{BVhng8 z8XTD5mZsaHP%*RmRpJciV1NI+Ws#|z*ca*55B&5CrFM}EYdl6Hf`{yrM-8UOb_>Jt zR<6!RX4?9Gp5uCqMor(a`JYYa4sG!o9EyI?~qzMXew$A^!4p?4$Zs<8ZGceFwv77+P8ad=fpG}Gj zb7OW2?RsYS!7XW+I^$zv`KqZJQ(Df@Im`YxX)&&G3i;ZSyr3Gl0a2&bISF&)5&A&GVWQE zz39h_e5Q;M;*p{GyPC-?-EoQFUc&(xvbVpSB</z`TiJ@js`lkX1>4%!R~x8!e% zKmV}imhp{C@uzE)Ytch|J_LCg%Zll$ptc83duj{nHC+Y8ELap;_)+23t^PjVnTZ{D z*F?c5J&xsecPrw|Tv>^_*7X(_50*WKPX-uJDafP;=+@Fg=cMfc^5h@H@M6_ylC3idc|+}JBt?2 zil2xdxG@_t>653ma|y@)2*P&N>zP+ZE%j?2yVy z+s(ccH_lnP1_+PuExfQH>FRvVLp|4H>^_Ou@*blZP*}jNd9T+uLv&NskM`7;;D&TF zX>c~{f@e9dC|>P*w0Nz(p(N#~Ihy|bCsk(@kg*;eR10MT`j`*BtISu;)bEV~$3axoLk1yopb z?tvAN6_;5bQ^5B5@r-Um4B`XskNtpNeXz0#tVEVM_gAXUQZ&;ux*xMU^JBfcERJG} znaoy7Y;}KS$uQF9o6~+NdiXBq7%sL9Iux(=e?}rf<&U83x?lsV*E}HjU@qbtbh;+L zjmAVW8+IXXO!4zF`@&d>A(d;_h?a#I{YevfyvGYTdu2~)Py^STdBZba9S&@! zLrdF(BR=KjAD~kXBZn{b7~Pn7Rkh^F!wIyfP`63g&Zh Date: Thu, 25 Jun 2026 22:25:00 +0800 Subject: [PATCH 02/20] Add: dev_tools.downtime_notice_crawler --- dev_tools/downtime_notice_crawler.py | 301 +++++++++++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 dev_tools/downtime_notice_crawler.py diff --git a/dev_tools/downtime_notice_crawler.py b/dev_tools/downtime_notice_crawler.py new file mode 100644 index 000000000..ca9dfbd54 --- /dev/null +++ b/dev_tools/downtime_notice_crawler.py @@ -0,0 +1,301 @@ +""" +碧蓝航线停服维护公告爬取工具 + +从B站碧蓝航线官方专栏合集抓取最新维护公告,输出停服时间段。 + +默认项 + --mid 233114659(碧蓝航线B站官方号UID) + --collection-name 《碧蓝航线》维护公告 + --cache 公告缓存文件,默认 maintenance/_cache.jsonl,位于脚本同级目录。 + --state 合集状态文件,默认 maintenance/state.json,位于脚本同级目录。 + +命令 + fetch 爬取:拉取专栏合集数据,写入缓存。 + parse 解析:从缓存读取并解析公告,向 stdout 输出纯文本停服时间段,如:2026-06-20 14:00~18:00 + 解析失败输出空行,并返回退出码 2。 + run 完整流程测试:拉取最新公告并解析停服维护信息,不依赖缓存。 + +示例 + .\\toolkit\\python.exe dev_tools/bilibili_maintenance.py fetch --force + .\\toolkit\\python.exe dev_tools/bilibili_maintenance.py parse + .\\toolkit\\python.exe dev_tools/bilibili_maintenance.py run --verbose +""" +from __future__ import annotations + +import argparse +import json +import logging +import re +import sys +from dataclasses import dataclass +from datetime import datetime, timezone, timedelta +from pathlib import Path +from typing import Optional + +import requests + +SCRIPT_DIR = Path(__file__).resolve().parent +DEFAULT_MID = 233114659 +DEFAULT_CACHE = SCRIPT_DIR / 'maintenance' / '_cache.jsonl' +DEFAULT_STATE = SCRIPT_DIR / 'maintenance' / 'state.json' +DEFAULT_COLLECTION_NAME = '《碧蓝航线》维护公告' + +ARTICLE_LISTS_API = 'https://api.bilibili.com/x/article/up/lists' +ARTICLE_COLLECTION_API = 'https://api.bilibili.com/x/article/list/web/articles' +CN_TZ = timezone(timedelta(hours=8)) + +WS = r'[\s ]*' +WINDOW_RE = re.compile( + r'司令部将于' + WS + + r'(\d{1,2})月(\d{1,2})日' + WS + + r'(\d{1,2})[::](\d{2})' + WS + + r'[~~至到—\-]+' + WS + + r'(\d{1,2})[::](\d{2})', +) +DURATION_RE = re.compile(r'为期' + WS + r'(\d+)' + WS + r'个?小时') +TITLE_TIME_RE = re.compile(r'(\d{1,2})月(\d{1,2})日' + WS + r'(\d{1,2})[::](\d{2})') + +logger = logging.getLogger('bilibili_maintenance') + + +class FetchError(RuntimeError): + pass + + +@dataclass +class MaintenanceInfo: + window: str + duration_hours: int + + +@dataclass +class CollectionMeta: + collection_id: int + collection_name: str + articles_count: int + update_time: int + + +def configure_stdio() -> None: + if hasattr(sys.stdout, 'reconfigure'): + sys.stdout.reconfigure(encoding='utf-8') + + +def session_for(mid: int) -> requests.Session: + s = requests.Session() + s.headers.update({ + 'User-Agent': ( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36' + ), + 'Referer': f'https://space.bilibili.com/{mid}/article', + }) + return s + + +def get_json(session: requests.Session, url: str, params: dict, timeout: float) -> dict: + response = session.get(url, params=params, timeout=timeout) + response.raise_for_status() + payload = response.json() + if payload['code'] != 0: + raise FetchError(payload.get('message') or payload.get('msg') or 'unknown error') + return payload + + +def find_collection(session: requests.Session, mid: int, name: str, timeout: float) -> tuple[CollectionMeta, dict]: + payload = get_json(session, ARTICLE_LISTS_API, {'mid': str(mid), 'sort': 0}, timeout) + for item in payload['data']['lists']: + if item['name'] == name: + return CollectionMeta(collection_id=int(item['id']), collection_name=item['name'], articles_count=int(item['articles_count']), update_time=int(item['update_time'])), payload + raise FetchError(f'collection not found: mid={mid} name={name!r}') + + +def normalize_article(article: dict, meta: CollectionMeta) -> dict: + cvid = str(article['id']) + pub_ts = int(article['publish_time']) + return { + 'pub_ts': pub_ts, + 'pub_time': datetime.fromtimestamp(pub_ts, tz=CN_TZ).isoformat(), + 'title': article['title'], + 'text': article['summary'], + 'url': f'https://www.bilibili.com/read/cv{cvid}', + 'collection_id': meta.collection_id, + 'collection_name': meta.collection_name, + } + + +def load_state(path: Path) -> dict: + if not path.exists(): + return {} + return json.loads(path.read_text(encoding='utf-8')) + + +def load_jsonl(path: Path) -> list[dict]: + if not path.exists(): + return [] + return [json.loads(line) for line in path.read_text(encoding='utf-8').splitlines() if line.strip()] + + +def persist_fetch(cache_path: Path, state_path: Path, records: list[dict], state: dict) -> None: + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_content = ''.join(json.dumps(record, ensure_ascii=False) + '\n' for record in records) + state_content = json.dumps(state, ensure_ascii=False, indent=2) + cache_tmp = cache_path.with_suffix(cache_path.suffix + '.tmp') + state_tmp = state_path.with_suffix(state_path.suffix + '.tmp') + cache_tmp.write_text(cache_content, encoding='utf-8') + state_tmp.write_text(state_content, encoding='utf-8') + cache_tmp.replace(cache_path) + state_tmp.replace(state_path) + + +def clean_text(text: str) -> str: + return text.replace('[图片]', '').replace('\u3000', ' ').strip() + + +def infer_year(published: datetime, month: int, day: int) -> int: + year = published.year + delta = (datetime(year, month, day, tzinfo=CN_TZ).date() - published.date()).days + if delta < -60: + year += 1 + elif delta > 300: + year -= 1 + return year + + +def _build_window_dt(published: datetime, sm: int, sd: int, sh: int, smin: int, eh: int, emin: int) -> tuple[datetime, datetime]: + year = infer_year(published, sm, sd) + start = datetime(year, sm, sd, sh, smin, tzinfo=CN_TZ) + end = datetime(year, sm, sd, eh, emin, tzinfo=CN_TZ) + return start, end + + +def _hours_between(start: datetime, end: datetime) -> int: + return round((end - start).total_seconds() / 3600) + + +def parse_maintenance(text: str, pub_ts: int, title: str) -> Optional[MaintenanceInfo]: + published = datetime.fromtimestamp(pub_ts, tz=CN_TZ) + body = clean_text(text) + window_match = WINDOW_RE.search(body) + duration_match = DURATION_RE.search(body) + + if window_match: + sm = int(window_match.group(1)) + sd = int(window_match.group(2)) + sh = int(window_match.group(3)) + smin = int(window_match.group(4)) + eh = int(window_match.group(5)) + emin = int(window_match.group(6)) + start, end = _build_window_dt(published, sm, sd, sh, smin, eh, emin) + hours = int(duration_match.group(1)) if duration_match else _hours_between(start, end) + return MaintenanceInfo(window=f'{start:%Y-%m-%d} {start:%H:%M}~{end:%H:%M}', duration_hours=hours) + + if duration_match: + title_match = TITLE_TIME_RE.search(title) + if title_match: + sm = int(title_match.group(1)) + sd = int(title_match.group(2)) + sh = int(title_match.group(3)) + smin = int(title_match.group(4)) + hours = int(duration_match.group(1)) + start, _ = _build_window_dt(published, sm, sd, sh, smin, sh, smin) + end = start + timedelta(hours=hours) + logger.warning('WINDOW_RE not matched; synthesized window from TITLE_TIME_RE and DURATION_RE (%02d-%02d %02d:%02d, duration %d h).', sm, sd, sh, smin, hours) + return MaintenanceInfo(window=f'{start:%Y-%m-%d} {start:%H:%M}~{end:%H:%M}', duration_hours=hours) + + logger.warning('Failed to parse maintenance window from article text.') + return None + + +def latest_record(records: list[dict]) -> dict: + return max(records, key=lambda r: r['pub_ts']) + + +def parse_records(records: list[dict]) -> Optional[MaintenanceInfo]: + if not records: + return None + record = latest_record(records) + return parse_maintenance(record['text'], record['pub_ts'], record['title']) + + +def fetch_articles(cache_path: Path, state_path: Path, mid: int, collection_name: str, timeout: float, force: bool, verbose: bool) -> tuple[list[dict], bool]: + state = load_state(state_path) + session = session_for(mid) + meta, lists_payload = find_collection(session, mid, collection_name, timeout) + + if verbose: + logger.info('%s', json.dumps(lists_payload, ensure_ascii=False, indent=2)) + + if not force and state.get('articles_count') == meta.articles_count and state.get('update_time') == meta.update_time: + logger.info('skip: count=%s update_time=%s', meta.articles_count, meta.update_time) + return load_jsonl(cache_path), False + + payload = get_json(session, ARTICLE_COLLECTION_API, {'id': str(meta.collection_id)}, timeout) + records = [normalize_article(article, meta) for article in payload['data']['articles']] + persist_fetch(cache_path, state_path, records, {'last_run_at': datetime.now(tz=CN_TZ).isoformat(timespec='seconds'), 'collection_id': meta.collection_id, 'articles_count': meta.articles_count, 'update_time': meta.update_time}) + logger.info('updated: count=%s update_time=%s records=%s', meta.articles_count, meta.update_time, len(records)) + return records, True + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog='bilibili_maintenance.py', description='碧蓝航线停服维护公告爬取工具') + parser.add_argument('command', choices=['fetch', 'parse', 'run'], help='fetch: 拉取最新停服维护公告 | parse: 基于缓存解析停服时间 | run: 拉取并解析最新公告') + parser.add_argument('--cache', type=Path, default=DEFAULT_CACHE, help=f'公告缓存文件路径(默认 {DEFAULT_CACHE})') + parser.add_argument('--state', type=Path, default=DEFAULT_STATE, help=f'合集状态文件路径(默认 {DEFAULT_STATE})') + parser.add_argument('--mid', type=int, default=DEFAULT_MID, help=f'B站UP主UID(默认 {DEFAULT_MID})') + parser.add_argument('--collection-name', default=DEFAULT_COLLECTION_NAME, help=f'专栏合集名称,须与B站合集页标题一致(默认 {DEFAULT_COLLECTION_NAME!r})') + parser.add_argument('--timeout', type=float, default=30.0, help='网络请求超时秒数(默认 30)') + parser.add_argument('--force', action='store_true', help='强制重新拉取并刷新缓存数据') + parser.add_argument('--verbose', action='store_true', help='启用调试日志,输出 API 原始响应') + return parser + + +def cmd_fetch(args: argparse.Namespace) -> int: + try: + fetch_articles(args.cache, args.state, args.mid, args.collection_name, args.timeout, args.force, args.verbose) + except (FetchError, requests.RequestException) as exc: + logger.error('fetch failed: %s', exc) + return 1 + return 0 + + +def cmd_parse(args: argparse.Namespace) -> int: + info = parse_records(load_jsonl(args.cache)) + if info: + print(info.window) + return 0 + print() + return 2 + + +def cmd_run(args: argparse.Namespace) -> int: + try: + records, _ = fetch_articles(args.cache, args.state, args.mid, args.collection_name, args.timeout, force=True, verbose=args.verbose) + except (FetchError, requests.RequestException) as exc: + logger.error('fetch failed: %s', exc) + return 1 + info = parse_records(records) + if info: + print(info.window) + return 0 + print() + return 2 + + +def main() -> int: + configure_stdio() + logging.basicConfig(level=logging.WARNING, format='%(levelname)s: %(message)s') + args = build_parser().parse_args() + if args.verbose: + logging.getLogger().setLevel(logging.INFO) + if args.command == 'fetch': + return cmd_fetch(args) + if args.command == 'parse': + return cmd_parse(args) + if args.command == 'run': + return cmd_run(args) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) From 2164efdb6b7237af165082fc315249c8d3f728c6 Mon Sep 17 00:00:00 2001 From: positnuec <93694981+positnuec@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:57:45 +0800 Subject: [PATCH 03/20] Fix: inconsistent return type in get_commit for empty log --- module/webui/updater.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/module/webui/updater.py b/module/webui/updater.py index 91a330f68..ddc88832b 100644 --- a/module/webui/updater.py +++ b/module/webui/updater.py @@ -56,7 +56,9 @@ class Updater(DeployConfig, GitManager, PipManager): ) if not log: - return None, None, None, None + if n == 1: + return None, None, None, None + return [] logs = log.split("\n") logs = list(map(lambda log: tuple(log.split("---")), logs)) From 5edf80a19f5606b3f95449c92caa5fdfb5f407b2 Mon Sep 17 00:00:00 2001 From: positnuec <93694981+positnuec@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:47:24 +0800 Subject: [PATCH 04/20] Refactor: separate submarine_mode for clearer semantics --- config/template.json | 36 ++- module/combat/auto_search_combat.py | 6 +- module/combat/combat.py | 16 +- module/combat/submarine.py | 18 +- module/config/argument/args.json | 336 ++++++++++++++++---------- module/config/argument/argument.yaml | 9 +- module/config/config_generated.py | 3 +- module/config/config_updater.py | 12 + module/config/i18n/en-US.json | 24 +- module/config/i18n/ja-JP.json | 56 +++-- module/config/i18n/zh-CN.json | 26 +- module/config/i18n/zh-TW.json | 24 +- module/config/redirect_utils/utils.py | 14 ++ module/handler/fast_forward.py | 2 +- module/handler/strategy.py | 2 +- module/map/fleet.py | 13 +- module/os/map.py | 2 +- module/os_combat/combat.py | 2 +- module/raid/raid.py | 2 +- 19 files changed, 364 insertions(+), 239 deletions(-) diff --git a/config/template.json b/config/template.json index c0c6f42b2..3480fcb18 100644 --- a/config/template.json +++ b/config/template.json @@ -191,8 +191,9 @@ "Submarine": { "Fleet": 0, "AutoRecommend": false, - "Mode": "do_not_use", "AutoSearchMode": "sub_standby", + "HuntMode": "do_not_use", + "CallMode": "do_not_use", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -269,8 +270,9 @@ "Submarine": { "Fleet": 0, "AutoRecommend": false, - "Mode": "do_not_use", "AutoSearchMode": "sub_standby", + "HuntMode": "do_not_use", + "CallMode": "do_not_use", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -347,8 +349,9 @@ "Submarine": { "Fleet": 0, "AutoRecommend": false, - "Mode": "do_not_use", "AutoSearchMode": "sub_standby", + "HuntMode": "do_not_use", + "CallMode": "do_not_use", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -438,8 +441,9 @@ "Submarine": { "Fleet": 0, "AutoRecommend": false, - "Mode": "do_not_use", "AutoSearchMode": "sub_standby", + "HuntMode": "do_not_use", + "CallMode": "do_not_use", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -517,8 +521,9 @@ "Submarine": { "Fleet": 0, "AutoRecommend": false, - "Mode": "do_not_use", "AutoSearchMode": "sub_standby", + "HuntMode": "do_not_use", + "CallMode": "do_not_use", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -595,8 +600,9 @@ "Submarine": { "Fleet": 0, "AutoRecommend": false, - "Mode": "do_not_use", "AutoSearchMode": "sub_standby", + "HuntMode": "do_not_use", + "CallMode": "do_not_use", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -833,8 +839,9 @@ "Submarine": { "Fleet": 0, "AutoRecommend": false, - "Mode": "do_not_use", "AutoSearchMode": "sub_standby", + "HuntMode": "do_not_use", + "CallMode": "do_not_use", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -915,8 +922,9 @@ "Submarine": { "Fleet": 0, "AutoRecommend": false, - "Mode": "do_not_use", "AutoSearchMode": "sub_standby", + "HuntMode": "do_not_use", + "CallMode": "do_not_use", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -997,8 +1005,9 @@ "Submarine": { "Fleet": 0, "AutoRecommend": false, - "Mode": "do_not_use", "AutoSearchMode": "sub_standby", + "HuntMode": "do_not_use", + "CallMode": "do_not_use", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -1079,8 +1088,9 @@ "Submarine": { "Fleet": 0, "AutoRecommend": false, - "Mode": "do_not_use", "AutoSearchMode": "sub_standby", + "HuntMode": "do_not_use", + "CallMode": "do_not_use", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -1161,8 +1171,9 @@ "Submarine": { "Fleet": 0, "AutoRecommend": false, - "Mode": "do_not_use", "AutoSearchMode": "sub_standby", + "HuntMode": "do_not_use", + "CallMode": "do_not_use", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -1239,8 +1250,9 @@ "Submarine": { "Fleet": 0, "AutoRecommend": false, - "Mode": "do_not_use", "AutoSearchMode": "sub_standby", + "HuntMode": "do_not_use", + "CallMode": "do_not_use", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { diff --git a/module/combat/auto_search_combat.py b/module/combat/auto_search_combat.py index 6220b3142..9063b53f4 100644 --- a/module/combat/auto_search_combat.py +++ b/module/combat/auto_search_combat.py @@ -349,8 +349,8 @@ class AutoSearchCombat(MapOperation, Combat, CampaignStatus): self.submarine_call_reset() submarine_mode = 'do_not_use' if self.config.Submarine_Fleet: - submarine_mode = self.config.Submarine_Mode - force_call = battle[0] == battle[1] - 1 if battle is not None else False + submarine_mode = self.config.Submarine_CallMode + is_boss = battle[0] == battle[1] - 1 if battle is not None else False self.combat_auto_reset() self.combat_manual_reset() self.device.stuck_record_clear() @@ -364,7 +364,7 @@ class AutoSearchCombat(MapOperation, Combat, CampaignStatus): exp_confirm_timer = Timer(1, count=2) for _ in self.loop(): - if self.handle_submarine_call(submarine_mode, call=force_call): + if self.handle_submarine_call(call_mode=submarine_mode, is_boss=is_boss): continue if self.handle_combat_auto(auto): continue diff --git a/module/combat/combat.py b/module/combat/combat.py index 40f76a324..675308250 100644 --- a/module/combat/combat.py +++ b/module/combat/combat.py @@ -348,11 +348,12 @@ class Combat(Level, HPBalancer, Retirement, SubmarineCall, CombatAuto, CombatMan return False - def combat_execute(self, auto='combat_auto', submarine='do_not_use', drop=None): + def combat_execute(self, auto='combat_auto', submarine='do_not_use', is_boss=False, drop=None): """ Args: auto (str): ['combat_auto', 'combat_manual', 'stand_still_in_the_middle', 'hide_in_bottom_left'] - submarine (str): ['do_not_use', 'hunt_only', 'every_combat'] + submarine (str): do_not_use, boss_only, every_combat + is_boss (bool): True on BOSS battle drop (DropImage): """ logger.info('Combat execute') @@ -379,7 +380,7 @@ class Combat(Level, HPBalancer, Retirement, SubmarineCall, CombatAuto, CombatMan if auto != 'combat_auto' and self.auto_mode_checked and self.is_combat_executing(): if self.handle_combat_weapon_release(): continue - if self.handle_submarine_call(submarine): + if self.handle_submarine_call(call_mode=submarine, is_boss=is_boss): continue # bunch of popup handlers if self.handle_popup_confirm('COMBAT_EXECUTE'): @@ -629,7 +630,7 @@ class Combat(Level, HPBalancer, Retirement, SubmarineCall, CombatAuto, CombatMan if self.handle_in_map_with_enemy_searching(drop=drop): break - def combat(self, balance_hp=None, emotion_reduce=None, auto_mode=None, submarine_mode=None, + def combat(self, balance_hp=None, emotion_reduce=None, auto_mode=None, submarine_mode=None, is_boss=False, save_get_items=None, expected_end=None, fleet_index=1): """ Execute a combat. @@ -639,7 +640,8 @@ class Combat(Level, HPBalancer, Retirement, SubmarineCall, CombatAuto, CombatMan balance_hp (bool): emotion_reduce (bool): auto_mode (str): combat_auto, combat_manual, stand_still_in_the_middle, hide_in_bottom_left - submarine_mode (str): do_not_use, hunt_only, every_combat + submarine_mode (str): do_not_use, boss_only, every_combat + is_boss (bool): True on BOSS battle save_get_items (bool, DropImage): expected_end (str, callable): fleet_index (int): 1 or 2 @@ -651,7 +653,7 @@ class Combat(Level, HPBalancer, Retirement, SubmarineCall, CombatAuto, CombatMan if submarine_mode is None: submarine_mode = 'do_not_use' if self.config.Submarine_Fleet: - submarine_mode = self.config.Submarine_Mode + submarine_mode = self.config.Submarine_CallMode self.battle_status_click_interval = 7 if save_get_items else 0 # if not hasattr(self, 'emotion'): @@ -667,7 +669,7 @@ class Combat(Level, HPBalancer, Retirement, SubmarineCall, CombatAuto, CombatMan self.combat_preparation( balance_hp=balance_hp, emotion_reduce=emotion_reduce, auto=auto_mode, fleet_index=fleet_index) self.combat_execute( - auto=auto_mode, submarine=submarine_mode, drop=drop) + auto=auto_mode, submarine=submarine_mode, drop=drop, is_boss=is_boss) self.combat_status( drop=drop, expected_end=expected_end) # self.handle_map_after_combat_story() diff --git a/module/combat/submarine.py b/module/combat/submarine.py index 7d1e2797f..5be0a155b 100644 --- a/module/combat/submarine.py +++ b/module/combat/submarine.py @@ -16,19 +16,21 @@ class SubmarineCall(ModuleBase): self.submarine_call_timer.reset() self.submarine_call_flag = False - def handle_submarine_call(self, submarine='do_not_use', call=False): + def handle_submarine_call(self, call_mode='do_not_use', is_boss=False): """ + Args: + call_mode (str): do_not_use, boss_only, every_combat + is_boss (bool): True on BOSS battle + Returns: - str: If call. + bool: If clicked submarine call button. """ if self.submarine_call_flag: return False - if call and submarine == 'boss_only': - pass - else: - if submarine in ['do_not_use', 'hunt_only', 'boss_only', 'hunt_and_boss']: - self.submarine_call_flag = True - return False + if call_mode == 'do_not_use' or (call_mode == 'boss_only' and not is_boss): + self.submarine_call_flag = True + return False + if self.submarine_call_timer.reached(): logger.info('Submarine call timer reached') self.submarine_call_flag = True diff --git a/module/config/argument/args.json b/module/config/argument/args.json index bc097af84..89e3cd31f 100644 --- a/module/config/argument/args.json +++ b/module/config/argument/args.json @@ -896,17 +896,6 @@ "type": "checkbox", "value": false }, - "Mode": { - "type": "select", - "value": "do_not_use", - "option": [ - "do_not_use", - "hunt_only", - "boss_only", - "hunt_and_boss", - "every_combat" - ] - }, "AutoSearchMode": { "type": "select", "value": "sub_standby", @@ -915,6 +904,23 @@ "sub_auto_call" ] }, + "HuntMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "hunt" + ] + }, + "CallMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "boss_only", + "every_combat" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -1292,17 +1298,6 @@ "type": "checkbox", "value": false }, - "Mode": { - "type": "select", - "value": "do_not_use", - "option": [ - "do_not_use", - "hunt_only", - "boss_only", - "hunt_and_boss", - "every_combat" - ] - }, "AutoSearchMode": { "type": "select", "value": "sub_standby", @@ -1311,6 +1306,23 @@ "sub_auto_call" ] }, + "HuntMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "hunt" + ] + }, + "CallMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "boss_only", + "every_combat" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -1688,17 +1700,6 @@ "type": "checkbox", "value": false }, - "Mode": { - "type": "select", - "value": "do_not_use", - "option": [ - "do_not_use", - "hunt_only", - "boss_only", - "hunt_and_boss", - "every_combat" - ] - }, "AutoSearchMode": { "type": "select", "value": "sub_standby", @@ -1707,6 +1708,23 @@ "sub_auto_call" ] }, + "HuntMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "hunt" + ] + }, + "CallMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "boss_only", + "every_combat" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -2182,17 +2200,6 @@ "type": "checkbox", "value": false }, - "Mode": { - "type": "select", - "value": "do_not_use", - "option": [ - "do_not_use", - "hunt_only", - "boss_only", - "hunt_and_boss", - "every_combat" - ] - }, "AutoSearchMode": { "type": "select", "value": "sub_standby", @@ -2201,6 +2208,23 @@ "sub_auto_call" ] }, + "HuntMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "hunt" + ] + }, + "CallMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "boss_only", + "every_combat" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -2592,17 +2616,6 @@ "type": "checkbox", "value": false }, - "Mode": { - "type": "select", - "value": "do_not_use", - "option": [ - "do_not_use", - "hunt_only", - "boss_only", - "hunt_and_boss", - "every_combat" - ] - }, "AutoSearchMode": { "type": "select", "value": "sub_standby", @@ -2611,6 +2624,23 @@ "sub_auto_call" ] }, + "HuntMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "hunt" + ] + }, + "CallMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "boss_only", + "every_combat" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -3004,17 +3034,6 @@ "type": "checkbox", "value": false }, - "Mode": { - "type": "select", - "value": "do_not_use", - "option": [ - "do_not_use", - "hunt_only", - "boss_only", - "hunt_and_boss", - "every_combat" - ] - }, "AutoSearchMode": { "type": "select", "value": "sub_standby", @@ -3023,6 +3042,23 @@ "sub_auto_call" ] }, + "HuntMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "hunt" + ] + }, + "CallMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "boss_only", + "every_combat" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -4409,17 +4445,6 @@ "type": "checkbox", "value": false }, - "Mode": { - "type": "select", - "value": "do_not_use", - "option": [ - "do_not_use", - "hunt_only", - "boss_only", - "hunt_and_boss", - "every_combat" - ] - }, "AutoSearchMode": { "type": "select", "value": "sub_standby", @@ -4428,6 +4453,23 @@ "sub_auto_call" ] }, + "HuntMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "hunt" + ] + }, + "CallMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "boss_only", + "every_combat" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -4839,17 +4881,6 @@ "type": "checkbox", "value": false }, - "Mode": { - "type": "select", - "value": "do_not_use", - "option": [ - "do_not_use", - "hunt_only", - "boss_only", - "hunt_and_boss", - "every_combat" - ] - }, "AutoSearchMode": { "type": "select", "value": "sub_standby", @@ -4858,6 +4889,23 @@ "sub_auto_call" ] }, + "HuntMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "hunt" + ] + }, + "CallMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "boss_only", + "every_combat" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -5269,17 +5317,6 @@ "type": "checkbox", "value": false }, - "Mode": { - "type": "select", - "value": "do_not_use", - "option": [ - "do_not_use", - "hunt_only", - "boss_only", - "hunt_and_boss", - "every_combat" - ] - }, "AutoSearchMode": { "type": "select", "value": "sub_standby", @@ -5288,6 +5325,23 @@ "sub_auto_call" ] }, + "HuntMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "hunt" + ] + }, + "CallMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "boss_only", + "every_combat" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -5699,17 +5753,6 @@ "type": "checkbox", "value": false }, - "Mode": { - "type": "select", - "value": "do_not_use", - "option": [ - "do_not_use", - "hunt_only", - "boss_only", - "hunt_and_boss", - "every_combat" - ] - }, "AutoSearchMode": { "type": "select", "value": "sub_standby", @@ -5718,6 +5761,23 @@ "sub_auto_call" ] }, + "HuntMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "hunt" + ] + }, + "CallMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "boss_only", + "every_combat" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -6129,17 +6189,6 @@ "type": "checkbox", "value": false }, - "Mode": { - "type": "select", - "value": "do_not_use", - "option": [ - "do_not_use", - "hunt_only", - "boss_only", - "hunt_and_boss", - "every_combat" - ] - }, "AutoSearchMode": { "type": "select", "value": "sub_standby", @@ -6148,6 +6197,23 @@ "sub_auto_call" ] }, + "HuntMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "hunt" + ] + }, + "CallMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "boss_only", + "every_combat" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -6549,17 +6615,6 @@ "type": "checkbox", "value": false }, - "Mode": { - "type": "select", - "value": "do_not_use", - "option": [ - "do_not_use", - "hunt_only", - "boss_only", - "hunt_and_boss", - "every_combat" - ] - }, "AutoSearchMode": { "type": "select", "value": "sub_standby", @@ -6569,6 +6624,23 @@ ], "display": "hide" }, + "HuntMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "hunt" + ] + }, + "CallMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "boss_only", + "every_combat" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", diff --git a/module/config/argument/argument.yaml b/module/config/argument/argument.yaml index 025086a46..12d80c092 100644 --- a/module/config/argument/argument.yaml +++ b/module/config/argument/argument.yaml @@ -213,12 +213,15 @@ Submarine: value: 0 option: [ 0, 1, 2 ] AutoRecommend: false - Mode: - value: do_not_use - option: [ do_not_use, hunt_only, boss_only, hunt_and_boss, every_combat ] AutoSearchMode: value: sub_standby option: [ sub_standby, sub_auto_call ] + HuntMode: + value: do_not_use + option: [ do_not_use, hunt ] + CallMode: + value: do_not_use + option: [ do_not_use, boss_only, every_combat ] DistanceToBoss: value: '2_grid_to_boss' option: [ to_boss_position, 1_grid_to_boss, 2_grid_to_boss, use_open_ocean_support ] diff --git a/module/config/config_generated.py b/module/config/config_generated.py index b534a1774..9709c584f 100644 --- a/module/config/config_generated.py +++ b/module/config/config_generated.py @@ -112,8 +112,9 @@ class GeneratedConfig: # Group `Submarine` Submarine_Fleet = 0 # 0, 1, 2 Submarine_AutoRecommend = False - Submarine_Mode = 'do_not_use' # do_not_use, hunt_only, boss_only, hunt_and_boss, every_combat Submarine_AutoSearchMode = 'sub_standby' # sub_standby, sub_auto_call + Submarine_HuntMode = 'do_not_use' # do_not_use, hunt + Submarine_CallMode = 'do_not_use' # do_not_use, boss_only, every_combat Submarine_DistanceToBoss = '2_grid_to_boss' # to_boss_position, 1_grid_to_boss, 2_grid_to_boss, use_open_ocean_support # Group `Emotion` diff --git a/module/config/config_updater.py b/module/config/config_updater.py index c4c89767f..82ff87e64 100644 --- a/module/config/config_updater.py +++ b/module/config/config_updater.py @@ -622,6 +622,18 @@ class ConfigUpdater: # ] # ] + redirection += [ + ( + f'{task}.Submarine.Mode', + (f'{task}.Submarine.HuntMode', f'{task}.Submarine.CallMode'), + submarine_mode_redirect + ) for task in [ + 'Main', 'Main2', 'Main3', 'GemsFarming', + 'Event', 'Event2', 'EventA', 'EventB', 'EventC', 'EventD', 'EventSp', 'Raid', 'RaidDaily', + 'Sos', 'WarArchives', + ] + ] + @cached_property def args(self): return read_file(filepath_args()) diff --git a/module/config/i18n/en-US.json b/module/config/i18n/en-US.json index 9e46a6595..2f7ff537a 100644 --- a/module/config/i18n/en-US.json +++ b/module/config/i18n/en-US.json @@ -1045,24 +1045,28 @@ "name": "Hard Mode Auto-Recommend", "help": "Auto-fills empty submarine fleet if using via recommend button in Hard Mode" }, - "Mode": { - "name": "Submarine Mode", - "help": "Effective only when auto search disabled. Reminder: 'Hunt and Boss' is actually a mix of 'Hunt Only' and 'Boss Only', it does hunt and summon submarines at boss if available.", - "do_not_use": "Don't Use", - "hunt_only": "Hunt Only", - "boss_only": "BOSS Only", - "hunt_and_boss": "Hunt and BOSS", - "every_combat": "Every Combat" - }, "AutoSearchMode": { "name": "Submarine Roles in Auto Search", "help": "Effective only for auto search", "sub_standby": "Standby", "sub_auto_call": "Auto Call" }, + "HuntMode": { + "name": "Hunt Mode", + "help": "Effective only when auto search is disabled, adjusts submarine hunting settings in-stage", + "do_not_use": "Standby", + "hunt": "Hunt" + }, + "CallMode": { + "name": "Call Submarine", + "help": "Attempt to call submarines by clicking the call button during combat", + "do_not_use": "Don't Use", + "boss_only": "BOSS Only", + "every_combat": "Every Combat" + }, "DistanceToBoss": { "name": "Before BOSS battle move the submarine near BOSS", - "help": "Effective only if \"Submarine Mode\" is \"BOSS Only\" or \"Hunt and BOSS\"\nSelecting \"X Grids To BOSS\" needs to ensure that the submarine hunting range can cover the BOSS. Distance is calculated using the Manhattan Distance. Selecting \"Use Open Ocean Support\" requires submarine with aforementioned skill", + "help": "Effective only if \"Call Submarine\" is \"BOSS Only\"\nSelecting \"X Grids To BOSS\" needs to ensure that the submarine hunting range can cover the BOSS. Distance is calculated using the Manhattan Distance. Selecting \"Use Open Ocean Support\" requires submarine with aforementioned skill", "to_boss_position": "To BOSS Location", "1_grid_to_boss": "1 Grid To BOSS", "2_grid_to_boss": "2 Grids To BOSS", diff --git a/module/config/i18n/ja-JP.json b/module/config/i18n/ja-JP.json index 88db48d9b..143758fbd 100644 --- a/module/config/i18n/ja-JP.json +++ b/module/config/i18n/ja-JP.json @@ -1031,42 +1031,46 @@ }, "Submarine": { "_info": { - "name": "Submarine._info.name", - "help": "Submarine._info.help" + "name": "潜水艦設定", + "help": "" }, "Fleet": { - "name": "Submarine.Fleet.name", - "help": "Submarine.Fleet.help", - "0": "0", + "name": "潜水艦艦隊番号", + "help": "", + "0": "使用しない", "1": "1", "2": "2" }, "AutoRecommend": { - "name": "Submarine.AutoRecommend.name", - "help": "Submarine.AutoRecommend.help" - }, - "Mode": { - "name": "Submarine.Mode.name", - "help": "Submarine.Mode.help", - "do_not_use": "do_not_use", - "hunt_only": "hunt_only", - "boss_only": "boss_only", - "hunt_and_boss": "hunt_and_boss", - "every_combat": "every_combat" + "name": "推薦編成", + "help": "難易度ハードモードで潜水艦艦隊を使用するが未設定の場合、推薦ボタンで自動編成します" }, "AutoSearchMode": { - "name": "Submarine.AutoSearchMode.name", - "help": "Submarine.AutoSearchMode.help", - "sub_standby": "sub_standby", - "sub_auto_call": "sub_auto_call" + "name": "潜水艦自律索敵設定", + "help": "自律索敵でのみ有効", + "sub_standby": "待機", + "sub_auto_call": "自動召喚" + }, + "HuntMode": { + "name": "狩猟モード設定", + "help": "自律索敵を使用しない場合のみ有効、ステージ内で潜水艦の狩猟設定を調整します", + "do_not_use": "待機", + "hunt": "狩猟" + }, + "CallMode": { + "name": "潜水艦出撃設定", + "help": "戦闘中にボタンをクリックして潜水艦の召喚を試みます", + "do_not_use": "使用しない", + "boss_only": "BOSS戦のみ", + "every_combat": "毎戦" }, "DistanceToBoss": { - "name": "Submarine.DistanceToBoss.name", - "help": "Submarine.DistanceToBoss.help", - "to_boss_position": "to_boss_position", - "1_grid_to_boss": "1_grid_to_boss", - "2_grid_to_boss": "2_grid_to_boss", - "use_open_ocean_support": "use_open_ocean_support" + "name": "BOSS戦前に潜水艦をBOSS付近に移動", + "help": "\"潜水艦出撃設定\"が\"BOSS戦のみ\"の場合のみ有効\n\"BOSSまでXマス\"を選択する場合、潜水艦の狩猟範囲がBOSSまで届くことを確認してください。距離はマンハッタン距離で計算されます。\"遠洋支援を使用\"を選択する場合はU522/ダ・ヴィンチなどの遠洋支援スキルを持つ艦娘が必要です", + "to_boss_position": "BOSSの位置へ", + "1_grid_to_boss": "BOSSまで1マス", + "2_grid_to_boss": "BOSSまで2マス", + "use_open_ocean_support": "遠洋支援を使用" } }, "Emotion": { diff --git a/module/config/i18n/zh-CN.json b/module/config/i18n/zh-CN.json index 923c65eb3..4a6079562 100644 --- a/module/config/i18n/zh-CN.json +++ b/module/config/i18n/zh-CN.json @@ -1045,24 +1045,28 @@ "name": "推荐编队", "help": "困难模式下,如果使用潜艇编队但未配置,会点击推荐自动编队" }, - "Mode": { - "name": "潜艇出击方案", - "help": "仅在自律寻敌关闭的情况下生效,提醒: '狩猎及BOSS战'为'仅狩猎'与'仅BOSS战'的混合,它会在道中进行狩猎打击,并在BOSS战尝试召唤潜艇。", - "do_not_use": "不使用", - "hunt_only": "仅狩猎", - "boss_only": "仅BOSS战", - "hunt_and_boss": "狩猎及BOSS战", - "every_combat": "每战出击" - }, "AutoSearchMode": { "name": "潜艇自律方案", - "help": "仅在自律寻敌下生效", + "help": "周回模式潜艇职能设置", "sub_standby": "待机", "sub_auto_call": "自动召唤潜艇" }, + "HuntMode": { + "name": "设置狩猎模式", + "help": "仅在关卡不使用自律时生效,在关卡内调整潜艇狩猎设置", + "do_not_use": "待机", + "hunt": "狩猎" + }, + "CallMode": { + "name": "潜艇出击方案", + "help": "在战斗内通过点击按钮以尝试召唤潜艇", + "do_not_use": "不使用", + "boss_only": "仅BOSS战", + "every_combat": "每场战斗" + }, "DistanceToBoss": { "name": "BOSS战前将潜艇移动到BOSS附近", - "help": "仅在\"潜艇出击方案\"为\"仅BOSS战\"及\"狩猎及BOSS战\",时生效\n选择\"距离BOSS X格\"需要保证潜艇狩猎范围能覆盖到BOSS,距离使用曼哈顿距离计算,选择\"使用远洋支援\"需要潜艇队伍里有U522/达芬奇", + "help": "仅在\"潜艇出击方案\"为\"仅BOSS战\"时生效\n选择\"距离BOSS X格\"需要保证潜艇狩猎范围能覆盖到BOSS,距离使用曼哈顿距离计算;选择\"使用远洋支援\"需要潜艇队伍里有具备远洋支援技能的舰娘,如U522、达芬奇等", "to_boss_position": "至 BOSS 所在位置", "1_grid_to_boss": "距离 BOSS 1 格", "2_grid_to_boss": "距离 BOSS 2 格", diff --git a/module/config/i18n/zh-TW.json b/module/config/i18n/zh-TW.json index da6e7fdea..455340a24 100644 --- a/module/config/i18n/zh-TW.json +++ b/module/config/i18n/zh-TW.json @@ -1045,24 +1045,28 @@ "name": "推薦編隊", "help": "困難模式下,如果使用潛艦編隊但未配置,會點擊推薦自動編隊" }, - "Mode": { - "name": "潛艇出擊方案", - "help": "僅在自律尋敵關閉的情況下生效,提醒: '狩獵及BOSS戰'為'僅狩獵'與'僅BOSS戰'的混合,它會在道中進行狩獵打擊,並在BOSS戰嘗試召喚潛艇。", - "do_not_use": "不使用", - "hunt_only": "僅狩獵", - "boss_only": "僅BOSS", - "hunt_and_boss": "狩獵及BOSS戰", - "every_combat": "每戰出擊" - }, "AutoSearchMode": { "name": "潛艇自律尋敵方案", "help": "僅在自律尋敵下生效", "sub_standby": "待機", "sub_auto_call": "自動出擊" }, + "HuntMode": { + "name": "設定狩獵模式", + "help": "僅在關卡不使用自律時生效,在關卡內調整潛艇狩獵設定", + "do_not_use": "待機", + "hunt": "狩獵" + }, + "CallMode": { + "name": "潛艇出擊方案", + "help": "在戰鬥內透過點擊按鈕以嘗試召喚潛艇", + "do_not_use": "不使用", + "boss_only": "僅BOSS戰", + "every_combat": "每場戰鬥" + }, "DistanceToBoss": { "name": "BOSS戰前將潛艇移動到BOSS附近", - "help": "僅在\"潛艇出擊方案\"為\"僅BOSS戰\"及\"狩獵及BOSS戰\"時生效\n選擇\"距離BOSS X格\"需要保證潛艇狩獵範圍能覆蓋到BOSS,距離使用曼哈頓距離計算,選擇\"使用遠洋支援\"需要潛艇隊伍裡有U522或是達芬奇", + "help": "僅在\"潛艇出擊方案\"為\"僅BOSS戰\"時生效\n選擇\"距離BOSS X格\"需要保證潛艇狩獵範圍能覆蓋到BOSS,距離使用曼哈頓距離計算,選擇\"使用遠洋支援\"需要潛艇隊伍裡有U522或是達芬奇", "to_boss_position": "至 BOSS 所在位置", "1_grid_to_boss": "距離 BOSS 1 格", "2_grid_to_boss": "距離 BOSS 2 格", diff --git a/module/config/redirect_utils/utils.py b/module/config/redirect_utils/utils.py index 33a4f6f6e..a7eb53074 100644 --- a/module/config/redirect_utils/utils.py +++ b/module/config/redirect_utils/utils.py @@ -130,3 +130,17 @@ def coalition_to_little_academy(value): return 'hard' else: return value + + +def submarine_mode_redirect(value): + """ + Submarine.Mode -> (Submarine.HuntMode, Submarine.CallMode) + """ + mapping = { + 'do_not_use': ('do_not_use', 'do_not_use'), + 'hunt_only': ('hunt', 'do_not_use'), + 'boss_only': ('do_not_use', 'boss_only'), + 'hunt_and_boss': ('hunt', 'boss_only'), + 'every_combat': ('do_not_use', 'every_combat'), + } + return mapping.get(value, ('do_not_use', 'do_not_use')) diff --git a/module/handler/fast_forward.py b/module/handler/fast_forward.py index 3598f7fbb..3c5777eae 100644 --- a/module/handler/fast_forward.py +++ b/module/handler/fast_forward.py @@ -306,7 +306,7 @@ class FastForwardHandler(AutoSearchHandler): @property def is_call_submarine_at_boss(self): - return self.config.SUBMARINE and self.config.Submarine_Mode in ['boss_only', 'hunt_and_boss'] + return self.config.SUBMARINE and self.config.Submarine_CallMode == 'boss_only' def handle_auto_submarine_call_disable(self): """ diff --git a/module/handler/strategy.py b/module/handler/strategy.py index e4f1c37ee..7b406f0bd 100644 --- a/module/handler/strategy.py +++ b/module/handler/strategy.py @@ -113,7 +113,7 @@ class StrategyHandler(InfoHandler): self.strategy_set_execute( formation=expected_formation, sub_view=False, - sub_hunt=bool(self.config.Submarine_Fleet) and self.config.Submarine_Mode in ['hunt_only', 'hunt_and_boss'] + sub_hunt=bool(self.config.Submarine_Fleet) and self.config.Submarine_HuntMode == 'hunt' ) self.strategy_close() self.__setattr__(f'fleet_{index}_formation_fixed', True) diff --git a/module/map/fleet.py b/module/map/fleet.py index 249f61e20..d83d8ce81 100644 --- a/module/map/fleet.py +++ b/module/map/fleet.py @@ -287,7 +287,7 @@ class Fleet(Camera, AmbushHandler): arrived = False # Wait to confirm fleet arrived. It does't appear immediately if fleet in combat. extra = 0 - if self.config.Submarine_Mode in ['hunt_only', 'hunt_and_boss']: + if self.config.Submarine_HuntMode == 'hunt': extra += 4.5 if self.config.MAP_HAS_LAND_BASED and grid.is_mechanism_trigger: extra += grid.mechanism_wait @@ -317,7 +317,7 @@ class Fleet(Camera, AmbushHandler): self.combat( expected_end=self._expected_end(expected), fleet_index=self.fleet_show_index, - submarine_mode=self._submarine_mode(expected) + is_boss='boss' in expected, ) self.hp_get() self.lv_get(after_battle=True) @@ -941,15 +941,6 @@ class Fleet(Camera, AmbushHandler): # Out of the spawn_data, nothing will spawn return 'no_searching' - def _submarine_mode(self, expected): - if self.is_call_submarine_at_boss: - if 'boss' in expected: - return 'every_combat' - else: - return 'do_not_use' - else: - return None - def fleet_at(self, grid, fleet=None): """ Args: diff --git a/module/os/map.py b/module/os/map.py index 013beaf91..76665ba73 100644 --- a/module/os/map.py +++ b/module/os/map.py @@ -54,7 +54,7 @@ class OSMap(OSFleet, Map, GlobeCamera, StorageHandler, StrategicSearchHandler): kwargs[key] = 0 self.config.override( Submarine_Fleet=1, - Submarine_Mode='every_combat', + Submarine_CallMode='every_combat', STORY_ALLOW_SKIP=False, **kwargs ) diff --git a/module/os_combat/combat.py b/module/os_combat/combat.py index 349518e15..4dad57634 100644 --- a/module/os_combat/combat.py +++ b/module/os_combat/combat.py @@ -255,7 +255,7 @@ class Combat(Combat_, MapEventHandler): self.device.click_record_clear() submarine_mode = 'do_not_use' if self.config.Submarine_Fleet: - submarine_mode = self.config.Submarine_Mode + submarine_mode = self.config.Submarine_CallMode success = True while 1: diff --git a/module/raid/raid.py b/module/raid/raid.py index 401c3ccac..9fa840355 100644 --- a/module/raid/raid.py +++ b/module/raid/raid.py @@ -380,7 +380,7 @@ class Raid(MapOperation, RaidCombat, CampaignEvent): if mode == 'ex': backup = self.config.temporary( Submarine_Fleet=1, - Submarine_Mode='every_combat' + Submarine_CallMode='every_combat' ) self.emotion.check_reduce(1) From fcfd4972ec247f145ee8bc66d2fbaf7928f72294 Mon Sep 17 00:00:00 2001 From: positnuec <93694981+positnuec@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:44:34 +0800 Subject: [PATCH 05/20] Add: submarine call timing --- config/template.json | 12 ++++ module/combat/submarine.py | 28 ++++++++ module/config/argument/args.json | 96 ++++++++++++++++++++++++++++ module/config/argument/argument.yaml | 3 + module/config/config_generated.py | 1 + module/config/i18n/en-US.json | 6 ++ module/config/i18n/ja-JP.json | 6 ++ module/config/i18n/zh-CN.json | 6 ++ module/config/i18n/zh-TW.json | 6 ++ 9 files changed, 164 insertions(+) diff --git a/config/template.json b/config/template.json index 3480fcb18..dece83037 100644 --- a/config/template.json +++ b/config/template.json @@ -194,6 +194,7 @@ "AutoSearchMode": "sub_standby", "HuntMode": "do_not_use", "CallMode": "do_not_use", + "Timing": "default", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -273,6 +274,7 @@ "AutoSearchMode": "sub_standby", "HuntMode": "do_not_use", "CallMode": "do_not_use", + "Timing": "default", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -352,6 +354,7 @@ "AutoSearchMode": "sub_standby", "HuntMode": "do_not_use", "CallMode": "do_not_use", + "Timing": "default", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -444,6 +447,7 @@ "AutoSearchMode": "sub_standby", "HuntMode": "do_not_use", "CallMode": "do_not_use", + "Timing": "default", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -524,6 +528,7 @@ "AutoSearchMode": "sub_standby", "HuntMode": "do_not_use", "CallMode": "do_not_use", + "Timing": "default", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -603,6 +608,7 @@ "AutoSearchMode": "sub_standby", "HuntMode": "do_not_use", "CallMode": "do_not_use", + "Timing": "default", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -842,6 +848,7 @@ "AutoSearchMode": "sub_standby", "HuntMode": "do_not_use", "CallMode": "do_not_use", + "Timing": "default", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -925,6 +932,7 @@ "AutoSearchMode": "sub_standby", "HuntMode": "do_not_use", "CallMode": "do_not_use", + "Timing": "default", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -1008,6 +1016,7 @@ "AutoSearchMode": "sub_standby", "HuntMode": "do_not_use", "CallMode": "do_not_use", + "Timing": "default", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -1091,6 +1100,7 @@ "AutoSearchMode": "sub_standby", "HuntMode": "do_not_use", "CallMode": "do_not_use", + "Timing": "default", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -1174,6 +1184,7 @@ "AutoSearchMode": "sub_standby", "HuntMode": "do_not_use", "CallMode": "do_not_use", + "Timing": "default", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -1253,6 +1264,7 @@ "AutoSearchMode": "sub_standby", "HuntMode": "do_not_use", "CallMode": "do_not_use", + "Timing": "default", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { diff --git a/module/combat/submarine.py b/module/combat/submarine.py index 5be0a155b..4977cb641 100644 --- a/module/combat/submarine.py +++ b/module/combat/submarine.py @@ -1,8 +1,18 @@ from module.base.base import ModuleBase from module.base.timer import Timer +from module.base.utils import color_similar, crop, get_color, image_color_count from module.combat.assets import * from module.logger import logger +# BOSS detection areas +# Classic UI: BOSS avatar frame right border +BOSS_AVATAR_FRAME_RIGHT = (301, 13, 303, 89) +BOSS_AVATAR_FRAME_COLOR = (24, 28, 24) +# New UI: BOSS HP bar bottom border, middle-left portion +# Color targets deep blue themes; need adjustment for other color schemes +BOSS_HP_BAR_BORDER = (420, 48, 620, 50) +BOSS_HP_BAR_COLOR = (31, 39, 61) + class SubmarineCall(ModuleBase): submarine_call_flag = False @@ -16,6 +26,20 @@ class SubmarineCall(ModuleBase): self.submarine_call_timer.reset() self.submarine_call_flag = False + def boss_appeared(self): + """ + Detect whether BOSS has appeared by checking stable BOSS UI elements. + """ + # New UI: BOSS HP bar bottom border + avg = get_color(self.device.image, BOSS_HP_BAR_BORDER) + if color_similar(avg, BOSS_HP_BAR_COLOR, threshold=40): + return True + # Classic UI: BOSS avatar frame right border + frame = crop(self.device.image, BOSS_AVATAR_FRAME_RIGHT, copy=False) + if image_color_count(frame, color=BOSS_AVATAR_FRAME_COLOR, count=55): + return True + return False + def handle_submarine_call(self, call_mode='do_not_use', is_boss=False): """ Args: @@ -31,6 +55,10 @@ class SubmarineCall(ModuleBase): self.submarine_call_flag = True return False + if self.config.Submarine_Timing == 'wait_for_boss' and is_boss: + if not self.boss_appeared(): + self.submarine_call_timer.reset() + return False if self.submarine_call_timer.reached(): logger.info('Submarine call timer reached') self.submarine_call_flag = True diff --git a/module/config/argument/args.json b/module/config/argument/args.json index 89e3cd31f..c2ed8173c 100644 --- a/module/config/argument/args.json +++ b/module/config/argument/args.json @@ -921,6 +921,14 @@ "every_combat" ] }, + "Timing": { + "type": "select", + "value": "default", + "option": [ + "default", + "wait_for_boss" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -1323,6 +1331,14 @@ "every_combat" ] }, + "Timing": { + "type": "select", + "value": "default", + "option": [ + "default", + "wait_for_boss" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -1725,6 +1741,14 @@ "every_combat" ] }, + "Timing": { + "type": "select", + "value": "default", + "option": [ + "default", + "wait_for_boss" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -2225,6 +2249,14 @@ "every_combat" ] }, + "Timing": { + "type": "select", + "value": "default", + "option": [ + "default", + "wait_for_boss" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -2641,6 +2673,14 @@ "every_combat" ] }, + "Timing": { + "type": "select", + "value": "default", + "option": [ + "default", + "wait_for_boss" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -3059,6 +3099,14 @@ "every_combat" ] }, + "Timing": { + "type": "select", + "value": "default", + "option": [ + "default", + "wait_for_boss" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -4470,6 +4518,14 @@ "every_combat" ] }, + "Timing": { + "type": "select", + "value": "default", + "option": [ + "default", + "wait_for_boss" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -4906,6 +4962,14 @@ "every_combat" ] }, + "Timing": { + "type": "select", + "value": "default", + "option": [ + "default", + "wait_for_boss" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -5342,6 +5406,14 @@ "every_combat" ] }, + "Timing": { + "type": "select", + "value": "default", + "option": [ + "default", + "wait_for_boss" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -5778,6 +5850,14 @@ "every_combat" ] }, + "Timing": { + "type": "select", + "value": "default", + "option": [ + "default", + "wait_for_boss" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -6214,6 +6294,14 @@ "every_combat" ] }, + "Timing": { + "type": "select", + "value": "default", + "option": [ + "default", + "wait_for_boss" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -6641,6 +6729,14 @@ "every_combat" ] }, + "Timing": { + "type": "select", + "value": "default", + "option": [ + "default", + "wait_for_boss" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", diff --git a/module/config/argument/argument.yaml b/module/config/argument/argument.yaml index 12d80c092..e25c1b3a2 100644 --- a/module/config/argument/argument.yaml +++ b/module/config/argument/argument.yaml @@ -222,6 +222,9 @@ Submarine: CallMode: value: do_not_use option: [ do_not_use, boss_only, every_combat ] + Timing: + value: default + option: [ default, wait_for_boss ] DistanceToBoss: value: '2_grid_to_boss' option: [ to_boss_position, 1_grid_to_boss, 2_grid_to_boss, use_open_ocean_support ] diff --git a/module/config/config_generated.py b/module/config/config_generated.py index 9709c584f..9fcc7358c 100644 --- a/module/config/config_generated.py +++ b/module/config/config_generated.py @@ -115,6 +115,7 @@ class GeneratedConfig: Submarine_AutoSearchMode = 'sub_standby' # sub_standby, sub_auto_call Submarine_HuntMode = 'do_not_use' # do_not_use, hunt Submarine_CallMode = 'do_not_use' # do_not_use, boss_only, every_combat + Submarine_Timing = 'default' # default, wait_for_boss Submarine_DistanceToBoss = '2_grid_to_boss' # to_boss_position, 1_grid_to_boss, 2_grid_to_boss, use_open_ocean_support # Group `Emotion` diff --git a/module/config/i18n/en-US.json b/module/config/i18n/en-US.json index 2f7ff537a..7a08dfc31 100644 --- a/module/config/i18n/en-US.json +++ b/module/config/i18n/en-US.json @@ -1064,6 +1064,12 @@ "boss_only": "BOSS Only", "every_combat": "Every Combat" }, + "Timing": { + "name": "BOSS Battle Call Timing (Experimental)", + "help": "Note: 'Wait For BOSS' currently only supports: Classic UI, and new UI with solid deep blue HP bar borders! Using unsupported UIs will prevent submarines from being called!", + "default": "Default (call immediately after entering combat)", + "wait_for_boss": "Wait For BOSS" + }, "DistanceToBoss": { "name": "Before BOSS battle move the submarine near BOSS", "help": "Effective only if \"Call Submarine\" is \"BOSS Only\"\nSelecting \"X Grids To BOSS\" needs to ensure that the submarine hunting range can cover the BOSS. Distance is calculated using the Manhattan Distance. Selecting \"Use Open Ocean Support\" requires submarine with aforementioned skill", diff --git a/module/config/i18n/ja-JP.json b/module/config/i18n/ja-JP.json index 143758fbd..a34efad53 100644 --- a/module/config/i18n/ja-JP.json +++ b/module/config/i18n/ja-JP.json @@ -1064,6 +1064,12 @@ "boss_only": "BOSS戦のみ", "every_combat": "毎戦" }, + "Timing": { + "name": "BOSS戦召喚タイミング(テスト中)", + "help": "注意:'BOSS出現待機'現在のみ:クラシックUI、HPバー枠が深青一色の新型UIのみサポート!認識できないUIを使用すると潜水艦が出撃しません!", + "default": "デフォルト(戦闘開始直後に召喚を試みる)", + "wait_for_boss": "BOSS出現待機" + }, "DistanceToBoss": { "name": "BOSS戦前に潜水艦をBOSS付近に移動", "help": "\"潜水艦出撃設定\"が\"BOSS戦のみ\"の場合のみ有効\n\"BOSSまでXマス\"を選択する場合、潜水艦の狩猟範囲がBOSSまで届くことを確認してください。距離はマンハッタン距離で計算されます。\"遠洋支援を使用\"を選択する場合はU522/ダ・ヴィンチなどの遠洋支援スキルを持つ艦娘が必要です", diff --git a/module/config/i18n/zh-CN.json b/module/config/i18n/zh-CN.json index 4a6079562..98a993b5b 100644 --- a/module/config/i18n/zh-CN.json +++ b/module/config/i18n/zh-CN.json @@ -1064,6 +1064,12 @@ "boss_only": "仅BOSS战", "every_combat": "每场战斗" }, + "Timing": { + "name": "BOSS战召唤时机(测试中)", + "help": "注意:'等待BOSS出现'当前仅支持:经典UI、血条边框为深蓝纯色的新型UI!若使用无法识别的UI将导致潜艇不出击!", + "default": "默认(进战立即尝试召唤)", + "wait_for_boss": "等待boss出现" + }, "DistanceToBoss": { "name": "BOSS战前将潜艇移动到BOSS附近", "help": "仅在\"潜艇出击方案\"为\"仅BOSS战\"时生效\n选择\"距离BOSS X格\"需要保证潜艇狩猎范围能覆盖到BOSS,距离使用曼哈顿距离计算;选择\"使用远洋支援\"需要潜艇队伍里有具备远洋支援技能的舰娘,如U522、达芬奇等", diff --git a/module/config/i18n/zh-TW.json b/module/config/i18n/zh-TW.json index 455340a24..e01cc9eab 100644 --- a/module/config/i18n/zh-TW.json +++ b/module/config/i18n/zh-TW.json @@ -1064,6 +1064,12 @@ "boss_only": "僅BOSS戰", "every_combat": "每場戰鬥" }, + "Timing": { + "name": "BOSS戰召喚時機(測試中)", + "help": "注意:'等待BOSS出現'目前僅支援:經典UI、血條邊框為深藍純色的新型UI!若使用無法識別的UI將導致潛艇不出擊!", + "default": "預設(進戰立即嘗試召喚)", + "wait_for_boss": "等待BOSS出現" + }, "DistanceToBoss": { "name": "BOSS戰前將潛艇移動到BOSS附近", "help": "僅在\"潛艇出擊方案\"為\"僅BOSS戰\"時生效\n選擇\"距離BOSS X格\"需要保證潛艇狩獵範圍能覆蓋到BOSS,距離使用曼哈頓距離計算,選擇\"使用遠洋支援\"需要潛艇隊伍裡有U522或是達芬奇", From 0fa54fe07eb32d1a1eb44066224d2f3ce5b02a6b Mon Sep 17 00:00:00 2001 From: positnuec <93694981+positnuec@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:14:32 +0800 Subject: [PATCH 06/20] Fix: prevent unwanted submarine hunt wait in super()._goto --- module/os/map.py | 1 + 1 file changed, 1 insertion(+) diff --git a/module/os/map.py b/module/os/map.py index 76665ba73..2381527ea 100644 --- a/module/os/map.py +++ b/module/os/map.py @@ -55,6 +55,7 @@ class OSMap(OSFleet, Map, GlobeCamera, StorageHandler, StrategicSearchHandler): self.config.override( Submarine_Fleet=1, Submarine_CallMode='every_combat', + Submarine_HuntMode='do_not_use', STORY_ALLOW_SKIP=False, **kwargs ) From ed6418efde139ea7c102e270949897825f6f8b57 Mon Sep 17 00:00:00 2001 From: positnuec <93694981+positnuec@users.noreply.github.com> Date: Fri, 10 Jul 2026 02:37:45 +0800 Subject: [PATCH 07/20] Fix: drop support for new UI for submarine call timing --- module/combat/submarine.py | 14 +++++++------- module/config/i18n/en-US.json | 6 +++--- module/config/i18n/ja-JP.json | 6 +++--- module/config/i18n/zh-CN.json | 6 +++--- module/config/i18n/zh-TW.json | 6 +++--- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/module/combat/submarine.py b/module/combat/submarine.py index 4977cb641..f22c27c98 100644 --- a/module/combat/submarine.py +++ b/module/combat/submarine.py @@ -6,12 +6,12 @@ from module.logger import logger # BOSS detection areas # Classic UI: BOSS avatar frame right border -BOSS_AVATAR_FRAME_RIGHT = (301, 13, 303, 89) +BOSS_AVATAR_FRAME_RIGHT = (301, 15, 302, 80) BOSS_AVATAR_FRAME_COLOR = (24, 28, 24) # New UI: BOSS HP bar bottom border, middle-left portion # Color targets deep blue themes; need adjustment for other color schemes -BOSS_HP_BAR_BORDER = (420, 48, 620, 50) -BOSS_HP_BAR_COLOR = (31, 39, 61) +# BOSS_HP_BAR_BORDER = (420, 48, 620, 50) +# BOSS_HP_BAR_COLOR = (31, 39, 61) class SubmarineCall(ModuleBase): @@ -31,12 +31,12 @@ class SubmarineCall(ModuleBase): Detect whether BOSS has appeared by checking stable BOSS UI elements. """ # New UI: BOSS HP bar bottom border - avg = get_color(self.device.image, BOSS_HP_BAR_BORDER) - if color_similar(avg, BOSS_HP_BAR_COLOR, threshold=40): - return True + # avg = get_color(self.device.image, BOSS_HP_BAR_BORDER) + # if color_similar(avg, BOSS_HP_BAR_COLOR, threshold=40): + # return True # Classic UI: BOSS avatar frame right border frame = crop(self.device.image, BOSS_AVATAR_FRAME_RIGHT, copy=False) - if image_color_count(frame, color=BOSS_AVATAR_FRAME_COLOR, count=55): + if image_color_count(frame, color=BOSS_AVATAR_FRAME_COLOR, threshold=200, count=50): return True return False diff --git a/module/config/i18n/en-US.json b/module/config/i18n/en-US.json index 7a08dfc31..43f201e92 100644 --- a/module/config/i18n/en-US.json +++ b/module/config/i18n/en-US.json @@ -1059,14 +1059,14 @@ }, "CallMode": { "name": "Call Submarine", - "help": "Attempt to call submarines by clicking the call button during combat", + "help": "Only supports Classic UI. Attempts to call submarines by clicking the call button during combat", "do_not_use": "Don't Use", "boss_only": "BOSS Only", "every_combat": "Every Combat" }, "Timing": { - "name": "BOSS Battle Call Timing (Experimental)", - "help": "Note: 'Wait For BOSS' currently only supports: Classic UI, and new UI with solid deep blue HP bar borders! Using unsupported UIs will prevent submarines from being called!", + "name": "BOSS Battle Call Timing", + "help": "", "default": "Default (call immediately after entering combat)", "wait_for_boss": "Wait For BOSS" }, diff --git a/module/config/i18n/ja-JP.json b/module/config/i18n/ja-JP.json index a34efad53..1ac15b7ca 100644 --- a/module/config/i18n/ja-JP.json +++ b/module/config/i18n/ja-JP.json @@ -1059,14 +1059,14 @@ }, "CallMode": { "name": "潜水艦出撃設定", - "help": "戦闘中にボタンをクリックして潜水艦の召喚を試みます", + "help": "クラシックUIのみ対応。戦闘中にボタンをクリックして潜水艦の召喚を試みます", "do_not_use": "使用しない", "boss_only": "BOSS戦のみ", "every_combat": "毎戦" }, "Timing": { - "name": "BOSS戦召喚タイミング(テスト中)", - "help": "注意:'BOSS出現待機'現在のみ:クラシックUI、HPバー枠が深青一色の新型UIのみサポート!認識できないUIを使用すると潜水艦が出撃しません!", + "name": "BOSS戦召喚タイミング", + "help": "", "default": "デフォルト(戦闘開始直後に召喚を試みる)", "wait_for_boss": "BOSS出現待機" }, diff --git a/module/config/i18n/zh-CN.json b/module/config/i18n/zh-CN.json index 98a993b5b..802d0ca7c 100644 --- a/module/config/i18n/zh-CN.json +++ b/module/config/i18n/zh-CN.json @@ -1059,14 +1059,14 @@ }, "CallMode": { "name": "潜艇出击方案", - "help": "在战斗内通过点击按钮以尝试召唤潜艇", + "help": "仅支持经典UI,在战斗内通过点击潜艇按钮尝试召唤", "do_not_use": "不使用", "boss_only": "仅BOSS战", "every_combat": "每场战斗" }, "Timing": { - "name": "BOSS战召唤时机(测试中)", - "help": "注意:'等待BOSS出现'当前仅支持:经典UI、血条边框为深蓝纯色的新型UI!若使用无法识别的UI将导致潜艇不出击!", + "name": "BOSS战召唤时机", + "help": "仅支持经典UI", "default": "默认(进战立即尝试召唤)", "wait_for_boss": "等待boss出现" }, diff --git a/module/config/i18n/zh-TW.json b/module/config/i18n/zh-TW.json index e01cc9eab..68a507fe0 100644 --- a/module/config/i18n/zh-TW.json +++ b/module/config/i18n/zh-TW.json @@ -1059,14 +1059,14 @@ }, "CallMode": { "name": "潛艇出擊方案", - "help": "在戰鬥內透過點擊按鈕以嘗試召喚潛艇", + "help": "僅支援經典UI,在戰鬥內透過點擊按鈕以嘗試召喚潛艇", "do_not_use": "不使用", "boss_only": "僅BOSS戰", "every_combat": "每場戰鬥" }, "Timing": { - "name": "BOSS戰召喚時機(測試中)", - "help": "注意:'等待BOSS出現'目前僅支援:經典UI、血條邊框為深藍純色的新型UI!若使用無法識別的UI將導致潛艇不出擊!", + "name": "BOSS戰召喚時機", + "help": "", "default": "預設(進戰立即嘗試召喚)", "wait_for_boss": "等待BOSS出現" }, From 9920c89fea4c38c4efa8c0c4ee92deea906bbbd0 Mon Sep 17 00:00:00 2001 From: positnuec <93694981+positnuec@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:27:43 +0800 Subject: [PATCH 08/20] Fix: misplaced timer reset prevented retry in _pq_goto_room --- module/private_quarters/interact.py | 1 - 1 file changed, 1 deletion(-) diff --git a/module/private_quarters/interact.py b/module/private_quarters/interact.py index 3415d343b..511e9a4d8 100644 --- a/module/private_quarters/interact.py +++ b/module/private_quarters/interact.py @@ -87,7 +87,6 @@ class PQInteract(UI): self.device.drag(p1, p2, segments=2, shake=(0, 25), point_random=(0, 0, 0, 0), shake_random=(0, -5, 0, 5)) - settle_timer.reset() else: # Absence of check likely means dialogue is ongoing self._pq_handle_dialogue() From 47a433d2fcb03ae727c66a201af319b50d7b21b4 Mon Sep 17 00:00:00 2001 From: positnuec <93694981+positnuec@users.noreply.github.com> Date: Fri, 10 Jul 2026 03:18:06 +0800 Subject: [PATCH 09/20] Feat: notify on empty tactical class slot --- config/template.json | 3 ++- module/config/argument/args.json | 4 ++++ module/config/argument/argument.yaml | 1 + module/config/config_generated.py | 1 + module/config/i18n/en-US.json | 4 ++++ module/config/i18n/ja-JP.json | 4 ++++ module/config/i18n/zh-CN.json | 4 ++++ module/config/i18n/zh-TW.json | 4 ++++ module/tactical/tactical_class.py | 27 +++++++++++++++++++++++++++ 9 files changed, 51 insertions(+), 1 deletion(-) diff --git a/config/template.json b/config/template.json index dece83037..10552c881 100644 --- a/config/template.json +++ b/config/template.json @@ -1441,7 +1441,8 @@ "AddNewStudent": { "Enable": false, "Favorite": false, - "MinLevel": 50 + "MinLevel": 50, + "EnableNotify": false }, "Storage": { "Storage": {} diff --git a/module/config/argument/args.json b/module/config/argument/args.json index c2ed8173c..f864467a1 100644 --- a/module/config/argument/args.json +++ b/module/config/argument/args.json @@ -7554,6 +7554,10 @@ "MinLevel": { "type": "input", "value": 50 + }, + "EnableNotify": { + "type": "checkbox", + "value": false } }, "Storage": { diff --git a/module/config/argument/argument.yaml b/module/config/argument/argument.yaml index e25c1b3a2..cf722385a 100644 --- a/module/config/argument/argument.yaml +++ b/module/config/argument/argument.yaml @@ -412,6 +412,7 @@ AddNewStudent: Enable: false Favorite: false MinLevel: 50 + EnableNotify: false Research: UseCube: value: only_05_hour diff --git a/module/config/config_generated.py b/module/config/config_generated.py index 9fcc7358c..8537b711f 100644 --- a/module/config/config_generated.py +++ b/module/config/config_generated.py @@ -227,6 +227,7 @@ class GeneratedConfig: AddNewStudent_Enable = False AddNewStudent_Favorite = False AddNewStudent_MinLevel = 50 + AddNewStudent_EnableNotify = False # Group `Research` Research_UseCube = 'only_05_hour' # always_use, only_05_hour, only_no_project, do_not_use diff --git a/module/config/i18n/en-US.json b/module/config/i18n/en-US.json index 43f201e92..6333cf1c3 100644 --- a/module/config/i18n/en-US.json +++ b/module/config/i18n/en-US.json @@ -1561,6 +1561,10 @@ "MinLevel": { "name": "Add Students with Level >= X Only", "help": "" + }, + "EnableNotify": { + "name": "Notify on Empty Slot", + "help": "Send a notification when there is an empty slot in the Tactical Academy: a ship girl has finished studying and 'Level Skills' is disabled, or enabled but failed to assign a new student" } }, "Research": { diff --git a/module/config/i18n/ja-JP.json b/module/config/i18n/ja-JP.json index 1ac15b7ca..dd7ed0ea3 100644 --- a/module/config/i18n/ja-JP.json +++ b/module/config/i18n/ja-JP.json @@ -1561,6 +1561,10 @@ "MinLevel": { "name": "レベルがX以上のキャラクターのみを追加してください。", "help": "" + }, + "EnableNotify": { + "name": "空席通知を送信", + "help": "戦術学院に空席がある時に通知を送信:艦娘が学習を完了し、かつ'新しいスキルを学ぶ'が無効、または有効だが新規生徒の割り当てに失敗した時" } }, "Research": { diff --git a/module/config/i18n/zh-CN.json b/module/config/i18n/zh-CN.json index 802d0ca7c..9e689e391 100644 --- a/module/config/i18n/zh-CN.json +++ b/module/config/i18n/zh-CN.json @@ -1561,6 +1561,10 @@ "MinLevel": { "name": "仅添加等级 >= X 的角色", "help": "" + }, + "EnableNotify": { + "name": "推送空位提醒", + "help": "在战术学院有空位时推送通知:舰娘完成学习且未启用'学习新技能',或启用了但指派失败时" } }, "Research": { diff --git a/module/config/i18n/zh-TW.json b/module/config/i18n/zh-TW.json index 68a507fe0..093c3212f 100644 --- a/module/config/i18n/zh-TW.json +++ b/module/config/i18n/zh-TW.json @@ -1561,6 +1561,10 @@ "MinLevel": { "name": "僅新增等級 >= X 的角色", "help": "" + }, + "EnableNotify": { + "name": "推送空位提醒", + "help": "在戰術學院有空位時推送通知:艦娘完成學習且未啟用'學習新技能',或啟用了但指派失敗時" } }, "Research": { diff --git a/module/tactical/tactical_class.py b/module/tactical/tactical_class.py index 0e518a49a..e430e0d44 100644 --- a/module/tactical/tactical_class.py +++ b/module/tactical/tactical_class.py @@ -11,6 +11,7 @@ from module.exception import ScriptError from module.handler.assets import GET_MISSION, MISSION_POPUP_ACK, MISSION_POPUP_GO, POPUP_CANCEL, POPUP_CONFIRM from module.logger import logger from module.map.map_grids import SelectedGrids +from module.notify import handle_notify from module.ocr.ocr import DigitCounter, Duration, Ocr from module.retire.assets import DOCK_CHECK, DOCK_EMPTY, SHIP_CONFIRM from module.retire.dock import CARD_GRIDS, CARD_LEVEL_GRIDS, Dock @@ -378,6 +379,23 @@ class RewardTacticalClass(Dock): return False + def _notify_tactical_empty_slot(self, reason: str): + """ + Push notification when tactical class has empty slots. + Only the first call in a single run will actually send, later calls are skipped. + + Args: + reason (str): + """ + if getattr(self, '_tactical_notified', False): + return + handle_notify( + self.config.Error_OnePushConfig, + title=f"Alas <{self.config.config_name}> tactical slot empty", + content=f"{reason}", + ) + self._tactical_notified = True + def _tactical_get_finish(self): """ Get the future finish time. @@ -389,6 +407,10 @@ class RewardTacticalClass(Dock): is_running = [self.image_color_count(button, color=(148, 255, 99), count=50) for button in grids.buttons] logger.info(f'Tactical status: {["running" if s else "empty" for s in is_running]}') + if (self.config.AddNewStudent_EnableNotify + and not self.config.AddNewStudent_Enable and self.appear(ADD_NEW_STUDENT, offset=(800, 20))): + self._notify_tactical_empty_slot("Slot unfilled or lesson just completed, with 'Add New Student' disabled") + buttons = [b for b, s in zip(grids.buttons, is_running) if s] ocr = Duration(buttons, letter=(148, 255, 99), name='TACTICAL_REMAIN') remains = ocr.ocr(self.device.image) @@ -415,6 +437,7 @@ class RewardTacticalClass(Dock): """ logger.hr('Tactical class receive', level=1) received = False + self._tactical_notified = False study_finished = not self.config.AddNewStudent_Enable book_empty = False # tactical cards can't be loaded that fast, confirm if it's empty. @@ -520,6 +543,8 @@ class RewardTacticalClass(Dock): if self.select_suitable_ship(): pass else: + if self.config.AddNewStudent_EnableNotify: + self._notify_tactical_empty_slot('No available student to add') study_finished = True self.device.click(BACK_ARROW) else: @@ -536,6 +561,8 @@ class RewardTacticalClass(Dock): if self._tactical_skill_choose(): pass else: + if self.config.AddNewStudent_EnableNotify: + self._notify_tactical_empty_slot('No available skill to learn') study_finished = True self.device.click(BACK_ARROW) else: From 47414bdf5fb22ddc697a1940aec5e0c53a5de493 Mon Sep 17 00:00:00 2001 From: positnuec <93694981+positnuec@users.noreply.github.com> Date: Fri, 10 Jul 2026 03:52:35 +0800 Subject: [PATCH 10/20] Fix: expand auto_search_combat_status exits as popups block auto search check between close enemies --- module/combat/auto_search_combat.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/module/combat/auto_search_combat.py b/module/combat/auto_search_combat.py index 9063b53f4..5e6689e51 100644 --- a/module/combat/auto_search_combat.py +++ b/module/combat/auto_search_combat.py @@ -444,6 +444,13 @@ class AutoSearchCombat(MapOperation, Combat, CampaignStatus): if self.is_auto_search_running(): self._auto_search_status_confirm = False break + if self.is_combat_loading(): + logger.info('Next battle already started') + break + if self.handle_retirement(): + self.map_offensive_auto_search() + # Map offensive ends at is_combat_loading + break if self.is_in_auto_search_menu() or self._handle_auto_search_menu_missing(): raise CampaignEnd @@ -455,10 +462,10 @@ class AutoSearchCombat(MapOperation, Combat, CampaignStatus): if self.handle_get_ship(): continue if not self._sinking and self.handle_auto_search_map_option(): - self._auto_search_status_confirm = False continue # bunch of popup handlers if self.handle_popup_confirm('AUTO_SEARCH_COMBAT_STATUS'): + self._auto_search_status_confirm = True continue if self.handle_urgent_commission(): get_urgent_commission = True From c615e17d8f6889d94f94e1f8a2dad4f0b440b09e Mon Sep 17 00:00:00 2001 From: positnuec <93694981+positnuec@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:47:12 +0800 Subject: [PATCH 11/20] Refactor: reorganize exercise.run() for clearer structure --- module/exercise/exercise.py | 101 ++++++++++++++++++++---------------- 1 file changed, 55 insertions(+), 46 deletions(-) diff --git a/module/exercise/exercise.py b/module/exercise/exercise.py index bac6ec0e8..d68d3ae33 100644 --- a/module/exercise/exercise.py +++ b/module/exercise/exercise.py @@ -206,52 +206,38 @@ class Exercise(ExerciseCombat): return preserve, admiral_interval - def run(self): - self.ui_ensure(page_exercise) - server_update = self.config.Scheduler_ServerUpdate + def _should_force_clear(self, remain_time, admiral_interval): + """Decide whether to clear all exercise attempts this run.""" + if admiral_interval is None or not remain_time: + return False - self.opponent_change_count = self._get_opponent_change_count() - logger.attr("Change_opponent_count", self.opponent_change_count) - logger.attr('Exercise_ExerciseStrategy', self.config.Exercise_ExerciseStrategy) - self.preserve, admiral_interval = self._get_exercise_strategy() + remain_hours = int(remain_time.total_seconds() // 3600) + admiral_start, admiral_end = admiral_interval - remain_time = OCR_PERIOD_REMAIN.ocr(self.device.image) - logger.info(f'Exercise period remain: {remain_time}') + if self._get_thursday_strategy(remain_hours): + logger.info('Reach Thursday, using all attempts') + return True + if admiral_start > remain_hours >= admiral_end: + logger.info('Reach set time for admiral trial, using all attempts') + return True + if remain_hours < 6: + logger.info('Exercise period remain less than 6 hours, using all attempts') + return True - if admiral_interval is not None and remain_time: - admiral_start, admiral_end = admiral_interval + return False - if self._get_thursday_strategy(int(remain_time.total_seconds() // 3600)): - logger.info('Reach Thursday for admiral trial, using all attempts.') - self.preserve = 0 - forced_run = True - elif admiral_start > int(remain_time.total_seconds() // 3600) >= admiral_end: # set time for getting admiral - logger.info('Reach set time for admiral trial, using all attempts.') - self.preserve = 0 - forced_run = True - elif int(remain_time.total_seconds() // 3600) < 6: # if not set to "sun18", still depleting at sunday 18pm. - logger.info('Exercise period remain less than 6 hours, using all attempts.') - self.preserve = 0 - forced_run = True - else: - logger.info(f'Preserve {self.preserve} exercise') - forced_run = False - else: - forced_run = False + def _should_delay(self, next_update, forced_clear): + """Check whether to delay exercise to the configured time before next update.""" + if forced_clear: + return False + seconds_until_update = (next_update - datetime.datetime.now()).seconds + return seconds_until_update > 3600 * self.config.Exercise_DelayUntilHoursBeforeNextUpdate - # Delay task to the configured time - if ((get_server_next_update(server_update) - datetime.datetime.now()).seconds > - 3600 * self.config.Exercise_DelayUntilHoursBeforeNextUpdate)\ - and not forced_run: - logger.warning(f'Exercise should run at {self.config.Exercise_DelayUntilHoursBeforeNextUpdate} ' - f'hours before next update. Delay task to it.') - run = False - else: - run = True - - while run: + def _exercise(self, preserve): + """Consume exercise attempts until remain <= preserve or refresh exhausted.""" + while 1: self.remain = OCR_EXERCISE_REMAIN.ocr(self.device.image) - if self.remain <= self.preserve: + if self.remain <= preserve: break logger.hr(f'Exercise remain {self.remain}', level=1) @@ -263,19 +249,42 @@ class Exercise(ExerciseCombat): logger.info('New opponent exhausted') break + def run(self): + self.ui_ensure(page_exercise) + server_update = self.config.Scheduler_ServerUpdate + + self.opponent_change_count = self._get_opponent_change_count() + logger.attr("Change_opponent_count", self.opponent_change_count) + logger.attr('Exercise_ExerciseStrategy', self.config.Exercise_ExerciseStrategy) + self.preserve, admiral_interval = self._get_exercise_strategy() + + remain_time = self._get_exercise_reset_remain() + logger.info(f'Exercise period remain: {remain_time}') + + forced_clear = self._should_force_clear(remain_time, admiral_interval) + if forced_clear: + self.preserve = 0 + + next_update = get_server_next_update(server_update) + should_delay = self._should_delay(next_update, forced_clear) + if should_delay: + logger.warning(f'Exercise should run at {self.config.Exercise_DelayUntilHoursBeforeNextUpdate} ' + f'hours before next update. Delay task to it.') + else: + self._exercise(self.preserve) + # self.equipment_take_off_when_finished() # Scheduler with self.config.multi_set(): self.config.set_record(Exercise_OpponentRefreshValue=self.opponent_change_count) if self.remain <= self.preserve or self.opponent_change_count >= 5: - next_run = get_server_next_update(server_update) \ - - datetime.timedelta(hours=self.config.Exercise_DelayUntilHoursBeforeNextUpdate) + next_run = next_update - datetime.timedelta(hours=self.config.Exercise_DelayUntilHoursBeforeNextUpdate) now = datetime.datetime.now() - if next_run < now or run: + if next_run >= now or should_delay: + minutes_to_delay = int((next_run - now).total_seconds() / 60 + 1) + self.config.task_delay(minute=minutes_to_delay) + else: self.config.task_delay(server_update=True) - return - minutes_to_delay = int((next_run - now).total_seconds() / 60 + 1) - self.config.task_delay(minute=minutes_to_delay) else: self.config.task_delay(success=False) From f92ee9907d75e3f257d15103d9556d162889d035 Mon Sep 17 00:00:00 2001 From: positnuec <93694981+positnuec@users.noreply.github.com> Date: Thu, 9 Jul 2026 03:55:24 +0800 Subject: [PATCH 12/20] Update .gitignore for AI coding local folders --- .gitignore | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.gitignore b/.gitignore index 73ce58dae..c4abb6fb7 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,17 @@ config/reloadalas test.py test/ + +# Cover some AI IDEs +.claude +.codex +.cursor +.trae + +# Cover some mcp tools +.codegraph + + # Created by .ignore support plugin (hsz.mobi) ### JetBrains template From 9cc7aa804b9962248cf0c178331ded3cc47fda52 Mon Sep 17 00:00:00 2001 From: positnuec <93694981+positnuec@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:25:18 +0800 Subject: [PATCH 13/20] Feat: add module smart_mgmt --- alas.py | 20 +- config/template.json | 25 ++ module/config/argument/args.json | 72 ++++++ module/config/argument/argument.yaml | 10 + module/config/argument/menu.json | 8 + module/config/argument/override.yaml | 8 + module/config/argument/task.yaml | 12 + module/config/config_generated.py | 7 + module/config/config_manual.py | 1 + module/config/i18n/en-US.json | 36 +++ module/config/i18n/ja-JP.json | 36 +++ module/config/i18n/zh-CN.json | 36 +++ module/config/i18n/zh-TW.json | 36 +++ module/smart_mgmt/downtime_fetch.py | 47 ++++ module/smart_mgmt/downtime_fetcher.py | 345 ++++++++++++++++++++++++++ module/smart_mgmt/utils.py | 34 +++ 16 files changed, 727 insertions(+), 6 deletions(-) create mode 100644 module/smart_mgmt/downtime_fetch.py create mode 100644 module/smart_mgmt/downtime_fetcher.py create mode 100644 module/smart_mgmt/utils.py diff --git a/alas.py b/alas.py index 06b635c53..26ae0cd94 100644 --- a/alas.py +++ b/alas.py @@ -15,6 +15,7 @@ from module.logger import logger from module.notify import handle_notify RESTART_SENSITIVE_TASKS = ['OpsiObscure', 'OpsiAbyssal', 'OpsiCrossMonth'] +DEVICE_FREE_TASKS = 'DowntimeFetch' class AzurLaneAutoScript: @@ -198,6 +199,10 @@ class AzurLaneAutoScript: LoginHandler(self.config, device=self.device).app_start() UI(self.config, device=self.device).ui_goto_main() + def downtime_fetch(self): + from module.smart_mgmt.downtime_fetch import DowntimeFetch + DowntimeFetch(config=self.config).run() + def research(self): from module.research.research import RewardResearch RewardResearch(config=self.config, device=self.device).run() @@ -570,9 +575,8 @@ class AzurLaneAutoScript: self.config.task_call('Restart') # Get task task = self.get_next_task() - # Init device and change server - _ = self.device - self.device.config = self.config + # Network-only tasks skip device init and screenshot + device_free_task = task == DEVICE_FREE_TASKS # Skip first restart if task == 'Restart' and self.is_first_task: logger.info('Skip task `Restart` at scheduler start') @@ -582,10 +586,14 @@ class AzurLaneAutoScript: # Run logger.info(f'Scheduler: Start task `{task}`') - self.device.stuck_record_clear() - self.device.click_record_clear() + # Init device and change server + if not device_free_task: + _ = self.device + self.device.config = self.config + self.device.stuck_record_clear() + self.device.click_record_clear() logger.hr(task, level=0) - success = self.run(inflection.underscore(task)) + success = self.run(inflection.underscore(task), skip_first_screenshot=device_free_task) logger.info(f'Scheduler: End task `{task}`') self.is_first_task = False diff --git a/config/template.json b/config/template.json index 10552c881..5fefca2e0 100644 --- a/config/template.json +++ b/config/template.json @@ -149,6 +149,31 @@ "Storage": {} } }, + "DowntimeFetch": { + "Scheduler": { + "Enable": false, + "NextRun": "2020-01-01 00:00:00", + "Command": "DowntimeFetch", + "SuccessInterval": 30, + "FailureInterval": 30, + "ServerUpdate": "22:30" + }, + "Storage": { + "Storage": {} + } + }, + "DowntimeMgmt": { + "Downtime": { + "Window": null, + "Title": null + }, + "DowntimeStrategy": { + "ManageExercise": false + }, + "Storage": { + "Storage": {} + } + }, "Main": { "Scheduler": { "Enable": false, diff --git a/module/config/argument/args.json b/module/config/argument/args.json index f864467a1..78917b0a0 100644 --- a/module/config/argument/args.json +++ b/module/config/argument/args.json @@ -669,6 +669,78 @@ } } }, + "DowntimeFetch": { + "Scheduler": { + "Enable": { + "type": "checkbox", + "value": false, + "option": [ + true, + false + ] + }, + "NextRun": { + "type": "datetime", + "value": "2020-01-01 00:00:00", + "validate": "datetime" + }, + "Command": { + "type": "input", + "value": "DowntimeFetch", + "display": "hide" + }, + "SuccessInterval": { + "type": "input", + "value": 30, + "display": "hide" + }, + "FailureInterval": { + "type": "input", + "value": 30, + "display": "hide" + }, + "ServerUpdate": { + "type": "input", + "value": "22:30", + "display": "hide" + } + }, + "Storage": { + "Storage": { + "type": "storage", + "value": {}, + "valuetype": "ignore", + "display": "disabled" + } + } + }, + "DowntimeMgmt": { + "Downtime": { + "Window": { + "type": "input", + "value": "" + }, + "Title": { + "type": "input", + "value": "", + "display": "disabled" + } + }, + "DowntimeStrategy": { + "ManageExercise": { + "type": "checkbox", + "value": false + } + }, + "Storage": { + "Storage": { + "type": "storage", + "value": {}, + "valuetype": "ignore", + "display": "disabled" + } + } + }, "Main": { "Scheduler": { "Enable": { diff --git a/module/config/argument/argument.yaml b/module/config/argument/argument.yaml index cf722385a..c618401eb 100644 --- a/module/config/argument/argument.yaml +++ b/module/config/argument/argument.yaml @@ -153,6 +153,16 @@ OldRetire: value: retire_all option: [ retire_all, retire_10 ] +# ==================== SmartMgmt ==================== + +Downtime: + Window: '' + Title: + value: '' + display: disabled +DowntimeStrategy: + ManageExercise: false + # ==================== Farm ==================== Campaign: diff --git a/module/config/argument/menu.json b/module/config/argument/menu.json index b6f7a9c4b..f9cb0a1d7 100644 --- a/module/config/argument/menu.json +++ b/module/config/argument/menu.json @@ -8,6 +8,14 @@ "Restart" ] }, + "SmartMgmt": { + "menu": "collapse", + "page": "setting", + "tasks": [ + "DowntimeFetch", + "DowntimeMgmt" + ] + }, "Farm": { "menu": "collapse", "page": "setting", diff --git a/module/config/argument/override.yaml b/module/config/argument/override.yaml index e88d9d3dc..a38f435db 100644 --- a/module/config/argument/override.yaml +++ b/module/config/argument/override.yaml @@ -15,6 +15,14 @@ Restart: FailureInterval: 0 ServerUpdate: 00:00 +# ==================== SmartMgmt ==================== + +DowntimeFetch: + Scheduler: + SuccessInterval: 30 + FailureInterval: 30 + ServerUpdate: "22:30" + # ==================== Farm ==================== Main: diff --git a/module/config/argument/task.yaml b/module/config/argument/task.yaml index 92c76dd76..ab18f111a 100644 --- a/module/config/argument/task.yaml +++ b/module/config/argument/task.yaml @@ -22,6 +22,18 @@ Alas: Restart: - Scheduler +# ==================== SmartMgmt ==================== + +SmartMgmt: + menu: 'collapse' + page: 'setting' + tasks: + DowntimeFetch: + - Scheduler + DowntimeMgmt: + - Downtime + - DowntimeStrategy + # ==================== Farm ==================== Farm: diff --git a/module/config/config_generated.py b/module/config/config_generated.py index 8537b711f..c982b4b24 100644 --- a/module/config/config_generated.py +++ b/module/config/config_generated.py @@ -76,6 +76,13 @@ class GeneratedConfig: OldRetire_SSR = False OldRetire_RetireAmount = 'retire_all' # retire_all, retire_10 + # Group `Downtime` + Downtime_Window = None + Downtime_Title = None + + # Group `DowntimeStrategy` + DowntimeStrategy_ManageExercise = False + # Group `Campaign` Campaign_Name = '12-4' Campaign_Event = 'campaign_main' # campaign_main diff --git a/module/config/config_manual.py b/module/config/config_manual.py index fcc6de026..424790f68 100644 --- a/module/config/config_manual.py +++ b/module/config/config_manual.py @@ -11,6 +11,7 @@ class ManualConfig: SCHEDULER_PRIORITY = """ Restart > OpsiCrossMonth + > DowntimeFetch > Commission > Tactical > Research > Exercise > Dorm > Meowfficer > Guild > Gacha diff --git a/module/config/i18n/en-US.json b/module/config/i18n/en-US.json index 6333cf1c3..fac610ea7 100644 --- a/module/config/i18n/en-US.json +++ b/module/config/i18n/en-US.json @@ -4,6 +4,10 @@ "name": "Alas", "help": "" }, + "SmartMgmt": { + "name": "Smart Management", + "help": "" + }, "Farm": { "name": "Farm", "help": "" @@ -46,6 +50,14 @@ "name": "Restart", "help": "" }, + "DowntimeFetch": { + "name": "Downtime Fetch", + "help": "CN server only. Automatically fetches downtime maintenance notices from Bilibili Azur Lane columns, parses and fills 'Downtime Info'" + }, + "DowntimeMgmt": { + "name": "Downtime Maintenance", + "help": "" + }, "Main": { "name": "Main", "help": "" @@ -706,6 +718,30 @@ "retire_10": "Retire 10" } }, + "Downtime": { + "_info": { + "name": "Downtime Info", + "help": "Auto-filled by 'Downtime Fetch' task" + }, + "Window": { + "name": "Time Window", + "help": "Format YYYY-MM-DD HH:MM~HH:MM" + }, + "Title": { + "name": "Notice Title", + "help": "Display only" + } + }, + "DowntimeStrategy": { + "_info": { + "name": "Strategy Settings", + "help": "Auto-adjust task scheduling and strategy based on downtime info" + }, + "ManageExercise": { + "name": "Exercise", + "help": "When the downtime end time is 15:00 or later, exercise strategy before downtime maintenance on that day will be replaced with 'clear exercise attempts'" + } + }, "Campaign": { "_info": { "name": "Level Settings", diff --git a/module/config/i18n/ja-JP.json b/module/config/i18n/ja-JP.json index dd7ed0ea3..c45896959 100644 --- a/module/config/i18n/ja-JP.json +++ b/module/config/i18n/ja-JP.json @@ -4,6 +4,10 @@ "name": "Alas", "help": "" }, + "SmartMgmt": { + "name": "スマート管理", + "help": "" + }, "Farm": { "name": "出撃", "help": "" @@ -46,6 +50,14 @@ "name": "再起動設定", "help": "" }, + "DowntimeFetch": { + "name": "メンテナンス情報取得", + "help": "国服のみ対応。bilibiliアズールレーンコラムからメンテナンス告知を自動取得し、解析して'メンテナンス情報'に記入" + }, + "DowntimeMgmt": { + "name": "メンテナンス", + "help": "" + }, "Main": { "name": "メイン海域", "help": "" @@ -706,6 +718,30 @@ "retire_10": "retire_10" } }, + "Downtime": { + "_info": { + "name": "メンテナンス情報", + "help": "'メンテナンス情報取得'タスクにより自動入力" + }, + "Window": { + "name": "時間枠", + "help": "形式 YYYY-MM-DD HH:MM~HH:MM" + }, + "Title": { + "name": "告知タイトル", + "help": "表示のみ" + } + }, + "DowntimeStrategy": { + "_info": { + "name": "機能設定", + "help": "メンテナンス情報に基づきタスクスケジュールと戦略を自動調整" + }, + "ManageExercise": { + "name": "演習", + "help": "メンテナンス終了時刻が15:00以降の場合、当日のメンテナンス開始前の演習戦略が'演習回数をクリア'に置き換えられます" + } + }, "Campaign": { "_info": { "name": "Campaign._info.name", diff --git a/module/config/i18n/zh-CN.json b/module/config/i18n/zh-CN.json index 9e689e391..0d71abbc2 100644 --- a/module/config/i18n/zh-CN.json +++ b/module/config/i18n/zh-CN.json @@ -4,6 +4,10 @@ "name": "Alas", "help": "" }, + "SmartMgmt": { + "name": "智能管理", + "help": "" + }, "Farm": { "name": "出击", "help": "" @@ -46,6 +50,14 @@ "name": "重启设置", "help": "" }, + "DowntimeFetch": { + "name": "公告获取", + "help": "仅支持国服,自动从B站碧蓝航线专栏获取停服维护公告,解析并填写'停服维护信息'" + }, + "DowntimeMgmt": { + "name": "停服维护", + "help": "" + }, "Main": { "name": "主线图", "help": "" @@ -706,6 +718,30 @@ "retire_10": "退役10个" } }, + "Downtime": { + "_info": { + "name": "停服维护信息", + "help": "可由'公告获取'任务自动填写" + }, + "Window": { + "name": "时间窗口", + "help": "格式 YYYY-MM-DD HH:MM~HH:MM" + }, + "Title": { + "name": "公告标题", + "help": "仅作展示" + } + }, + "DowntimeStrategy": { + "_info": { + "name": "功能设置", + "help": "基于停服维护信息自动调整任务策略与调度" + }, + "ManageExercise": { + "name": "演习", + "help": "当停服结束时间为15:00及之后时,当天停服维护前演习策略将替换为'清空演习次数'" + } + }, "Campaign": { "_info": { "name": "关卡设置", diff --git a/module/config/i18n/zh-TW.json b/module/config/i18n/zh-TW.json index 093c3212f..27f94b30c 100644 --- a/module/config/i18n/zh-TW.json +++ b/module/config/i18n/zh-TW.json @@ -4,6 +4,10 @@ "name": "Alas", "help": "" }, + "SmartMgmt": { + "name": "智能管理", + "help": "" + }, "Farm": { "name": "出擊", "help": "" @@ -46,6 +50,14 @@ "name": "重啟設定", "help": "" }, + "DowntimeFetch": { + "name": "公告取得", + "help": "僅支援國服,自動從B站碧藍航線專欄取得停服維護公告,解析並填寫'停服維護資訊'" + }, + "DowntimeMgmt": { + "name": "停服維護", + "help": "" + }, "Main": { "name": "主線圖", "help": "" @@ -706,6 +718,30 @@ "retire_10": "退役10個" } }, + "Downtime": { + "_info": { + "name": "停服維護資訊", + "help": "可由'公告取得'任務自動填寫" + }, + "Window": { + "name": "時間窗口", + "help": "格式 YYYY-MM-DD HH:MM~HH:MM" + }, + "Title": { + "name": "公告標題", + "help": "僅作展示" + } + }, + "DowntimeStrategy": { + "_info": { + "name": "功能設定", + "help": "基於停服維護資訊自動調整任務排程與策略" + }, + "ManageExercise": { + "name": "演習", + "help": "當停服結束時間為15:00及之後時,當天停服維護前演習策略將替換為'清空演習次數'" + } + }, "Campaign": { "_info": { "name": "地圖設定", diff --git a/module/smart_mgmt/downtime_fetch.py b/module/smart_mgmt/downtime_fetch.py new file mode 100644 index 000000000..b13537aa3 --- /dev/null +++ b/module/smart_mgmt/downtime_fetch.py @@ -0,0 +1,47 @@ +import random +from datetime import timedelta + +from module.config.utils import get_server_next_update +from module.logger import logger +from module.smart_mgmt.downtime_fetcher import DowntimeFetcher, FetchError + + +class DowntimeFetch: + def __init__(self, config): + self.config = config + + def _set_downtime(self, window, title): + self.config.modified['DowntimeMgmt.Downtime.Window'] = window + self.config.modified['DowntimeMgmt.Downtime.Title'] = title + self.config.update() + + def _update_downtime(self) -> bool: + """Fetch downtime via fetcher and write cross-task config.""" + try: + payload = DowntimeFetcher().run() + except FetchError as e: + # Expected operational failure (API error, collection missing, no cache); + # degrade gracefully and let the caller reschedule + logger.warning(f'Fetch downtime failed: {e}') + return False + except Exception: + # Unexpected programming error; full traceback for diagnosis + logger.exception('Unexpected error during downtime fetch') + return False + self._set_downtime(payload.get('window', ''), payload.get('title', '')) + return True + + def run(self): + success = False + if self.config.SERVER != 'cn': + # Downtime fetch targets CN Bilibili notices only; skip on other servers + logger.warning('Downtime fetch is only available for CN server, skip') + else: + success = self._update_downtime() + if success: + # Stagger execution across instances within 5 minutes after server update + next_update = get_server_next_update(self.config.Scheduler_ServerUpdate) + target = next_update + timedelta(seconds=random.randint(0, 300)) + self.config.task_delay(target=target) + else: + self.config.task_delay(success=False) diff --git a/module/smart_mgmt/downtime_fetcher.py b/module/smart_mgmt/downtime_fetcher.py new file mode 100644 index 000000000..5b6b67e4b --- /dev/null +++ b/module/smart_mgmt/downtime_fetcher.py @@ -0,0 +1,345 @@ +from __future__ import annotations + +import json +import os +import re +import time +from dataclasses import dataclass +from datetime import datetime, timezone, timedelta +from pathlib import Path +from typing import Optional + +import requests + +from deploy.atomic import atomic_read_text, atomic_write +from module.logger import logger + + +class FetchError(RuntimeError): + pass + + +@dataclass +class CollectionMeta: + collection_id: int + collection_name: str + update_time: int + +@dataclass +class Article: + pub_ts: int + title: str + summary: str + +@dataclass +class DowntimeWindow: + window: str + + +class DowntimeFetcher: + DEFAULT_MID = 233114659 + DEFAULT_COLLECTION_NAME = '《碧蓝航线》维护公告' + ARTICLE_LISTS_API = 'https://api.bilibili.com/x/article/up/lists' + ARTICLE_COLLECTION_API = 'https://api.bilibili.com/x/article/list/web/articles' + CN_TZ = timezone(timedelta(hours=8)) + REQUEST_TIMEOUT = 30.0 + + WS = r'[\s ]*' + WINDOW_RE = re.compile( + r'司令部将于' + WS + + r'(\d{1,2})月(\d{1,2})日' + WS + + r'(\d{1,2})[::](\d{2})' + WS + + r'[~~至到—\-]+' + WS + + r'(\d{1,2})[::](\d{2})', + ) + DURATION_RE = re.compile(r'为期' + WS + r'(\d+)' + WS + r'个?小时') + TITLE_TIME_RE = re.compile(r'(\d{1,2})月(\d{1,2})日' + WS + r'(\d{1,2})[::](\d{2})') + + CACHE_FILE = './log/downtime_cache.json' + FETCH_LOCK_FILE = './log/downtime_fetch.lock' + # Multi-instance duplicate fetch avoidance window (seconds) + CACHE_FRESH_SECONDS = 300 + # A bit larger than REQUEST_TIMEOUT + LOCK_WAIT_SECONDS = 35 + + def __init__(self): + self.session = self._session_for() + + def _session_for(self): + s = requests.Session() + s.headers.update({ + 'User-Agent': ( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36' + ), + 'Referer': f'https://space.bilibili.com/{self.DEFAULT_MID}/article', + }) + return s + + def _request_api(self, url, params) -> dict: + """Request Bilibili API and return inner data field (code == 0).""" + http_resp = self.session.get(url, params=params, timeout=self.REQUEST_TIMEOUT) + http_resp.raise_for_status() + body = http_resp.json() + if body['code'] != 0: + raise FetchError(body.get('message') or body.get('msg') or 'unknown error') + return body['data'] + + def find_collection(self) -> CollectionMeta: + """ + Find target collection by name from UP's article lists. + + Returns: + CollectionMeta: Metadata of the matched collection. + """ + lists = self._request_api(self.ARTICLE_LISTS_API, {'mid': str(self.DEFAULT_MID), 'sort': 0})['lists'] + for item in lists: + if item['name'] == self.DEFAULT_COLLECTION_NAME: + collection_meta = CollectionMeta( + collection_id=int(item['id']), + collection_name=item['name'], + update_time=int(item['update_time']), + ) + logger.info(f'Found collection: id={collection_meta.collection_id} ' + f'update_time={collection_meta.update_time}') + return collection_meta + raise FetchError(f'collection not found: mid={self.DEFAULT_MID} name={self.DEFAULT_COLLECTION_NAME!r}') + + @staticmethod + def _normalize_article(article) -> Article: + return Article( + pub_ts=int(article['publish_time']), + title=article['title'], + summary=article['summary'], + ) + + @staticmethod + def _clean_text(text: str): + return text.replace('[图片]', '').replace('\u3000', ' ').strip() + + def _infer_year(self, published, month, day): + year = published.year + delta = (datetime(year, month, day, tzinfo=self.CN_TZ).date() - published.date()).days + if delta < -30: + year += 1 + return year + + def _build_window_dt(self, published, sm, sd, sh, smin, eh, emin): + year = self._infer_year(published, sm, sd) + start = datetime(year, sm, sd, sh, smin, tzinfo=self.CN_TZ) + end = datetime(year, sm, sd, eh, emin, tzinfo=self.CN_TZ) + return start, end + + def parse_window_from_article(self, article: Article) -> Optional[DowntimeWindow]: + """ + Returns: + Optional[DowntimeWindow]: Parsed downtime window, or None if no pattern matched. + The window string uses '~' as time range separator to prevent config loader + from mis-parsing 'HH:MM-HH:MM' as an ISO 8601 datetime with offset. + """ + published = datetime.fromtimestamp(article.pub_ts, tz=self.CN_TZ) + body = self._clean_text(article.summary) + window_match = self.WINDOW_RE.search(body) + duration_match = self.DURATION_RE.search(body) + + if window_match: + sm = int(window_match.group(1)) + sd = int(window_match.group(2)) + sh = int(window_match.group(3)) + smin = int(window_match.group(4)) + eh = int(window_match.group(5)) + emin = int(window_match.group(6)) + start, end = self._build_window_dt(published, sm, sd, sh, smin, eh, emin) + return DowntimeWindow(window=f'{start:%Y-%m-%d} {start:%H:%M}~{end:%H:%M}') + + if duration_match: + title_match = self.TITLE_TIME_RE.search(article.title) + if title_match: + sm = int(title_match.group(1)) + sd = int(title_match.group(2)) + sh = int(title_match.group(3)) + smin = int(title_match.group(4)) + hours = int(duration_match.group(1)) + start, _ = self._build_window_dt(published, sm, sd, sh, smin, sh, smin) + end = start + timedelta(hours=hours) + return DowntimeWindow(window=f'{start:%Y-%m-%d} {start:%H:%M}~{end:%H:%M}') + + return None + + @staticmethod + def _latest_article(articles: list[Article]) -> Article: + return max(articles, key=lambda a: a.pub_ts) + + def fetch_articles(self, local_update_time=None) -> tuple[Optional[list[Article]], CollectionMeta]: + """ + Full fetch pipeline: find_collection → incremental check → fetch articles. + + Returns: + tuple[Optional[list[Article]], CollectionMeta]: + articles is None when collection not updated (incremental check hit). + """ + collection_meta = self.find_collection() + + if local_update_time is not None and local_update_time == collection_meta.update_time: + logger.info('Collection not updated, reuse local cache') + return None, collection_meta + + logger.info(f'Collection updated, fetching articles (id={collection_meta.collection_id})') + articles_data = self._request_api(self.ARTICLE_COLLECTION_API, {'id': str(collection_meta.collection_id)}) + articles = [self._normalize_article(article) for article in articles_data['articles']] + logger.info(f'Fetched {len(articles)} articles') + return articles, collection_meta + + def collect_info(self, local_update_time=None) -> tuple[Optional[DowntimeWindow], CollectionMeta, Optional[Article]]: + """ + Full fetch and parse pipeline. + + Returns: + tuple[Optional[DowntimeWindow], CollectionMeta, Optional[Article]]: + (downtime_window, collection_meta, latest_article). + downtime_window is None if parse failed. latest_article is None if collection not updated. + """ + articles, collection_meta = self.fetch_articles(local_update_time) + if articles is None: + return None, collection_meta, None + latest_article = self._latest_article(articles) + downtime_window = self.parse_window_from_article(latest_article) + logger.info(f'Fetched downtime: window={downtime_window.window if downtime_window else ""} ' + f'title={latest_article.title!r}') + return downtime_window, collection_meta, latest_article + + @classmethod + def load_cache(cls) -> Optional[dict]: + if not Path(cls.CACHE_FILE).exists(): + return None + text = atomic_read_text(cls.CACHE_FILE) + if not text: + return None + try: + return json.loads(text) + except json.JSONDecodeError: + logger.warning('Failed to parse downtime_cache.json, will re-fetch') + return None + + @classmethod + def save_cache(cls, payload): + Path(cls.CACHE_FILE).parent.mkdir(parents=True, exist_ok=True) + atomic_write(cls.CACHE_FILE, json.dumps(payload, ensure_ascii=False, indent=2)) + logger.info(f'Saved downtime cache to {cls.CACHE_FILE}') + + @classmethod + def is_cache_fresh(cls): + """Check if cache file was modified within CACHE_FRESH_SECONDS.""" + p = Path(cls.CACHE_FILE) + if not p.exists(): + return False + age = datetime.now().timestamp() - p.stat().st_mtime + return age < cls.CACHE_FRESH_SECONDS + + def get_downtime_info(self) -> dict: + """ + Full pipeline: load cache → collect_info → save cache. + + Returns: + dict: payload with window, title, collection_id, update_time. + """ + cache = self.load_cache() + # Avoid duplicate fetch across instances: reuse fresh cache + if cache and self.is_cache_fresh(): + logger.info('Cache is fresh, reuse without fetch') + return cache + local_update_time = cache.get('update_time') if cache else None + + downtime_window, collection_meta, latest_article = self.collect_info(local_update_time) + + if latest_article is None: + if cache is None: + raise FetchError('No available cache and collection not updated') + return cache + + # Degrade on parse failure: reuse cached window + if downtime_window is None: + if cache and cache.get('window'): + logger.warning('Parse failed, reuse cached window') + window = cache['window'] + else: + window = '' + else: + window = downtime_window.window + + cache_payload = { + 'collection_id': collection_meta.collection_id, + 'update_time': collection_meta.update_time, + 'window': window, + 'title': latest_article.title, + } + self.save_cache(cache_payload) + return cache_payload + + @classmethod + def acquire_lock(cls) -> bool: + """ + Acquire cross-process fetch lock. + + Returns: + bool: True if acquired, False on timeout or OSError. + """ + p = Path(cls.FETCH_LOCK_FILE) + p.parent.mkdir(parents=True, exist_ok=True) + deadline = time.monotonic() + cls.LOCK_WAIT_SECONDS + while time.monotonic() < deadline: + if p.exists(): + try: + age = datetime.now().timestamp() - p.stat().st_mtime + if age < cls.LOCK_WAIT_SECONDS: + time.sleep(1) + continue + logger.warning(f'Stale lock assumed (age={age:.0f}s), overriding') + p.unlink() + except (OSError, FileNotFoundError): + pass + # Atomic create via O_CREAT|O_EXCL to avoid race between instances + try: + fd = os.open(str(p), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.write(fd, str(datetime.now().timestamp()).encode()) + os.close(fd) + return True + except FileExistsError: + time.sleep(1) + except OSError as e: + logger.warning(f'Failed to acquire fetch lock: {e}') + return False + return False + + @classmethod + def release_lock(cls): + try: + Path(cls.FETCH_LOCK_FILE).unlink() + except FileNotFoundError: + pass + except OSError as e: + logger.warning(f'Failed to release fetch lock: {e}') + + def run(self) -> dict: + """ + Lock-guarded entry for the downtime pipeline. + + Fast path: cache fresh, invoke get_downtime_info without lock (read-only, no contention). + Common path: acquire cross-process lock, then get_downtime_info (re-checks freshness). + Fallback: lock acquisition failed, reuse cache only if another process + has freshly updated it; otherwise raise FetchError. + + Returns: + dict: payload with window, title, collection_id, update_time. + """ + if self.is_cache_fresh(): + return self.get_downtime_info() + if not self.acquire_lock(): + if self.is_cache_fresh(): + logger.info('Cache updated by another process, reuse') + return self.get_downtime_info() + raise FetchError('Failed to acquire fetch lock') + try: + return self.get_downtime_info() + finally: + self.release_lock() diff --git a/module/smart_mgmt/utils.py b/module/smart_mgmt/utils.py new file mode 100644 index 000000000..e33946163 --- /dev/null +++ b/module/smart_mgmt/utils.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import re +from datetime import datetime +from typing import Optional + +from module.logger import logger + + +DOWNTIME_FORMAT_RE = re.compile( + r'(\d{4})-(\d{1,2})-(\d{1,2}) ' + r'(\d{1,2}):(\d{2})~(\d{1,2}):(\d{2})', +) + +def parse_downtime(downtime: str) -> Optional[datetime]: + """ + Parse downtime window string into end datetime. + + Args: + downtime (str): Window string in format 'YYYY-MM-DD HH:MM~HH:MM'. + + Returns: + Optional[datetime]: End datetime parsed from the window, + or None if no pattern matched. + """ + match = DOWNTIME_FORMAT_RE.search(downtime) + if not match: + logger.warning(f'Failed to parse downtime window: {downtime}') + return None + year, month, day = int(match.group(1)), int(match.group(2)), int(match.group(3)) + end_hour, end_minute = int(match.group(6)), int(match.group(7)) + end_dt = datetime(year, month, day, end_hour, end_minute) + logger.info(f'Parsed downtime window: end={end_dt:%Y-%m-%d %H:%M}') + return end_dt From 22ca2d054282bdf4652ec2d0cceae50856e7b22c Mon Sep 17 00:00:00 2001 From: positnuec <93694981+positnuec@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:25:55 +0800 Subject: [PATCH 14/20] Feat: clear exercise attempts based on downtime --- module/exercise/exercise.py | 77 ++++++++++++++++++++++++++----------- 1 file changed, 55 insertions(+), 22 deletions(-) diff --git a/module/exercise/exercise.py b/module/exercise/exercise.py index d68d3ae33..39b89612e 100644 --- a/module/exercise/exercise.py +++ b/module/exercise/exercise.py @@ -1,11 +1,11 @@ import datetime -from module.config.utils import get_server_last_update +from module.config.utils import get_server_next_update, get_server_last_update from module.exercise.assets import * from module.exercise.combat import ExerciseCombat from module.logger import logger from module.ocr.ocr import Digit, Ocr, OcrYuv from module.ui.page import page_exercise -from module.config.utils import get_server_next_update +from module.smart_mgmt.utils import parse_downtime class DatedDuration(Ocr): def __init__(self, buttons, lang='cnocr', letter=(255, 255, 255), threshold=128, alphabet='0123456789:IDS天日d', @@ -77,6 +77,7 @@ class Exercise(ExerciseCombat): opponent_change_count = 0 remain = 0 preserve = 0 + DOWNTIME_END_HOUR_THRESHOLD = 15 def _new_opponent(self): logger.info('New opponent') @@ -171,18 +172,6 @@ class Exercise(ExerciseCombat): self.config.set_record(Exercise_OpponentRefreshValue=0) return 0 - def _get_thursday_strategy(self, remain_time): - """ - Args: - remain_time(datetime.timedelta): - - Returns: - bool: if use Thursday strategy - """ - if 96 > remain_time >= 84 or 264 > remain_time >= 252: - return True - return False - def _get_exercise_reset_remain(self): """ Returns: @@ -206,7 +195,43 @@ class Exercise(ExerciseCombat): return preserve, admiral_interval - def _should_force_clear(self, remain_time, admiral_interval): + def _get_thursday_strategy(self, remain_hours): + """ + Args: + remain_hours(int): + + Returns: + bool: if use Thursday strategy + """ + if 96 > remain_hours >= 84 or 264 > remain_hours >= 252: + return True + return False + + def _get_downtime_strategy(self, now): + """ + Check smart_mgmt downtime window to decide whether to clear attempts. + + Returns: + bool: + True if today is maintenance day with end time at or after DOWNTIME_END_HOUR_THRESHOLD. + False if available but today is not maintenance day. + None if unavailable (disabled, non-CN server, window missing, or parse failed). + """ + if self.config.SERVER != 'cn': + return None + if not self.config.cross_get('DowntimeMgmt.DowntimeStrategy.ManageExercise', False): + return None + window = self.config.cross_get('DowntimeMgmt.Downtime.Window', None) + if not window: + return None + end_dt = parse_downtime(window) + if end_dt is None: + return None + if end_dt <= now: + return False + return end_dt.date() == now.date() and end_dt.hour >= self.DOWNTIME_END_HOUR_THRESHOLD + + def _should_force_clear(self, now, remain_time, admiral_interval): """Decide whether to clear all exercise attempts this run.""" if admiral_interval is None or not remain_time: return False @@ -214,9 +239,16 @@ class Exercise(ExerciseCombat): remain_hours = int(remain_time.total_seconds() // 3600) admiral_start, admiral_end = admiral_interval - if self._get_thursday_strategy(remain_hours): - logger.info('Reach Thursday, using all attempts') + downtime_strategy = self._get_downtime_strategy(now) + if downtime_strategy is True: + logger.info('Downtime today, using all attempts before downtime') return True + elif downtime_strategy is None: + # Fallback to thursday strategy when smart_mgmt is unavailable + if self._get_thursday_strategy(remain_hours): + logger.info('Reach Thursday, using all attempts before downtime') + return True + if admiral_start > remain_hours >= admiral_end: logger.info('Reach set time for admiral trial, using all attempts') return True @@ -226,11 +258,11 @@ class Exercise(ExerciseCombat): return False - def _should_delay(self, next_update, forced_clear): + def _should_delay(self, now, next_update, forced_clear): """Check whether to delay exercise to the configured time before next update.""" if forced_clear: return False - seconds_until_update = (next_update - datetime.datetime.now()).seconds + seconds_until_update = (next_update - now).seconds return seconds_until_update > 3600 * self.config.Exercise_DelayUntilHoursBeforeNextUpdate def _exercise(self, preserve): @@ -258,15 +290,17 @@ class Exercise(ExerciseCombat): logger.attr('Exercise_ExerciseStrategy', self.config.Exercise_ExerciseStrategy) self.preserve, admiral_interval = self._get_exercise_strategy() + now = datetime.datetime.now() remain_time = self._get_exercise_reset_remain() logger.info(f'Exercise period remain: {remain_time}') - forced_clear = self._should_force_clear(remain_time, admiral_interval) + forced_clear = self._should_force_clear(now, remain_time, admiral_interval) if forced_clear: self.preserve = 0 + logger.attr("exercise preserve", self.preserve) next_update = get_server_next_update(server_update) - should_delay = self._should_delay(next_update, forced_clear) + should_delay = self._should_delay(now, next_update, forced_clear) if should_delay: logger.warning(f'Exercise should run at {self.config.Exercise_DelayUntilHoursBeforeNextUpdate} ' f'hours before next update. Delay task to it.') @@ -280,7 +314,6 @@ class Exercise(ExerciseCombat): self.config.set_record(Exercise_OpponentRefreshValue=self.opponent_change_count) if self.remain <= self.preserve or self.opponent_change_count >= 5: next_run = next_update - datetime.timedelta(hours=self.config.Exercise_DelayUntilHoursBeforeNextUpdate) - now = datetime.datetime.now() if next_run >= now or should_delay: minutes_to_delay = int((next_run - now).total_seconds() / 60 + 1) self.config.task_delay(minute=minutes_to_delay) From 1c3d83e92d3fbefb976b4bf4377b2e9d206987d7 Mon Sep 17 00:00:00 2001 From: positnuec <93694981+positnuec@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:59:47 +0800 Subject: [PATCH 15/20] Feat: delay Daily to end of downtime --- config/template.json | 3 ++- module/config/argument/args.json | 4 +++ module/config/argument/argument.yaml | 1 + module/config/config_generated.py | 1 + module/config/i18n/en-US.json | 4 +++ module/config/i18n/ja-JP.json | 4 +++ module/config/i18n/zh-CN.json | 4 +++ module/config/i18n/zh-TW.json | 4 +++ module/daily/daily.py | 38 ++++++++++++++++++++++++++-- 9 files changed, 60 insertions(+), 3 deletions(-) diff --git a/config/template.json b/config/template.json index 5fefca2e0..e9b93414b 100644 --- a/config/template.json +++ b/config/template.json @@ -168,7 +168,8 @@ "Title": null }, "DowntimeStrategy": { - "ManageExercise": false + "ManageExercise": false, + "ManageDaily": false }, "Storage": { "Storage": {} diff --git a/module/config/argument/args.json b/module/config/argument/args.json index 78917b0a0..aae11af95 100644 --- a/module/config/argument/args.json +++ b/module/config/argument/args.json @@ -730,6 +730,10 @@ "ManageExercise": { "type": "checkbox", "value": false + }, + "ManageDaily": { + "type": "checkbox", + "value": false } }, "Storage": { diff --git a/module/config/argument/argument.yaml b/module/config/argument/argument.yaml index c618401eb..9afe2309d 100644 --- a/module/config/argument/argument.yaml +++ b/module/config/argument/argument.yaml @@ -162,6 +162,7 @@ Downtime: display: disabled DowntimeStrategy: ManageExercise: false + ManageDaily: false # ==================== Farm ==================== diff --git a/module/config/config_generated.py b/module/config/config_generated.py index c982b4b24..f24d6f3e1 100644 --- a/module/config/config_generated.py +++ b/module/config/config_generated.py @@ -82,6 +82,7 @@ class GeneratedConfig: # Group `DowntimeStrategy` DowntimeStrategy_ManageExercise = False + DowntimeStrategy_ManageDaily = False # Group `Campaign` Campaign_Name = '12-4' diff --git a/module/config/i18n/en-US.json b/module/config/i18n/en-US.json index fac610ea7..1cb5de83c 100644 --- a/module/config/i18n/en-US.json +++ b/module/config/i18n/en-US.json @@ -740,6 +740,10 @@ "ManageExercise": { "name": "Exercise", "help": "When the downtime end time is 15:00 or later, exercise strategy before downtime maintenance on that day will be replaced with 'clear exercise attempts'" + }, + "ManageDaily": { + "name": "Daily", + "help": "On downtime maintenance day, next run will be delayed to downtime end time to handle possible Emergency Module Development" } }, "Campaign": { diff --git a/module/config/i18n/ja-JP.json b/module/config/i18n/ja-JP.json index c45896959..cbbeb9a23 100644 --- a/module/config/i18n/ja-JP.json +++ b/module/config/i18n/ja-JP.json @@ -740,6 +740,10 @@ "ManageExercise": { "name": "演習", "help": "メンテナンス終了時刻が15:00以降の場合、当日のメンテナンス開始前の演習戦略が'演習回数をクリア'に置き換えられます" + }, + "ManageDaily": { + "name": "デイリー", + "help": "メンテナンス当日の次回実行時間をメンテナンス終了時刻に設定し、限時兵装訓練に対応します" } }, "Campaign": { diff --git a/module/config/i18n/zh-CN.json b/module/config/i18n/zh-CN.json index 0d71abbc2..88b7bad15 100644 --- a/module/config/i18n/zh-CN.json +++ b/module/config/i18n/zh-CN.json @@ -740,6 +740,10 @@ "ManageExercise": { "name": "演习", "help": "当停服结束时间为15:00及之后时,当天停服维护前演习策略将替换为'清空演习次数'" + }, + "ManageDaily": { + "name": "每日任务", + "help": "停服维护当天的下次运行时间将设为维护结束时,以处理可能的限时兵装训练" } }, "Campaign": { diff --git a/module/config/i18n/zh-TW.json b/module/config/i18n/zh-TW.json index 27f94b30c..3b1e0f6ab 100644 --- a/module/config/i18n/zh-TW.json +++ b/module/config/i18n/zh-TW.json @@ -740,6 +740,10 @@ "ManageExercise": { "name": "演習", "help": "當停服結束時間為15:00及之後時,當天停服維護前演習策略將替換為'清空演習次數'" + }, + "ManageDaily": { + "name": "每日任務", + "help": "停服維護當天的下次運行時間將設為維護結束時間,以處理可能的限時兵裝訓練" } }, "Campaign": { diff --git a/module/daily/daily.py b/module/daily/daily.py index 66b2d2c2d..a43d203b6 100644 --- a/module/daily/daily.py +++ b/module/daily/daily.py @@ -1,3 +1,5 @@ +from datetime import datetime + import numpy as np import module.config.server as server @@ -7,6 +9,7 @@ from module.combat.combat import Combat from module.daily.assets import * from module.logger import logger from module.ocr.ocr import Digit +from module.smart_mgmt.utils import parse_downtime from module.ui.assets import BACK_ARROW, DAILY_CHECK from module.ui.page import page_campaign_menu, page_daily @@ -335,13 +338,44 @@ class Daily(Combat): logger.info('Daily clear complete.') break + def _get_downtime_end(self): + """ + Get downtime end datetime on maintenance day. + + Returns: + datetime.datetime: End datetime if today is maintenance day and + maintenance has not ended yet, or None if smart_mgmt unavailable, + today is not maintenance day, or maintenance already ended. + """ + if self.config.SERVER != 'cn': + return None + if not self.config.cross_get('DowntimeMgmt.DowntimeStrategy.ManageDaily', False): + return None + window = self.config.cross_get('DowntimeMgmt.Downtime.Window', None) + if not window: + return None + end_dt = parse_downtime(window) + if end_dt is None: + return None + now = datetime.now() + if end_dt.date() != now.date(): + return None + if end_dt <= now: + return None + return end_dt + def run(self): """ Pages: in: Any page out: page_daily """ + # Cannot stay in page_daily, because order is disordered. self.daily_run() - # Cannot stay in page_daily, because order is disordered. - self.config.task_delay(server_update=True) + end_dt = self._get_downtime_end() + if end_dt is not None: + logger.info('Downtime today, delay to end of downtime') + self.config.task_delay(target=end_dt) + else: + self.config.task_delay(server_update=True) From b3ccadf38e1b5c2d5c64203849ac95d3b4abe5a0 Mon Sep 17 00:00:00 2001 From: positnuec <93694981+positnuec@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:28:24 +0800 Subject: [PATCH 16/20] Fix: medal shop scroll keeps dragging at bottom --- module/shop/shop_medal.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/module/shop/shop_medal.py b/module/shop/shop_medal.py index efed69541..71bba4964 100644 --- a/module/shop/shop_medal.py +++ b/module/shop/shop_medal.py @@ -46,7 +46,9 @@ MEDAL_SHOP_SCROLL_250814 = ShopAdaptiveScroll( background=3, name="MEDAL_SHOP_SCROLL_250814" ) -MEDAL_SHOP_SCROLL_250814.drag_threshold = 0.1 +# A little bit larger than edge_threshold +# to prevent continuous scrolling attempts while already at the bottom +MEDAL_SHOP_SCROLL_250814.drag_threshold = 0.13 # A little bit larger than 0.1 to handle bottom MEDAL_SHOP_SCROLL_250814.edge_threshold = 0.12 From 189645b97ad06a1f982ebd23110aad4a81fa8312 Mon Sep 17 00:00:00 2001 From: positnuec <93694981+positnuec@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:48:57 +0800 Subject: [PATCH 17/20] Fix: handle_exp_info signature mismatch causing TypeError in auto_search --- campaign/campaign_main/campaign_2_base.py | 4 ++-- campaign/campaign_main/campaign_3_base.py | 4 ++-- campaign/event_20220224_cn/campaign_base.py | 4 ++-- campaign/event_20220428_cn/campaign_base.py | 4 ++-- campaign/event_20220915_cn/campaign_base.py | 4 ++-- campaign/event_20221222_cn/campaign_base.py | 4 ++-- campaign/event_20230914_cn/campaign_base.py | 4 ++-- campaign/event_20240815_cn/campaign_base.py | 4 ++-- campaign/event_20240912_cn/campaign_base.py | 4 ++-- campaign/event_20250424_cn/campaign_base.py | 4 ++-- campaign/event_20250724_cn/campaign_base.py | 4 ++-- campaign/event_20250912_cn/campaign_base.py | 4 ++-- campaign/war_archives_20220224_cn/campaign_base.py | 4 ++-- campaign/war_archives_20220428_cn/campaign_base.py | 4 ++-- campaign/war_archives_20220915_cn/campaign_base.py | 4 ++-- campaign/war_archives_20221222_cn/campaign_base.py | 4 ++-- 16 files changed, 32 insertions(+), 32 deletions(-) diff --git a/campaign/campaign_main/campaign_2_base.py b/campaign/campaign_main/campaign_2_base.py index 5f1565154..ac7382866 100644 --- a/campaign/campaign_main/campaign_2_base.py +++ b/campaign/campaign_main/campaign_2_base.py @@ -3,8 +3,8 @@ from module.ui.page import page_campaign class CampaignBase(CampaignBase_): - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background of Main Chapter 2 hits EXP_INFO_B if self.ui_page_appear(page_campaign): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) diff --git a/campaign/campaign_main/campaign_3_base.py b/campaign/campaign_main/campaign_3_base.py index 1567e3c6b..823f05f43 100644 --- a/campaign/campaign_main/campaign_3_base.py +++ b/campaign/campaign_main/campaign_3_base.py @@ -4,8 +4,8 @@ from module.ui.page import page_campaign class CampaignBase(CampaignBase_): - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background of Main Chapter 3 hits EXP_INFO_B if self.ui_page_appear(page_campaign): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) diff --git a/campaign/event_20220224_cn/campaign_base.py b/campaign/event_20220224_cn/campaign_base.py index c874590eb..47bda4bcd 100644 --- a/campaign/event_20220224_cn/campaign_base.py +++ b/campaign/event_20220224_cn/campaign_base.py @@ -8,8 +8,8 @@ class CampaignBase(CampaignBase_): self.config.MAP_SIREN_TEMPLATE = ['SS'] self.config.MAP_HAS_SIREN = True - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background hits EXP_INFO_B if self.ui_page_appear(page_event): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) diff --git a/campaign/event_20220428_cn/campaign_base.py b/campaign/event_20220428_cn/campaign_base.py index cf328723b..c3efeab9a 100644 --- a/campaign/event_20220428_cn/campaign_base.py +++ b/campaign/event_20220428_cn/campaign_base.py @@ -3,8 +3,8 @@ from module.ui.page import page_event class CampaignBase(CampaignBase_): - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background of chapter A hits EXP_INFO_B if self.ui_page_appear(page_event): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) diff --git a/campaign/event_20220915_cn/campaign_base.py b/campaign/event_20220915_cn/campaign_base.py index fc5e5f21b..8e7efbe07 100644 --- a/campaign/event_20220915_cn/campaign_base.py +++ b/campaign/event_20220915_cn/campaign_base.py @@ -13,8 +13,8 @@ class CampaignBase(CampaignBase_): MAP_ENEMY_SEARCHING.color, get_color(self.device.image, MAP_ENEMY_SEARCHING.area) ) > self.MAP_ENEMY_SEARCHING_OVERLAY_TRANSPARENCY_THRESHOLD - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background hits EXP_INFO_B if self.ui_page_appear(page_event): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) diff --git a/campaign/event_20221222_cn/campaign_base.py b/campaign/event_20221222_cn/campaign_base.py index 6e2175131..04a979e35 100644 --- a/campaign/event_20221222_cn/campaign_base.py +++ b/campaign/event_20221222_cn/campaign_base.py @@ -3,11 +3,11 @@ from module.ui.page import page_event class CampaignBase(CampaignBase_): - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background of hits EXP_INFO_B if self.ui_page_appear(page_event): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) def map_data_init(self, map_): super().map_data_init(map_) diff --git a/campaign/event_20230914_cn/campaign_base.py b/campaign/event_20230914_cn/campaign_base.py index e1522a3f5..83f9dc494 100644 --- a/campaign/event_20230914_cn/campaign_base.py +++ b/campaign/event_20230914_cn/campaign_base.py @@ -4,8 +4,8 @@ from module.campaign.campaign_base import CampaignBase as CampaignBase_ class CampaignBase(CampaignBase_): - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background hits EXP_INFO_B if self.ui_page_appear(page_event): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) diff --git a/campaign/event_20240815_cn/campaign_base.py b/campaign/event_20240815_cn/campaign_base.py index 415e17f00..7c5121b0c 100644 --- a/campaign/event_20240815_cn/campaign_base.py +++ b/campaign/event_20240815_cn/campaign_base.py @@ -84,8 +84,8 @@ class CampaignBase(CampaignBase_): return True return super().handle_campaign_ui_additional() - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background hits EXP_INFO_B if self.ui_page_appear(page_event): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) diff --git a/campaign/event_20240912_cn/campaign_base.py b/campaign/event_20240912_cn/campaign_base.py index 3b0e28b0b..63eaa146a 100644 --- a/campaign/event_20240912_cn/campaign_base.py +++ b/campaign/event_20240912_cn/campaign_base.py @@ -40,8 +40,8 @@ class CampaignBase(CampaignBase_): ) return super().campaign_set_chapter_20241219(*args, **kwargs) - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background of hits EXP_INFO_B if self.ui_page_appear(page_event): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) diff --git a/campaign/event_20250424_cn/campaign_base.py b/campaign/event_20250424_cn/campaign_base.py index c7a4d7a78..ebe10f736 100644 --- a/campaign/event_20250424_cn/campaign_base.py +++ b/campaign/event_20250424_cn/campaign_base.py @@ -5,11 +5,11 @@ from module.ui.page import page_campaign_menu, page_event class CampaignBase(CampaignBase_): - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background of hits EXP_INFO_B if self.ui_page_appear(page_event): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) def ui_goto_event(self): if self.appear(EVENT_20250424_PT_ICON, offset=(20, 20)) and self.ui_page_appear(page_event): diff --git a/campaign/event_20250724_cn/campaign_base.py b/campaign/event_20250724_cn/campaign_base.py index ea0bf47cb..ce74ffc28 100644 --- a/campaign/event_20250724_cn/campaign_base.py +++ b/campaign/event_20250724_cn/campaign_base.py @@ -51,11 +51,11 @@ class CampaignBaseTS(CampaignBaseT): return True return super().campaign_set_chapter_20241219(chapter, stage, mode) - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Extra confirm button in chapter TS if self.appear_then_click(ALCHEMIST_MATERIAL_CONFIRM, offset=(20, 20), interval=1): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) def get_map_clear_percentage(self): if AUTO_SEARCH.appear(main=self): diff --git a/campaign/event_20250912_cn/campaign_base.py b/campaign/event_20250912_cn/campaign_base.py index e1522a3f5..83f9dc494 100644 --- a/campaign/event_20250912_cn/campaign_base.py +++ b/campaign/event_20250912_cn/campaign_base.py @@ -4,8 +4,8 @@ from module.campaign.campaign_base import CampaignBase as CampaignBase_ class CampaignBase(CampaignBase_): - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background hits EXP_INFO_B if self.ui_page_appear(page_event): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) diff --git a/campaign/war_archives_20220224_cn/campaign_base.py b/campaign/war_archives_20220224_cn/campaign_base.py index b8b32ebb9..d194dea25 100644 --- a/campaign/war_archives_20220224_cn/campaign_base.py +++ b/campaign/war_archives_20220224_cn/campaign_base.py @@ -8,8 +8,8 @@ class CampaignBase(CampaignBase_): self.config.MAP_SIREN_TEMPLATE = ['SS'] self.config.MAP_HAS_SIREN = True - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background hits EXP_INFO_B if self.ui_page_appear(page_event): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) diff --git a/campaign/war_archives_20220428_cn/campaign_base.py b/campaign/war_archives_20220428_cn/campaign_base.py index ac57105c1..69f9caee5 100644 --- a/campaign/war_archives_20220428_cn/campaign_base.py +++ b/campaign/war_archives_20220428_cn/campaign_base.py @@ -3,8 +3,8 @@ from module.ui.page import page_event class CampaignBase(CampaignBase_): - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background of chapter A hits EXP_INFO_B if self.ui_page_appear(page_event): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) diff --git a/campaign/war_archives_20220915_cn/campaign_base.py b/campaign/war_archives_20220915_cn/campaign_base.py index 2c904a5a8..93ca2e9ec 100644 --- a/campaign/war_archives_20220915_cn/campaign_base.py +++ b/campaign/war_archives_20220915_cn/campaign_base.py @@ -13,8 +13,8 @@ class CampaignBase(CampaignBase_): MAP_ENEMY_SEARCHING.color, get_color(self.device.image, MAP_ENEMY_SEARCHING.area) ) > self.MAP_ENEMY_SEARCHING_OVERLAY_TRANSPARENCY_THRESHOLD - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background hits EXP_INFO_B if self.ui_page_appear(page_event): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) diff --git a/campaign/war_archives_20221222_cn/campaign_base.py b/campaign/war_archives_20221222_cn/campaign_base.py index 42e0ca3f9..487d13681 100644 --- a/campaign/war_archives_20221222_cn/campaign_base.py +++ b/campaign/war_archives_20221222_cn/campaign_base.py @@ -3,11 +3,11 @@ from module.ui.page import page_event class CampaignBase(CampaignBase_): - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background of hits EXP_INFO_B if self.ui_page_appear(page_event): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) def map_data_init(self, map_): super().map_data_init(map_) From fbe037322af3696444e949f6b0a22feecb86acd6 Mon Sep 17 00:00:00 2001 From: positnuec <93694981+positnuec@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:20:43 +0800 Subject: [PATCH 18/20] Refactor: replace independent scheduled DowntimeFetch task with on-demand auto-fetch --- alas.py | 24 +- config/template.json | 49 ++-- dev_tools/downtime_notice_crawler.py | 301 --------------------- module/config/argument/args.json | 131 ++++----- module/config/argument/argument.yaml | 8 +- module/config/argument/menu.json | 10 +- module/config/argument/override.yaml | 8 - module/config/argument/task.yaml | 15 +- module/config/config_generated.py | 5 + module/config/config_manual.py | 1 - module/config/i18n/en-US.json | 44 +-- module/config/i18n/ja-JP.json | 44 +-- module/config/i18n/zh-CN.json | 44 +-- module/config/i18n/zh-TW.json | 44 +-- module/daily/daily.py | 11 +- module/exercise/exercise.py | 22 +- module/smart_mgmt/downtime_fetch.py | 112 +++++--- module/smart_mgmt/downtime_fetcher.py | 74 +++--- module/smart_mgmt/fetcher_test.py | 370 ++++++++++++++++++++++++++ module/smart_mgmt/utils.py | 34 --- module/submodule/utils.py | 1 + 21 files changed, 715 insertions(+), 637 deletions(-) delete mode 100644 dev_tools/downtime_notice_crawler.py create mode 100644 module/smart_mgmt/fetcher_test.py delete mode 100644 module/smart_mgmt/utils.py diff --git a/alas.py b/alas.py index 26ae0cd94..bd6da18f4 100644 --- a/alas.py +++ b/alas.py @@ -15,7 +15,6 @@ from module.logger import logger from module.notify import handle_notify RESTART_SENSITIVE_TASKS = ['OpsiObscure', 'OpsiAbyssal', 'OpsiCrossMonth'] -DEVICE_FREE_TASKS = 'DowntimeFetch' class AzurLaneAutoScript: @@ -199,10 +198,6 @@ class AzurLaneAutoScript: LoginHandler(self.config, device=self.device).app_start() UI(self.config, device=self.device).ui_goto_main() - def downtime_fetch(self): - from module.smart_mgmt.downtime_fetch import DowntimeFetch - DowntimeFetch(config=self.config).run() - def research(self): from module.research.research import RewardResearch RewardResearch(config=self.config, device=self.device).run() @@ -465,6 +460,10 @@ class AzurLaneAutoScript: from module.daemon.benchmark import run_benchmark run_benchmark(config=self.config) + def downtime_fetch(self): + from module.smart_mgmt.downtime_fetch import run_downtime_fetch + run_downtime_fetch(config=self.config) + def game_manager(self): from module.daemon.game_manager import GameManager GameManager(config=self.config, device=self.device, task="GameManager").run() @@ -575,8 +574,9 @@ class AzurLaneAutoScript: self.config.task_call('Restart') # Get task task = self.get_next_task() - # Network-only tasks skip device init and screenshot - device_free_task = task == DEVICE_FREE_TASKS + # Init device and change server + _ = self.device + self.device.config = self.config # Skip first restart if task == 'Restart' and self.is_first_task: logger.info('Skip task `Restart` at scheduler start') @@ -586,14 +586,10 @@ class AzurLaneAutoScript: # Run logger.info(f'Scheduler: Start task `{task}`') - # Init device and change server - if not device_free_task: - _ = self.device - self.device.config = self.config - self.device.stuck_record_clear() - self.device.click_record_clear() + self.device.stuck_record_clear() + self.device.click_record_clear() logger.hr(task, level=0) - success = self.run(inflection.underscore(task), skip_first_screenshot=device_free_task) + success = self.run(inflection.underscore(task)) logger.info(f'Scheduler: End task `{task}`') self.is_first_task = False diff --git a/config/template.json b/config/template.json index e9b93414b..9cc3be3be 100644 --- a/config/template.json +++ b/config/template.json @@ -136,6 +136,21 @@ "Storage": {} } }, + "DowntimeMgmt": { + "Downtime": { + "Window": null, + "Title": null + }, + "DowntimeStrategy": { + "AutoFetch": false, + "ManageExercise": false, + "ExerciseClearTime": "15:00:00", + "ManageDaily": false + }, + "Storage": { + "Storage": {} + } + }, "Restart": { "Scheduler": { "Enable": true, @@ -149,32 +164,6 @@ "Storage": {} } }, - "DowntimeFetch": { - "Scheduler": { - "Enable": false, - "NextRun": "2020-01-01 00:00:00", - "Command": "DowntimeFetch", - "SuccessInterval": 30, - "FailureInterval": 30, - "ServerUpdate": "22:30" - }, - "Storage": { - "Storage": {} - } - }, - "DowntimeMgmt": { - "Downtime": { - "Window": null, - "Title": null - }, - "DowntimeStrategy": { - "ManageExercise": false, - "ManageDaily": false - }, - "Storage": { - "Storage": {} - } - }, "Main": { "Scheduler": { "Enable": false, @@ -2379,6 +2368,14 @@ "Storage": {} } }, + "DowntimeFetch": { + "DowntimeFetch": { + "FetcherTest": false + }, + "Storage": { + "Storage": {} + } + }, "AzurLaneUncensored": { "AzurLaneUncensored": { "Repository": "https://gitee.com/LmeSzinc/AzurLaneUncensored" diff --git a/dev_tools/downtime_notice_crawler.py b/dev_tools/downtime_notice_crawler.py deleted file mode 100644 index ca9dfbd54..000000000 --- a/dev_tools/downtime_notice_crawler.py +++ /dev/null @@ -1,301 +0,0 @@ -""" -碧蓝航线停服维护公告爬取工具 - -从B站碧蓝航线官方专栏合集抓取最新维护公告,输出停服时间段。 - -默认项 - --mid 233114659(碧蓝航线B站官方号UID) - --collection-name 《碧蓝航线》维护公告 - --cache 公告缓存文件,默认 maintenance/_cache.jsonl,位于脚本同级目录。 - --state 合集状态文件,默认 maintenance/state.json,位于脚本同级目录。 - -命令 - fetch 爬取:拉取专栏合集数据,写入缓存。 - parse 解析:从缓存读取并解析公告,向 stdout 输出纯文本停服时间段,如:2026-06-20 14:00~18:00 - 解析失败输出空行,并返回退出码 2。 - run 完整流程测试:拉取最新公告并解析停服维护信息,不依赖缓存。 - -示例 - .\\toolkit\\python.exe dev_tools/bilibili_maintenance.py fetch --force - .\\toolkit\\python.exe dev_tools/bilibili_maintenance.py parse - .\\toolkit\\python.exe dev_tools/bilibili_maintenance.py run --verbose -""" -from __future__ import annotations - -import argparse -import json -import logging -import re -import sys -from dataclasses import dataclass -from datetime import datetime, timezone, timedelta -from pathlib import Path -from typing import Optional - -import requests - -SCRIPT_DIR = Path(__file__).resolve().parent -DEFAULT_MID = 233114659 -DEFAULT_CACHE = SCRIPT_DIR / 'maintenance' / '_cache.jsonl' -DEFAULT_STATE = SCRIPT_DIR / 'maintenance' / 'state.json' -DEFAULT_COLLECTION_NAME = '《碧蓝航线》维护公告' - -ARTICLE_LISTS_API = 'https://api.bilibili.com/x/article/up/lists' -ARTICLE_COLLECTION_API = 'https://api.bilibili.com/x/article/list/web/articles' -CN_TZ = timezone(timedelta(hours=8)) - -WS = r'[\s ]*' -WINDOW_RE = re.compile( - r'司令部将于' + WS + - r'(\d{1,2})月(\d{1,2})日' + WS + - r'(\d{1,2})[::](\d{2})' + WS + - r'[~~至到—\-]+' + WS + - r'(\d{1,2})[::](\d{2})', -) -DURATION_RE = re.compile(r'为期' + WS + r'(\d+)' + WS + r'个?小时') -TITLE_TIME_RE = re.compile(r'(\d{1,2})月(\d{1,2})日' + WS + r'(\d{1,2})[::](\d{2})') - -logger = logging.getLogger('bilibili_maintenance') - - -class FetchError(RuntimeError): - pass - - -@dataclass -class MaintenanceInfo: - window: str - duration_hours: int - - -@dataclass -class CollectionMeta: - collection_id: int - collection_name: str - articles_count: int - update_time: int - - -def configure_stdio() -> None: - if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8') - - -def session_for(mid: int) -> requests.Session: - s = requests.Session() - s.headers.update({ - 'User-Agent': ( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' - '(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36' - ), - 'Referer': f'https://space.bilibili.com/{mid}/article', - }) - return s - - -def get_json(session: requests.Session, url: str, params: dict, timeout: float) -> dict: - response = session.get(url, params=params, timeout=timeout) - response.raise_for_status() - payload = response.json() - if payload['code'] != 0: - raise FetchError(payload.get('message') or payload.get('msg') or 'unknown error') - return payload - - -def find_collection(session: requests.Session, mid: int, name: str, timeout: float) -> tuple[CollectionMeta, dict]: - payload = get_json(session, ARTICLE_LISTS_API, {'mid': str(mid), 'sort': 0}, timeout) - for item in payload['data']['lists']: - if item['name'] == name: - return CollectionMeta(collection_id=int(item['id']), collection_name=item['name'], articles_count=int(item['articles_count']), update_time=int(item['update_time'])), payload - raise FetchError(f'collection not found: mid={mid} name={name!r}') - - -def normalize_article(article: dict, meta: CollectionMeta) -> dict: - cvid = str(article['id']) - pub_ts = int(article['publish_time']) - return { - 'pub_ts': pub_ts, - 'pub_time': datetime.fromtimestamp(pub_ts, tz=CN_TZ).isoformat(), - 'title': article['title'], - 'text': article['summary'], - 'url': f'https://www.bilibili.com/read/cv{cvid}', - 'collection_id': meta.collection_id, - 'collection_name': meta.collection_name, - } - - -def load_state(path: Path) -> dict: - if not path.exists(): - return {} - return json.loads(path.read_text(encoding='utf-8')) - - -def load_jsonl(path: Path) -> list[dict]: - if not path.exists(): - return [] - return [json.loads(line) for line in path.read_text(encoding='utf-8').splitlines() if line.strip()] - - -def persist_fetch(cache_path: Path, state_path: Path, records: list[dict], state: dict) -> None: - cache_path.parent.mkdir(parents=True, exist_ok=True) - cache_content = ''.join(json.dumps(record, ensure_ascii=False) + '\n' for record in records) - state_content = json.dumps(state, ensure_ascii=False, indent=2) - cache_tmp = cache_path.with_suffix(cache_path.suffix + '.tmp') - state_tmp = state_path.with_suffix(state_path.suffix + '.tmp') - cache_tmp.write_text(cache_content, encoding='utf-8') - state_tmp.write_text(state_content, encoding='utf-8') - cache_tmp.replace(cache_path) - state_tmp.replace(state_path) - - -def clean_text(text: str) -> str: - return text.replace('[图片]', '').replace('\u3000', ' ').strip() - - -def infer_year(published: datetime, month: int, day: int) -> int: - year = published.year - delta = (datetime(year, month, day, tzinfo=CN_TZ).date() - published.date()).days - if delta < -60: - year += 1 - elif delta > 300: - year -= 1 - return year - - -def _build_window_dt(published: datetime, sm: int, sd: int, sh: int, smin: int, eh: int, emin: int) -> tuple[datetime, datetime]: - year = infer_year(published, sm, sd) - start = datetime(year, sm, sd, sh, smin, tzinfo=CN_TZ) - end = datetime(year, sm, sd, eh, emin, tzinfo=CN_TZ) - return start, end - - -def _hours_between(start: datetime, end: datetime) -> int: - return round((end - start).total_seconds() / 3600) - - -def parse_maintenance(text: str, pub_ts: int, title: str) -> Optional[MaintenanceInfo]: - published = datetime.fromtimestamp(pub_ts, tz=CN_TZ) - body = clean_text(text) - window_match = WINDOW_RE.search(body) - duration_match = DURATION_RE.search(body) - - if window_match: - sm = int(window_match.group(1)) - sd = int(window_match.group(2)) - sh = int(window_match.group(3)) - smin = int(window_match.group(4)) - eh = int(window_match.group(5)) - emin = int(window_match.group(6)) - start, end = _build_window_dt(published, sm, sd, sh, smin, eh, emin) - hours = int(duration_match.group(1)) if duration_match else _hours_between(start, end) - return MaintenanceInfo(window=f'{start:%Y-%m-%d} {start:%H:%M}~{end:%H:%M}', duration_hours=hours) - - if duration_match: - title_match = TITLE_TIME_RE.search(title) - if title_match: - sm = int(title_match.group(1)) - sd = int(title_match.group(2)) - sh = int(title_match.group(3)) - smin = int(title_match.group(4)) - hours = int(duration_match.group(1)) - start, _ = _build_window_dt(published, sm, sd, sh, smin, sh, smin) - end = start + timedelta(hours=hours) - logger.warning('WINDOW_RE not matched; synthesized window from TITLE_TIME_RE and DURATION_RE (%02d-%02d %02d:%02d, duration %d h).', sm, sd, sh, smin, hours) - return MaintenanceInfo(window=f'{start:%Y-%m-%d} {start:%H:%M}~{end:%H:%M}', duration_hours=hours) - - logger.warning('Failed to parse maintenance window from article text.') - return None - - -def latest_record(records: list[dict]) -> dict: - return max(records, key=lambda r: r['pub_ts']) - - -def parse_records(records: list[dict]) -> Optional[MaintenanceInfo]: - if not records: - return None - record = latest_record(records) - return parse_maintenance(record['text'], record['pub_ts'], record['title']) - - -def fetch_articles(cache_path: Path, state_path: Path, mid: int, collection_name: str, timeout: float, force: bool, verbose: bool) -> tuple[list[dict], bool]: - state = load_state(state_path) - session = session_for(mid) - meta, lists_payload = find_collection(session, mid, collection_name, timeout) - - if verbose: - logger.info('%s', json.dumps(lists_payload, ensure_ascii=False, indent=2)) - - if not force and state.get('articles_count') == meta.articles_count and state.get('update_time') == meta.update_time: - logger.info('skip: count=%s update_time=%s', meta.articles_count, meta.update_time) - return load_jsonl(cache_path), False - - payload = get_json(session, ARTICLE_COLLECTION_API, {'id': str(meta.collection_id)}, timeout) - records = [normalize_article(article, meta) for article in payload['data']['articles']] - persist_fetch(cache_path, state_path, records, {'last_run_at': datetime.now(tz=CN_TZ).isoformat(timespec='seconds'), 'collection_id': meta.collection_id, 'articles_count': meta.articles_count, 'update_time': meta.update_time}) - logger.info('updated: count=%s update_time=%s records=%s', meta.articles_count, meta.update_time, len(records)) - return records, True - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog='bilibili_maintenance.py', description='碧蓝航线停服维护公告爬取工具') - parser.add_argument('command', choices=['fetch', 'parse', 'run'], help='fetch: 拉取最新停服维护公告 | parse: 基于缓存解析停服时间 | run: 拉取并解析最新公告') - parser.add_argument('--cache', type=Path, default=DEFAULT_CACHE, help=f'公告缓存文件路径(默认 {DEFAULT_CACHE})') - parser.add_argument('--state', type=Path, default=DEFAULT_STATE, help=f'合集状态文件路径(默认 {DEFAULT_STATE})') - parser.add_argument('--mid', type=int, default=DEFAULT_MID, help=f'B站UP主UID(默认 {DEFAULT_MID})') - parser.add_argument('--collection-name', default=DEFAULT_COLLECTION_NAME, help=f'专栏合集名称,须与B站合集页标题一致(默认 {DEFAULT_COLLECTION_NAME!r})') - parser.add_argument('--timeout', type=float, default=30.0, help='网络请求超时秒数(默认 30)') - parser.add_argument('--force', action='store_true', help='强制重新拉取并刷新缓存数据') - parser.add_argument('--verbose', action='store_true', help='启用调试日志,输出 API 原始响应') - return parser - - -def cmd_fetch(args: argparse.Namespace) -> int: - try: - fetch_articles(args.cache, args.state, args.mid, args.collection_name, args.timeout, args.force, args.verbose) - except (FetchError, requests.RequestException) as exc: - logger.error('fetch failed: %s', exc) - return 1 - return 0 - - -def cmd_parse(args: argparse.Namespace) -> int: - info = parse_records(load_jsonl(args.cache)) - if info: - print(info.window) - return 0 - print() - return 2 - - -def cmd_run(args: argparse.Namespace) -> int: - try: - records, _ = fetch_articles(args.cache, args.state, args.mid, args.collection_name, args.timeout, force=True, verbose=args.verbose) - except (FetchError, requests.RequestException) as exc: - logger.error('fetch failed: %s', exc) - return 1 - info = parse_records(records) - if info: - print(info.window) - return 0 - print() - return 2 - - -def main() -> int: - configure_stdio() - logging.basicConfig(level=logging.WARNING, format='%(levelname)s: %(message)s') - args = build_parser().parse_args() - if args.verbose: - logging.getLogger().setLevel(logging.INFO) - if args.command == 'fetch': - return cmd_fetch(args) - if args.command == 'parse': - return cmd_parse(args) - if args.command == 'run': - return cmd_run(args) - return 1 - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/module/config/argument/args.json b/module/config/argument/args.json index aae11af95..2ad3f1aee 100644 --- a/module/config/argument/args.json +++ b/module/config/argument/args.json @@ -622,6 +622,45 @@ } } }, + "DowntimeMgmt": { + "Downtime": { + "Window": { + "type": "input", + "value": "" + }, + "Title": { + "type": "input", + "value": "", + "display": "disabled" + } + }, + "DowntimeStrategy": { + "AutoFetch": { + "type": "checkbox", + "value": false + }, + "ManageExercise": { + "type": "checkbox", + "value": false + }, + "ExerciseClearTime": { + "type": "input", + "value": "15:00:00" + }, + "ManageDaily": { + "type": "checkbox", + "value": false + } + }, + "Storage": { + "Storage": { + "type": "storage", + "value": {}, + "valuetype": "ignore", + "display": "disabled" + } + } + }, "Restart": { "Scheduler": { "Enable": { @@ -669,82 +708,6 @@ } } }, - "DowntimeFetch": { - "Scheduler": { - "Enable": { - "type": "checkbox", - "value": false, - "option": [ - true, - false - ] - }, - "NextRun": { - "type": "datetime", - "value": "2020-01-01 00:00:00", - "validate": "datetime" - }, - "Command": { - "type": "input", - "value": "DowntimeFetch", - "display": "hide" - }, - "SuccessInterval": { - "type": "input", - "value": 30, - "display": "hide" - }, - "FailureInterval": { - "type": "input", - "value": 30, - "display": "hide" - }, - "ServerUpdate": { - "type": "input", - "value": "22:30", - "display": "hide" - } - }, - "Storage": { - "Storage": { - "type": "storage", - "value": {}, - "valuetype": "ignore", - "display": "disabled" - } - } - }, - "DowntimeMgmt": { - "Downtime": { - "Window": { - "type": "input", - "value": "" - }, - "Title": { - "type": "input", - "value": "", - "display": "disabled" - } - }, - "DowntimeStrategy": { - "ManageExercise": { - "type": "checkbox", - "value": false - }, - "ManageDaily": { - "type": "checkbox", - "value": false - } - }, - "Storage": { - "Storage": { - "type": "storage", - "value": {}, - "valuetype": "ignore", - "display": "disabled" - } - } - }, "Main": { "Scheduler": { "Enable": { @@ -14060,6 +14023,22 @@ } } }, + "DowntimeFetch": { + "DowntimeFetch": { + "FetcherTest": { + "type": "checkbox", + "value": false + } + }, + "Storage": { + "Storage": { + "type": "storage", + "value": {}, + "valuetype": "ignore", + "display": "disabled" + } + } + }, "AzurLaneUncensored": { "AzurLaneUncensored": { "Repository": { diff --git a/module/config/argument/argument.yaml b/module/config/argument/argument.yaml index 9afe2309d..7cc13060c 100644 --- a/module/config/argument/argument.yaml +++ b/module/config/argument/argument.yaml @@ -152,16 +152,15 @@ OldRetire: RetireAmount: value: retire_all option: [ retire_all, retire_10 ] - -# ==================== SmartMgmt ==================== - Downtime: Window: '' Title: value: '' display: disabled DowntimeStrategy: + AutoFetch: false ManageExercise: false + ExerciseClearTime: '15:00:00' ManageDaily: false # ==================== Farm ==================== @@ -893,6 +892,7 @@ PrivateQuarters: TargetShip: value: anchorage option: [ anchorage, noshiro, sirius, new_jersey, taihou, aegir, nakhimov ] + # ==================== Daily ==================== Daily: @@ -1095,6 +1095,8 @@ AzurLaneUncensored: display: disabled GameManager: AutoRestart: true +DowntimeFetch: + FetcherTest: false # ==================== Dashboard ==================== Oil: diff --git a/module/config/argument/menu.json b/module/config/argument/menu.json index f9cb0a1d7..30d9f8919 100644 --- a/module/config/argument/menu.json +++ b/module/config/argument/menu.json @@ -5,17 +5,10 @@ "tasks": [ "Alas", "General", + "DowntimeMgmt", "Restart" ] }, - "SmartMgmt": { - "menu": "collapse", - "page": "setting", - "tasks": [ - "DowntimeFetch", - "DowntimeMgmt" - ] - }, "Farm": { "menu": "collapse", "page": "setting", @@ -115,6 +108,7 @@ "BoxDisassemble", "IslandPearl", "Benchmark", + "DowntimeFetch", "AzurLaneUncensored", "GameManager" ] diff --git a/module/config/argument/override.yaml b/module/config/argument/override.yaml index a38f435db..e88d9d3dc 100644 --- a/module/config/argument/override.yaml +++ b/module/config/argument/override.yaml @@ -15,14 +15,6 @@ Restart: FailureInterval: 0 ServerUpdate: 00:00 -# ==================== SmartMgmt ==================== - -DowntimeFetch: - Scheduler: - SuccessInterval: 30 - FailureInterval: 30 - ServerUpdate: "22:30" - # ==================== Farm ==================== Main: diff --git a/module/config/argument/task.yaml b/module/config/argument/task.yaml index ab18f111a..43324d95c 100644 --- a/module/config/argument/task.yaml +++ b/module/config/argument/task.yaml @@ -19,20 +19,11 @@ Alas: - OneClickRetire - Enhance - OldRetire - Restart: - - Scheduler - -# ==================== SmartMgmt ==================== - -SmartMgmt: - menu: 'collapse' - page: 'setting' - tasks: - DowntimeFetch: - - Scheduler DowntimeMgmt: - Downtime - DowntimeStrategy + Restart: + - Scheduler # ==================== Farm ==================== @@ -376,6 +367,8 @@ Tool: - IslandPearl Benchmark: - Benchmark + DowntimeFetch: + - DowntimeFetch AzurLaneUncensored: - AzurLaneUncensored GameManager: diff --git a/module/config/config_generated.py b/module/config/config_generated.py index f24d6f3e1..50f8f88c0 100644 --- a/module/config/config_generated.py +++ b/module/config/config_generated.py @@ -81,7 +81,9 @@ class GeneratedConfig: Downtime_Title = None # Group `DowntimeStrategy` + DowntimeStrategy_AutoFetch = False DowntimeStrategy_ManageExercise = False + DowntimeStrategy_ExerciseClearTime = '15:00:00' DowntimeStrategy_ManageDaily = False # Group `Campaign` @@ -690,6 +692,9 @@ class GeneratedConfig: # Group `GameManager` GameManager_AutoRestart = True + # Group `DowntimeFetch` + DowntimeFetch_FetcherTest = False + # Group `Oil` Oil_Value = 0 Oil_Limit = 0 diff --git a/module/config/config_manual.py b/module/config/config_manual.py index 424790f68..fcc6de026 100644 --- a/module/config/config_manual.py +++ b/module/config/config_manual.py @@ -11,7 +11,6 @@ class ManualConfig: SCHEDULER_PRIORITY = """ Restart > OpsiCrossMonth - > DowntimeFetch > Commission > Tactical > Research > Exercise > Dorm > Meowfficer > Guild > Gacha diff --git a/module/config/i18n/en-US.json b/module/config/i18n/en-US.json index 1cb5de83c..de9d6ce60 100644 --- a/module/config/i18n/en-US.json +++ b/module/config/i18n/en-US.json @@ -4,10 +4,6 @@ "name": "Alas", "help": "" }, - "SmartMgmt": { - "name": "Smart Management", - "help": "" - }, "Farm": { "name": "Farm", "help": "" @@ -46,16 +42,12 @@ "name": "General", "help": "" }, - "Restart": { - "name": "Restart", + "DowntimeMgmt": { + "name": "Downtime Management", "help": "" }, - "DowntimeFetch": { - "name": "Downtime Fetch", - "help": "CN server only. Automatically fetches downtime maintenance notices from Bilibili Azur Lane columns, parses and fills 'Downtime Info'" - }, - "DowntimeMgmt": { - "name": "Downtime Maintenance", + "Restart": { + "name": "Restart", "help": "" }, "Main": { @@ -294,6 +286,10 @@ "name": "Performance Test", "help": "" }, + "DowntimeFetch": { + "name": "Downtime Fetch", + "help": "" + }, "AzurLaneUncensored": { "name": "[CN] Uncensored Patch", "help": "" @@ -721,11 +717,11 @@ "Downtime": { "_info": { "name": "Downtime Info", - "help": "Auto-filled by 'Downtime Fetch' task" + "help": "Auto-filled when 'Auto Update Info' is enabled, updated via 'Downtime Fetch' tool, or fill manually" }, "Window": { "name": "Time Window", - "help": "Format YYYY-MM-DD HH:MM~HH:MM" + "help": "Format: YYYY-MM-DD HH:MM~HH:MM; non-CN server users can fill manually" }, "Title": { "name": "Notice Title", @@ -737,9 +733,17 @@ "name": "Strategy Settings", "help": "Auto-adjust task scheduling and strategy based on downtime info" }, + "AutoFetch": { + "name": "Auto Update Info", + "help": "CN server only. Fetches downtime maintenance notices from Bilibili Azur Lane official columns\nWhen enabled, the above enabled tasks will automatically fetch CN notices and update 'Downtime Info' on execution" + }, "ManageExercise": { "name": "Exercise", - "help": "When the downtime end time is 15:00 or later, exercise strategy before downtime maintenance on that day will be replaced with 'clear exercise attempts'" + "help": "When downtime end time is later than 'Exercise clear threshold', exercise strategy before downtime maintenance on that day will be replaced with 'clear exercise attempts'" + }, + "ExerciseClearTime": { + "name": "Exercise clear threshold", + "help": "Format: HH:MM:SS" }, "ManageDaily": { "name": "Daily", @@ -6368,6 +6372,16 @@ "help": "Log back into the game when the game has been ended." } }, + "DowntimeFetch": { + "_info": { + "name": "Downtime Fetch", + "help": "CN server only. Fetches downtime maintenance notices from Bilibili Azur Lane official columns\nManually trigger to fetch the latest downtime notice and fill 'Downtime Info'" + }, + "FetcherTest": { + "name": "Fetcher Test", + "help": "Runs diagnostic tests for network, API, and content parsing; does not update config" + } + }, "Oil": { "_info": { "name": "Oil._info.name", diff --git a/module/config/i18n/ja-JP.json b/module/config/i18n/ja-JP.json index cbbeb9a23..d983c3522 100644 --- a/module/config/i18n/ja-JP.json +++ b/module/config/i18n/ja-JP.json @@ -4,10 +4,6 @@ "name": "Alas", "help": "" }, - "SmartMgmt": { - "name": "スマート管理", - "help": "" - }, "Farm": { "name": "出撃", "help": "" @@ -46,16 +42,12 @@ "name": "通用設定", "help": "" }, - "Restart": { - "name": "再起動設定", + "DowntimeMgmt": { + "name": "メンテナンス管理", "help": "" }, - "DowntimeFetch": { - "name": "メンテナンス情報取得", - "help": "国服のみ対応。bilibiliアズールレーンコラムからメンテナンス告知を自動取得し、解析して'メンテナンス情報'に記入" - }, - "DowntimeMgmt": { - "name": "メンテナンス", + "Restart": { + "name": "再起動設定", "help": "" }, "Main": { @@ -294,6 +286,10 @@ "name": "機能テスト", "help": "" }, + "DowntimeFetch": { + "name": "メンテナンス情報取得", + "help": "" + }, "AzurLaneUncensored": { "name": "中国サーバー規制解除", "help": "" @@ -721,11 +717,11 @@ "Downtime": { "_info": { "name": "メンテナンス情報", - "help": "'メンテナンス情報取得'タスクにより自動入力" + "help": "'自動更新情報'を有効にするとタスクで自動更新、ツールの'メンテナンス情報取得'で更新、または手動入力" }, "Window": { "name": "時間枠", - "help": "形式 YYYY-MM-DD HH:MM~HH:MM" + "help": "形式:YYYY-MM-DD HH:MM~HH:MM;国服以外のユーザーは手動入力可能" }, "Title": { "name": "告知タイトル", @@ -737,9 +733,17 @@ "name": "機能設定", "help": "メンテナンス情報に基づきタスクスケジュールと戦略を自動調整" }, + "AutoFetch": { + "name": "自動更新情報", + "help": "国服のみ対応。bilibiliアズールレーン公式コラムからメンテナンス告知を取得\n有効にすると、上記の有効なタスク実行時に国服の告知を自動取得し'メンテナンス情報'を更新します" + }, "ManageExercise": { "name": "演習", - "help": "メンテナンス終了時刻が15:00以降の場合、当日のメンテナンス開始前の演習戦略が'演習回数をクリア'に置き換えられます" + "help": "メンテナンス終了時刻が'演習クリア閾値'より遅い場合、当日のメンテナンス開始前の演習戦略が'演習回数をクリア'に置き換えられます" + }, + "ExerciseClearTime": { + "name": "演習クリア閾値", + "help": "形式:HH:MM:SS" }, "ManageDaily": { "name": "デイリー", @@ -6368,6 +6372,16 @@ "help": "GameManager.AutoRestart.help" } }, + "DowntimeFetch": { + "_info": { + "name": "メンテナンス公告取得", + "help": "国服のみ対応。bilibiliアズールレーン公式コラムからメンテナンス告知を取得\n手動トリガーで最新のメンテナンス告知を取得し'メンテナンス情報'に書き込み" + }, + "FetcherTest": { + "name": "機能テスト", + "help": "ネットワーク、API、内容解析の診断テストを実行、設定は更新しない" + } + }, "Oil": { "_info": { "name": "Oil._info.name", diff --git a/module/config/i18n/zh-CN.json b/module/config/i18n/zh-CN.json index 88b7bad15..6d027e126 100644 --- a/module/config/i18n/zh-CN.json +++ b/module/config/i18n/zh-CN.json @@ -4,10 +4,6 @@ "name": "Alas", "help": "" }, - "SmartMgmt": { - "name": "智能管理", - "help": "" - }, "Farm": { "name": "出击", "help": "" @@ -46,16 +42,12 @@ "name": "通用设置", "help": "" }, - "Restart": { - "name": "重启设置", + "DowntimeMgmt": { + "name": "停服维护设置", "help": "" }, - "DowntimeFetch": { - "name": "公告获取", - "help": "仅支持国服,自动从B站碧蓝航线专栏获取停服维护公告,解析并填写'停服维护信息'" - }, - "DowntimeMgmt": { - "name": "停服维护", + "Restart": { + "name": "重启设置", "help": "" }, "Main": { @@ -294,6 +286,10 @@ "name": "性能测试", "help": "" }, + "DowntimeFetch": { + "name": "公告拉取", + "help": "" + }, "AzurLaneUncensored": { "name": "反和谐", "help": "" @@ -721,11 +717,11 @@ "Downtime": { "_info": { "name": "停服维护信息", - "help": "可由'公告获取'任务自动填写" + "help": "可在开启'自动拉取信息'后由任务自动更新,也可通过工具的'公告拉取'更新,或手动填写" }, "Window": { "name": "时间窗口", - "help": "格式 YYYY-MM-DD HH:MM~HH:MM" + "help": "格式:YYYY-MM-DD HH:MM~HH:MM" }, "Title": { "name": "公告标题", @@ -737,9 +733,17 @@ "name": "功能设置", "help": "基于停服维护信息自动调整任务策略与调度" }, + "AutoFetch": { + "name": "自动拉取信息", + "help": "仅支持国服,从B站碧蓝航线官方专栏拉取停服维护公告\n开启后,以下启用的任务运行时将自动拉取国服公告并更新'停服维护信息'" + }, "ManageExercise": { "name": "演习", - "help": "当停服结束时间为15:00及之后时,当天停服维护前演习策略将替换为'清空演习次数'" + "help": "当停服结束时间晚于'演习时间阈值'时,当天停服维护前演习策略将替换为'清空演习次数'" + }, + "ExerciseClearTime": { + "name": "演习时间阈值", + "help": "格式:HH:MM:SS" }, "ManageDaily": { "name": "每日任务", @@ -6368,6 +6372,16 @@ "help": "游戏被强制结束后自动登录游戏" } }, + "DowntimeFetch": { + "_info": { + "name": "停服维护公告拉取", + "help": "仅支持国服,从B站碧蓝航线官方专栏拉取停服维护公告\n手动触发拉取最新停服维护公告并写入'停服维护信息'" + }, + "FetcherTest": { + "name": "功能测试", + "help": "运行网络、API、内容解析的诊断测试,不更新配置" + } + }, "Oil": { "_info": { "name": "Oil._info.name", diff --git a/module/config/i18n/zh-TW.json b/module/config/i18n/zh-TW.json index 3b1e0f6ab..07c68c67e 100644 --- a/module/config/i18n/zh-TW.json +++ b/module/config/i18n/zh-TW.json @@ -4,10 +4,6 @@ "name": "Alas", "help": "" }, - "SmartMgmt": { - "name": "智能管理", - "help": "" - }, "Farm": { "name": "出擊", "help": "" @@ -46,16 +42,12 @@ "name": "通用設定", "help": "" }, - "Restart": { - "name": "重啟設定", + "DowntimeMgmt": { + "name": "停服維護智能管理", "help": "" }, - "DowntimeFetch": { - "name": "公告取得", - "help": "僅支援國服,自動從B站碧藍航線專欄取得停服維護公告,解析並填寫'停服維護資訊'" - }, - "DowntimeMgmt": { - "name": "停服維護", + "Restart": { + "name": "重啟設定", "help": "" }, "Main": { @@ -294,6 +286,10 @@ "name": "性能測試", "help": "" }, + "DowntimeFetch": { + "name": "公告取得", + "help": "" + }, "AzurLaneUncensored": { "name": "反和諧", "help": "" @@ -721,11 +717,11 @@ "Downtime": { "_info": { "name": "停服維護資訊", - "help": "可由'公告取得'任務自動填寫" + "help": "可在開啟'自動更新資訊'後由任務自動更新,也可透過工具的'公告取得'更新,或手動填寫" }, "Window": { "name": "時間窗口", - "help": "格式 YYYY-MM-DD HH:MM~HH:MM" + "help": "格式:YYYY-MM-DD HH:MM~HH:MM;非國服用戶可手動填寫" }, "Title": { "name": "公告標題", @@ -737,9 +733,17 @@ "name": "功能設定", "help": "基於停服維護資訊自動調整任務排程與策略" }, + "AutoFetch": { + "name": "自動更新資訊", + "help": "僅支援國服,從B站碧藍航線官方專欄拉取停服維護公告\n開啟後,以下啟用的任務執行時將自動拉取國服公告並更新'停服維護資訊'" + }, "ManageExercise": { "name": "演習", - "help": "當停服結束時間為15:00及之後時,當天停服維護前演習策略將替換為'清空演習次數'" + "help": "當停服結束時間晚於'演習清空閾值'時,當天停服維護前演習策略將替換為'清空演習次數'" + }, + "ExerciseClearTime": { + "name": "演習清空閾值", + "help": "格式:HH:MM:SS" }, "ManageDaily": { "name": "每日任務", @@ -6368,6 +6372,16 @@ "help": "遊戲被強制結束後自動登錄遊戲" } }, + "DowntimeFetch": { + "_info": { + "name": "停服維護公告拉取", + "help": "僅支援國服,從B站碧藍航線官方專欄拉取停服維護公告\n手動觸發拉取最新停服維護公告並寫入'停服維護資訊'" + }, + "FetcherTest": { + "name": "功能測試", + "help": "運行網路、API、內容解析的診斷測試,不更新配置" + } + }, "Oil": { "_info": { "name": "Oil._info.name", diff --git a/module/daily/daily.py b/module/daily/daily.py index a43d203b6..7e1ad0290 100644 --- a/module/daily/daily.py +++ b/module/daily/daily.py @@ -9,7 +9,7 @@ from module.combat.combat import Combat from module.daily.assets import * from module.logger import logger from module.ocr.ocr import Digit -from module.smart_mgmt.utils import parse_downtime +from module.smart_mgmt.downtime_fetch import get_downtime_end from module.ui.assets import BACK_ARROW, DAILY_CHECK from module.ui.page import page_campaign_menu, page_daily @@ -347,14 +347,7 @@ class Daily(Combat): maintenance has not ended yet, or None if smart_mgmt unavailable, today is not maintenance day, or maintenance already ended. """ - if self.config.SERVER != 'cn': - return None - if not self.config.cross_get('DowntimeMgmt.DowntimeStrategy.ManageDaily', False): - return None - window = self.config.cross_get('DowntimeMgmt.Downtime.Window', None) - if not window: - return None - end_dt = parse_downtime(window) + end_dt = get_downtime_end(self.config) if end_dt is None: return None now = datetime.now() diff --git a/module/exercise/exercise.py b/module/exercise/exercise.py index 39b89612e..5ff94aa5b 100644 --- a/module/exercise/exercise.py +++ b/module/exercise/exercise.py @@ -5,7 +5,7 @@ from module.exercise.combat import ExerciseCombat from module.logger import logger from module.ocr.ocr import Digit, Ocr, OcrYuv from module.ui.page import page_exercise -from module.smart_mgmt.utils import parse_downtime +from module.smart_mgmt.downtime_fetch import get_downtime_end class DatedDuration(Ocr): def __init__(self, buttons, lang='cnocr', letter=(255, 255, 255), threshold=128, alphabet='0123456789:IDS天日d', @@ -77,7 +77,6 @@ class Exercise(ExerciseCombat): opponent_change_count = 0 remain = 0 preserve = 0 - DOWNTIME_END_HOUR_THRESHOLD = 15 def _new_opponent(self): logger.info('New opponent') @@ -212,24 +211,19 @@ class Exercise(ExerciseCombat): Check smart_mgmt downtime window to decide whether to clear attempts. Returns: - bool: - True if today is maintenance day with end time at or after DOWNTIME_END_HOUR_THRESHOLD. + bool: + True if today is maintenance day with end time at or after ExerciseClearTime. False if available but today is not maintenance day. - None if unavailable (disabled, non-CN server, window missing, or parse failed). + None if unavailable (disabled, window missing, or parse failed). """ - if self.config.SERVER != 'cn': - return None - if not self.config.cross_get('DowntimeMgmt.DowntimeStrategy.ManageExercise', False): - return None - window = self.config.cross_get('DowntimeMgmt.Downtime.Window', None) - if not window: - return None - end_dt = parse_downtime(window) + end_dt = get_downtime_end(self.config) if end_dt is None: return None if end_dt <= now: return False - return end_dt.date() == now.date() and end_dt.hour >= self.DOWNTIME_END_HOUR_THRESHOLD + threshold_str = self.config.cross_get('DowntimeMgmt.DowntimeStrategy.ExerciseClearTime', '15:00:00') + threshold_time = datetime.datetime.strptime(threshold_str, '%H:%M:%S').time() + return end_dt.date() == now.date() and end_dt.time() >= threshold_time def _should_force_clear(self, now, remain_time, admiral_interval): """Decide whether to clear all exercise attempts this run.""" diff --git a/module/smart_mgmt/downtime_fetch.py b/module/smart_mgmt/downtime_fetch.py index b13537aa3..516d6525e 100644 --- a/module/smart_mgmt/downtime_fetch.py +++ b/module/smart_mgmt/downtime_fetch.py @@ -1,47 +1,83 @@ -import random -from datetime import timedelta +from __future__ import annotations -from module.config.utils import get_server_next_update +import re +from datetime import datetime +from typing import Optional + +from module.config.config import AzurLaneConfig from module.logger import logger from module.smart_mgmt.downtime_fetcher import DowntimeFetcher, FetchError +from module.smart_mgmt.fetcher_test import DowntimeFetcherTest -class DowntimeFetch: - def __init__(self, config): - self.config = config +DOWNTIME_FORMAT_RE = re.compile( + r'(\d{4})-(\d{1,2})-(\d{1,2}) ' + r'(\d{1,2}):(\d{2})~(\d{1,2}):(\d{2})', +) - def _set_downtime(self, window, title): - self.config.modified['DowntimeMgmt.Downtime.Window'] = window - self.config.modified['DowntimeMgmt.Downtime.Title'] = title - self.config.update() - def _update_downtime(self) -> bool: - """Fetch downtime via fetcher and write cross-task config.""" - try: - payload = DowntimeFetcher().run() - except FetchError as e: - # Expected operational failure (API error, collection missing, no cache); - # degrade gracefully and let the caller reschedule - logger.warning(f'Fetch downtime failed: {e}') - return False - except Exception: - # Unexpected programming error; full traceback for diagnosis - logger.exception('Unexpected error during downtime fetch') - return False - self._set_downtime(payload.get('window', ''), payload.get('title', '')) - return True +def parse_downtime(window: str) -> Optional[datetime]: + """ + Args: + window (str): Window string in format 'YYYY-MM-DD HH:MM~HH:MM'. - def run(self): - success = False - if self.config.SERVER != 'cn': - # Downtime fetch targets CN Bilibili notices only; skip on other servers - logger.warning('Downtime fetch is only available for CN server, skip') + Returns: + Optional[datetime]: + End datetime parsed from the window, or None if no pattern matched. + """ + match = DOWNTIME_FORMAT_RE.search(window) + if not match: + logger.warning(f'Failed to parse downtime window: {window}') + return None + year, month, day = int(match.group(1)), int(match.group(2)), int(match.group(3)) + end_hour, end_minute = int(match.group(6)), int(match.group(7)) + end_dt = datetime(year, month, day, end_hour, end_minute) + logger.info(f'Parsed downtime window: end={end_dt:%Y-%m-%d %H:%M}') + return end_dt + + +def update_downtime(config: AzurLaneConfig, force_refresh=False) -> bool: + """Fetch downtime and write config.""" + try: + payload = DowntimeFetcher().run(force_refresh=force_refresh) + except FetchError as e: + # Expected operational failure degrade + logger.warning(f'Failed to fetch downtime: {e}') + return False + except Exception as e: + # Unexpected programming error; full traceback for diagnosis + logger.exception(f'Unexpected error: {e}') + return False + with config.multi_set(): + config.cross_set('DowntimeMgmt.Downtime.Window', payload.get('window', '')) + config.cross_set('DowntimeMgmt.Downtime.Title', payload.get('title', '')) + return True + + +def get_downtime_end(config: AzurLaneConfig) -> Optional[datetime]: + task = config.task.command + if not config.cross_get(f'DowntimeMgmt.DowntimeStrategy.Manage{task}', False): + return None + if config.cross_get('DowntimeMgmt.DowntimeStrategy.AutoFetch', False): + if config.SERVER == 'cn': + update_downtime(config) else: - success = self._update_downtime() - if success: - # Stagger execution across instances within 5 minutes after server update - next_update = get_server_next_update(self.config.Scheduler_ServerUpdate) - target = next_update + timedelta(seconds=random.randint(0, 300)) - self.config.task_delay(target=target) - else: - self.config.task_delay(success=False) + logger.warning('Auto-fetch skipped as fetcher is for CN only, use window from config') + else: + logger.info('Auto-fetch disabled, use window from config') + window = config.cross_get('DowntimeMgmt.Downtime.Window', None) + if not window: + return None + return parse_downtime(window) + + +def run_downtime_fetch(config: AzurLaneConfig): + # Tool task entry is invoked with config bound to Alas by default, + # rebind to DowntimeFetch. + config.init_task('DowntimeFetch') + if config.DowntimeFetch_FetcherTest: + DowntimeFetcherTest().run() + else: + if config.SERVER != 'cn': + logger.warning('Downtime fetch targets CN Bilibili notices, data may be irrelevant on other servers') + update_downtime(config, force_refresh=True) diff --git a/module/smart_mgmt/downtime_fetcher.py b/module/smart_mgmt/downtime_fetcher.py index 5b6b67e4b..6f8c3b99c 100644 --- a/module/smart_mgmt/downtime_fetcher.py +++ b/module/smart_mgmt/downtime_fetcher.py @@ -58,7 +58,7 @@ class DowntimeFetcher: CACHE_FILE = './log/downtime_cache.json' FETCH_LOCK_FILE = './log/downtime_fetch.lock' # Multi-instance duplicate fetch avoidance window (seconds) - CACHE_FRESH_SECONDS = 300 + CACHE_FRESH_SECONDS = 600 # A bit larger than REQUEST_TIMEOUT LOCK_WAIT_SECONDS = 35 @@ -124,10 +124,10 @@ class DowntimeFetcher: year += 1 return year - def _build_window_dt(self, published, sm, sd, sh, smin, eh, emin): - year = self._infer_year(published, sm, sd) - start = datetime(year, sm, sd, sh, smin, tzinfo=self.CN_TZ) - end = datetime(year, sm, sd, eh, emin, tzinfo=self.CN_TZ) + def _build_window_dt(self, published, month, day, sh, smin, eh, emin): + year = self._infer_year(published, month, day) + start = datetime(year, month, day, sh, smin, tzinfo=self.CN_TZ) + end = datetime(year, month, day, eh, emin, tzinfo=self.CN_TZ) return start, end def parse_window_from_article(self, article: Article) -> Optional[DowntimeWindow]: @@ -138,29 +138,29 @@ class DowntimeFetcher: from mis-parsing 'HH:MM-HH:MM' as an ISO 8601 datetime with offset. """ published = datetime.fromtimestamp(article.pub_ts, tz=self.CN_TZ) - body = self._clean_text(article.summary) - window_match = self.WINDOW_RE.search(body) - duration_match = self.DURATION_RE.search(body) + summary = self._clean_text(article.summary) + window_match = self.WINDOW_RE.search(summary) + duration_match = self.DURATION_RE.search(summary) if window_match: - sm = int(window_match.group(1)) - sd = int(window_match.group(2)) + month = int(window_match.group(1)) + day = int(window_match.group(2)) sh = int(window_match.group(3)) smin = int(window_match.group(4)) eh = int(window_match.group(5)) emin = int(window_match.group(6)) - start, end = self._build_window_dt(published, sm, sd, sh, smin, eh, emin) + start, end = self._build_window_dt(published, month, day, sh, smin, eh, emin) return DowntimeWindow(window=f'{start:%Y-%m-%d} {start:%H:%M}~{end:%H:%M}') if duration_match: title_match = self.TITLE_TIME_RE.search(article.title) if title_match: - sm = int(title_match.group(1)) - sd = int(title_match.group(2)) + month = int(title_match.group(1)) + day = int(title_match.group(2)) sh = int(title_match.group(3)) smin = int(title_match.group(4)) hours = int(duration_match.group(1)) - start, _ = self._build_window_dt(published, sm, sd, sh, smin, sh, smin) + start, _ = self._build_window_dt(published, month, day, sh, smin, sh, smin) end = start + timedelta(hours=hours) return DowntimeWindow(window=f'{start:%Y-%m-%d} {start:%H:%M}~{end:%H:%M}') @@ -172,7 +172,7 @@ class DowntimeFetcher: def fetch_articles(self, local_update_time=None) -> tuple[Optional[list[Article]], CollectionMeta]: """ - Full fetch pipeline: find_collection → incremental check → fetch articles. + find_collection -> incremental check -> fetch articles Returns: tuple[Optional[list[Article]], CollectionMeta]: @@ -196,7 +196,7 @@ class DowntimeFetcher: Returns: tuple[Optional[DowntimeWindow], CollectionMeta, Optional[Article]]: - (downtime_window, collection_meta, latest_article). + (downtime_window, collection_meta, latest_article). downtime_window is None if parse failed. latest_article is None if collection not updated. """ articles, collection_meta = self.fetch_articles(local_update_time) @@ -236,19 +236,19 @@ class DowntimeFetcher: age = datetime.now().timestamp() - p.stat().st_mtime return age < cls.CACHE_FRESH_SECONDS - def get_downtime_info(self) -> dict: + def get_downtime_info(self, force_refresh=False) -> dict: """ - Full pipeline: load cache → collect_info → save cache. + Fetch from API, parse, and save cache. + + Args: + force_refresh (bool): + Skip incremental check, always fetch from API and overwrite cache. Returns: dict: payload with window, title, collection_id, update_time. """ cache = self.load_cache() - # Avoid duplicate fetch across instances: reuse fresh cache - if cache and self.is_cache_fresh(): - logger.info('Cache is fresh, reuse without fetch') - return cache - local_update_time = cache.get('update_time') if cache else None + local_update_time = None if force_refresh else (cache.get('update_time') if cache else None) downtime_window, collection_meta, latest_article = self.collect_info(local_update_time) @@ -257,13 +257,13 @@ class DowntimeFetcher: raise FetchError('No available cache and collection not updated') return cache - # Degrade on parse failure: reuse cached window + # Degrade on parse failure: reuse cached window, or raise if no cache if downtime_window is None: if cache and cache.get('window'): - logger.warning('Parse failed, reuse cached window') + logger.warning('Failed to parse, reuse cached window') window = cache['window'] else: - window = '' + raise FetchError('Failed to parse and no cache available') else: window = downtime_window.window @@ -320,26 +320,32 @@ class DowntimeFetcher: except OSError as e: logger.warning(f'Failed to release fetch lock: {e}') - def run(self) -> dict: + def run(self, force_refresh=False) -> dict: """ Lock-guarded entry for the downtime pipeline. - Fast path: cache fresh, invoke get_downtime_info without lock (read-only, no contention). - Common path: acquire cross-process lock, then get_downtime_info (re-checks freshness). + Fast path: fresh cache, return directly without lock (read-only). + Common path: acquire cross-process lock, then fetch. Fallback: lock acquisition failed, reuse cache only if another process has freshly updated it; otherwise raise FetchError. Returns: dict: payload with window, title, collection_id, update_time. """ - if self.is_cache_fresh(): - return self.get_downtime_info() + # Fast path: fresh cache, read-only, no lock needed + if not force_refresh: + cache = self.load_cache() + if cache and self.is_cache_fresh(): + logger.info('Cache is fresh, reuse without fetch') + return cache if not self.acquire_lock(): - if self.is_cache_fresh(): + # Lock failed: reuse fresh cache if another process updated it + cache = self.load_cache() + if cache and self.is_cache_fresh(): logger.info('Cache updated by another process, reuse') - return self.get_downtime_info() + return cache raise FetchError('Failed to acquire fetch lock') try: - return self.get_downtime_info() + return self.get_downtime_info(force_refresh=force_refresh) finally: self.release_lock() diff --git a/module/smart_mgmt/fetcher_test.py b/module/smart_mgmt/fetcher_test.py new file mode 100644 index 000000000..4a0cda5af --- /dev/null +++ b/module/smart_mgmt/fetcher_test.py @@ -0,0 +1,370 @@ +from __future__ import annotations + +import json +import socket +import ssl +import time +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from pathlib import Path +from urllib.parse import urlparse + +import requests +from rich.table import Table +from rich.text import Text + +from module.logger import logger +from module.smart_mgmt.downtime_fetcher import DowntimeFetcher + + +class _TestStatus(Enum): + PASS = 'PASS' + FAIL = 'FAIL' + SKIP = 'SKIP' + + +@dataclass +class _TestResult: + name: str + status: _TestStatus + note: str + + +class DowntimeFetcherTest: + # TCP/TLS probe timeout, shorter than HTTP request timeout for faster fail + CONNECT_TIMEOUT = 10.0 + + # Number of articles (excluding the latest) shown as title-only preview + ARTICLE_PREVIEW_COUNT = 3 + # Max summary length shown for the latest article + SUMMARY_EXCERPT_LEN = 200 + ARTICLES_JSONL_FILE = './log/downtime_articles_cache.jsonl' + + # Test names + TEST_NETWORK = 'Network' + TEST_LISTS_API = 'Lists API' + TEST_COLLECTION = 'Collection lookup' + TEST_ARTICLES_API = 'Articles API' + TEST_PARSE = 'Parse window' + + _STATUS_STYLE = { + _TestStatus.PASS: 'green', + _TestStatus.FAIL: 'bright_red', + _TestStatus.SKIP: 'yellow', + } + + def __init__(self): + self.fetcher = DowntimeFetcher() + # Abstract main-flow params into local test params: single point of + # change when fetcher internals evolve, avoids scattered coupling. + self.session = self.fetcher.session + self.mid = self.fetcher.DEFAULT_MID + self.collection_name = self.fetcher.DEFAULT_COLLECTION_NAME + self.lists_api = self.fetcher.ARTICLE_LISTS_API + self.articles_api = self.fetcher.ARTICLE_COLLECTION_API + self.request_timeout = self.fetcher.REQUEST_TIMEOUT + self.cn_tz = self.fetcher.CN_TZ + self.window_re = self.fetcher.WINDOW_RE + self.duration_re = self.fetcher.DURATION_RE + self.title_time_re = self.fetcher.TITLE_TIME_RE + # State carried across tests along the dependency chain + self._lists = [] + self._up_name = '' + self._collection_id = None + self._latest_article = None + self._results = [] + self._skip_remaining = False + + def run(self): + logger.hr('Downtime fetcher test', level=1) + self._results.clear() + self._skip_remaining = False + + tests = [ + (self.TEST_NETWORK, self.test_network), + (self.TEST_LISTS_API, self.test_lists_api), + (self.TEST_COLLECTION, self.test_find_collection), + (self.TEST_ARTICLES_API, self.test_articles_api), + (self.TEST_PARSE, self.test_parse_window), + ] + for name, func in tests: + if self._skip_remaining: + self._results.append(_TestResult(name, _TestStatus.SKIP, 'Upstream failed')) + continue + try: + func() + except Exception as e: + # Unexpected programming error: record and stop the chain so + # the summary still prints + logger.exception(f'Unexpected error in {name}') + self._results.append(_TestResult(name, _TestStatus.FAIL, f'unexpected: {type(e).__name__}: {e}')) + self._skip_remaining = True + continue + if self._results[-1].status == _TestStatus.FAIL: + self._skip_remaining = True + + self._show_summary() + + # ------------------------------------------------------------------ + # Shared helpers + # ------------------------------------------------------------------ + + def _fetch_json(self, url, params): + """GET url with fetcher session and return (body, error). + + Returns: + tuple: (parsed_body, None) on success; (None, diagnostic_str) on failure. + """ + logger.info(f'Request: {url} params={params}') + try: + resp = self.session.get(url, params=params, timeout=self.request_timeout) + logger.info(f'HTTP status: {resp.status_code}') + return resp.json(), None + except requests.RequestException as e: + logger.warning(f'Request failed: {e}') + return None, f'{type(e).__name__}: {e}' + except ValueError as e: + logger.warning(f'JSON parse failed: {e}') + return None, f'JSON: {e}' + + def _log_body_structure(self, body): + """Log top-level keys and data keys of API response body.""" + if not isinstance(body, dict): + logger.info(f'Body is {type(body).__name__}, not dict') + return + logger.info(f'Body top-level keys: {list(body.keys())}') + data = body.get('data') + if isinstance(data, dict): + logger.info(f'data keys: {list(data.keys())}') + elif data is not None: + logger.info(f'data is {type(data).__name__}, not dict') + + def _format_pub_time(self, pub_ts): + """Format publish timestamp to readable CN time string.""" + return datetime.fromtimestamp(pub_ts, tz=self.cn_tz).strftime('%Y-%m-%d %H:%M') + + def _normalize_article_for_cache(self, article): + """Convert raw API article dict to cache record format. + + Mirrors dev_tools/downtime_notice_crawler.py normalize_article output + so test caches stay consistent with crawler-produced caches. + """ + cvid = str(article['id']) + pub_ts = int(article['publish_time']) + return { + 'pub_ts': pub_ts, + 'pub_time': datetime.fromtimestamp(pub_ts, tz=self.cn_tz).isoformat(), + 'title': article['title'], + 'text': article['summary'], + 'url': f'https://www.bilibili.com/read/cv{cvid}', + 'collection_id': self._collection_id, + 'collection_name': self.collection_name, + } + + def _save_caches(self, articles): + """Persist normalized article records for diagnostic inspection. + + Args: + articles (list[dict]): Raw article list from body['data']['articles']. + """ + Path(self.ARTICLES_JSONL_FILE).parent.mkdir(parents=True, exist_ok=True) + records = [self._normalize_article_for_cache(a) for a in articles] + jsonl_path = Path(self.ARTICLES_JSONL_FILE) + jsonl_path.write_text( + ''.join(json.dumps(r, ensure_ascii=False) + '\n' for r in records), + encoding='utf-8', + ) + logger.info(f'Saved {len(records)} article records to {self.ARTICLES_JSONL_FILE}') + + def _record(self, name, status, note=''): + self._results.append(_TestResult(name, status, note)) + + # ------------------------------------------------------------------ + # Test cases + # ------------------------------------------------------------------ + + def test_network(self): + """Probe DNS/TCP/TLS to the API host to locate where connectivity breaks.""" + logger.hr('Network connectivity', level=2) + host = urlparse(self.lists_api).hostname + port = 443 + logger.info(f'Target: {host}:{port}') + + try: + t0 = time.monotonic() + ip = socket.gethostbyname(host) + dns_cost = time.monotonic() - t0 + logger.info(f'DNS resolved: {host} -> {ip} ({dns_cost:.2f}s)') + except socket.gaierror as e: + logger.warning(f'DNS resolution failed: {e}') + self._record(self.TEST_NETWORK, _TestStatus.FAIL, f'DNS: {e}') + return + + ctx = ssl.create_default_context() + sock = None + try: + t0 = time.monotonic() + sock = socket.create_connection((host, port), timeout=self.CONNECT_TIMEOUT) + tcp_cost = time.monotonic() - t0 + logger.info(f'TCP connected: {host}:{port} ({tcp_cost:.2f}s)') + + t1 = time.monotonic() + ssock = ctx.wrap_socket(sock, server_hostname=host) + tls_cost = time.monotonic() - t1 + ssock.close() + sock = None + logger.info(f'TLS handshake ok ({tls_cost:.2f}s)') + self._record( + self.TEST_NETWORK, _TestStatus.PASS, + f'DNS:{dns_cost:.2f}s TCP:{tcp_cost:.2f}s TLS:{tls_cost:.2f}s', + ) + except OSError as e: + logger.warning(f'Connection failed: {type(e).__name__}: {e}') + self._record(self.TEST_NETWORK, _TestStatus.FAIL, f'{type(e).__name__}: {e}') + finally: + if sock is not None: + try: + sock.close() + except OSError: + pass + + def test_lists_api(self): + """Verify ARTICLE_LISTS_API returns valid response with data.lists.""" + logger.hr('Lists API', level=2) + body, err = self._fetch_json(self.lists_api, {'mid': str(self.mid), 'sort': 0}) + if body is None: + self._record(self.TEST_LISTS_API, _TestStatus.FAIL, err) + return + + self._log_body_structure(body) + code = body.get('code') + message = body.get('message') or body.get('msg') or '' + logger.info(f'API response: code={code} message={message!r}') + if code != 0: + logger.warning(f'Non-zero code, full body: {body}') + self._record(self.TEST_LISTS_API, _TestStatus.FAIL, f'code={code} msg={message}') + return + + lists = (body.get('data') or {}).get('lists') + if not isinstance(lists, list): + logger.warning(f'Invalid data.lists: type={type(lists).__name__}') + self._record(self.TEST_LISTS_API, _TestStatus.FAIL, f'lists invalid: {type(lists).__name__}') + return + + self._lists = lists + logger.info(f'Got {len(lists)} collections') + self._record(self.TEST_LISTS_API, _TestStatus.PASS, f'{len(lists)} collections') + + def test_find_collection(self): + """Check whether target collection exists in UP's lists.""" + logger.hr('Collection lookup', level=2) + logger.info(f'Target collection: {self.collection_name!r}') + names = [item.get('name') for item in self._lists] + logger.info(f'Available collections: {names}') + + target = next( + (item for item in self._lists if item.get('name') == self.collection_name), + None, + ) + if target is None: + logger.warning(f'Target not found: {self.collection_name!r}') + self._record(self.TEST_COLLECTION, _TestStatus.FAIL, f'not found, available={names}') + return + + self._collection_id = int(target['id']) + logger.info(f'Found: id={self._collection_id} name={target["name"]!r}') + self._record(self.TEST_COLLECTION, _TestStatus.PASS, f'id={self._collection_id}') + + def test_articles_api(self): + """Verify ARTICLE_COLLECTION_API returns articles for the collection.""" + logger.hr('Articles API', level=2) + body, err = self._fetch_json(self.articles_api, {'id': str(self._collection_id)}) + if body is None: + self._record(self.TEST_ARTICLES_API, _TestStatus.FAIL, err) + return + + self._log_body_structure(body) + code = body.get('code') + message = body.get('message') or body.get('msg') or '' + logger.info(f'API response: code={code} message={message!r}') + if code != 0: + logger.warning(f'Non-zero code, full body: {body}') + self._record(self.TEST_ARTICLES_API, _TestStatus.FAIL, f'code={code} msg={message}') + return + + articles = (body.get('data') or {}).get('articles') + if not isinstance(articles, list) or not articles: + logger.warning(f'Empty or invalid data.articles: type={type(articles).__name__}') + self._record(self.TEST_ARTICLES_API, _TestStatus.FAIL, f'articles empty: {type(articles).__name__}') + return + + # Extract UP name from response body for display + author = (body.get('data') or {}).get('author') + if isinstance(author, dict): + up_name = author.get('name') + if up_name: + self._up_name = up_name + logger.info(f'UP: mid={self.mid} name={up_name!r}') + + logger.info(f'The latest article and recent {self.ARTICLE_PREVIEW_COUNT} articles:') + + # Latest article: title + summary excerpt + sorted_articles = sorted(articles, key=lambda a: a.get('publish_time', 0), reverse=True) + latest = sorted_articles[0] + pub_time = self._format_pub_time(latest.get('publish_time', 0)) + summary = (latest.get('summary') or '')[:self.SUMMARY_EXCERPT_LEN] + logger.info(f'Latest: [{pub_time}] {latest.get("title")} | {summary!r}') + + # Next N articles: title only + for a in sorted_articles[1:1 + self.ARTICLE_PREVIEW_COUNT]: + pub_time = self._format_pub_time(a.get('publish_time', 0)) + logger.info(f'[{pub_time}] {a.get("title")}') + + # Persist article records for diagnostic inspection + self._save_caches(articles) + + articles_normalized = [self.fetcher._normalize_article(a) for a in articles] + self._latest_article = self.fetcher._latest_article(articles_normalized) + self._record(self.TEST_ARTICLES_API, _TestStatus.PASS, f'{len(articles)} articles') + + def test_parse_window(self): + """Run regex parse against the latest article to detect format drift.""" + logger.hr('Parse window', level=2) + article = self._latest_article + body = self.fetcher._clean_text(article.summary) + + # Show individual regex match results for diagnosis + window_match = self.window_re.search(body) + duration_match = self.duration_re.search(body) + title_match = self.title_time_re.search(article.title) + logger.info(f'WINDOW_RE on summary: {"matched" if window_match else "unmatched"} ' + f'groups={window_match.groups() if window_match else None}') + logger.info(f'DURATION_RE on summary: {"matched" if duration_match else "unmatched"} ' + f'groups={duration_match.groups() if duration_match else None}') + logger.info(f'TITLE_TIME_RE on title: {"matched" if title_match else "unmatched"} ' + f'groups={title_match.groups() if title_match else None}') + + window = self.fetcher.parse_window_from_article(article) + if window is None: + logger.warning('parse_window_from_article returned None') + self._record(self.TEST_PARSE, _TestStatus.FAIL, 'no regex matched') + return + + logger.info(f'Parsed window: {window.window!r}') + self._record(self.TEST_PARSE, _TestStatus.PASS, f'window={window.window}') + + # ------------------------------------------------------------------ + # Summary + # ------------------------------------------------------------------ + + def _show_summary(self): + logger.hr('Test summary', level=1) + table = Table(show_lines=True) + table.add_column('Test', style='cyan', no_wrap=True) + table.add_column('Result') + table.add_column('Note') + for r in self._results: + style = self._STATUS_STYLE.get(r.status, 'white') + table.add_row(r.name, Text(r.status.value, style=style), r.note) + logger.print(table, justify='center') diff --git a/module/smart_mgmt/utils.py b/module/smart_mgmt/utils.py deleted file mode 100644 index e33946163..000000000 --- a/module/smart_mgmt/utils.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import annotations - -import re -from datetime import datetime -from typing import Optional - -from module.logger import logger - - -DOWNTIME_FORMAT_RE = re.compile( - r'(\d{4})-(\d{1,2})-(\d{1,2}) ' - r'(\d{1,2}):(\d{2})~(\d{1,2}):(\d{2})', -) - -def parse_downtime(downtime: str) -> Optional[datetime]: - """ - Parse downtime window string into end datetime. - - Args: - downtime (str): Window string in format 'YYYY-MM-DD HH:MM~HH:MM'. - - Returns: - Optional[datetime]: End datetime parsed from the window, - or None if no pattern matched. - """ - match = DOWNTIME_FORMAT_RE.search(downtime) - if not match: - logger.warning(f'Failed to parse downtime window: {downtime}') - return None - year, month, day = int(match.group(1)), int(match.group(2)), int(match.group(3)) - end_hour, end_minute = int(match.group(6)), int(match.group(7)) - end_dt = datetime(year, month, day, end_hour, end_minute) - logger.info(f'Parsed downtime window: end={end_dt:%Y-%m-%d %H:%M}') - return end_dt diff --git a/module/submodule/utils.py b/module/submodule/utils.py index d7a87d916..86330ba2b 100644 --- a/module/submodule/utils.py +++ b/module/submodule/utils.py @@ -23,6 +23,7 @@ def get_available_func(): 'AzurLaneUncensored', 'Benchmark', 'GameManager', + 'DowntimeFetch', ) def get_available_mod(): From 78493757c2d3d70a0b59e6e89839cbae5a2ca059 Mon Sep 17 00:00:00 2001 From: positnuec <93694981+positnuec@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:21:10 +0800 Subject: [PATCH 19/20] Fix: extend submarine call timer to prevent animation-induced timeout --- module/combat/submarine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/combat/submarine.py b/module/combat/submarine.py index f22c27c98..1256db84b 100644 --- a/module/combat/submarine.py +++ b/module/combat/submarine.py @@ -16,7 +16,7 @@ BOSS_AVATAR_FRAME_COLOR = (24, 28, 24) class SubmarineCall(ModuleBase): submarine_call_flag = False - submarine_call_timer = Timer(5) + submarine_call_timer = Timer(8) submarine_call_click_timer = Timer(1) def submarine_call_reset(self): From 8aa81780c2c9fb5befe591acfb14cc4862204af2 Mon Sep 17 00:00:00 2001 From: positnuec <93694981+positnuec@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:40:27 +0800 Subject: [PATCH 20/20] Fix: swipe camera to avoid clicking Akashi grid overlaps with UI button --- module/os/map.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/module/os/map.py b/module/os/map.py index 2381527ea..18b4fe1a5 100644 --- a/module/os/map.py +++ b/module/os/map.py @@ -2,8 +2,10 @@ import time from sys import maxsize import inflection +import numpy as np from module.base.timer import Timer +from module.base.utils import area_cross_area from module.config.utils import get_os_reset_remain from module.exception import CampaignEnd, GameTooManyClickError, MapWalkError, RequestHumanTakeover, ScriptError from module.handler.login import LoginHandler, MAINTENANCE_ANNOUNCE @@ -20,6 +22,10 @@ from module.os_handler.strategic import StrategicSearchHandler from module.ui.assets import GOTO_MAIN from module.ui.page import page_os +# UI button area where clicking may trigger UI buttons instead of fleet movement +# To be replaced with an asset in the future +OS_UI_BUTTON_AREA = (1170, 455, 1280, 720) + class OSMap(OSFleet, Map, GlobeCamera, StorageHandler, StrategicSearchHandler): def os_init(self): @@ -1010,11 +1016,12 @@ class OSMap(OSFleet, Map, GlobeCamera, StorageHandler, StrategicSearchHandler): self.clear_question() self.map_rescan() - def map_rescan_current(self, drop=None): + def map_rescan_current(self, drop=None, _ui_avoid_count=0): """ Args: drop: + _ui_avoid_count (int): Internal counter for UI button area avoidance attempts Returns: bool: If solved a map random event @@ -1036,6 +1043,13 @@ class OSMap(OSFleet, Map, GlobeCamera, StorageHandler, StrategicSearchHandler): logger.info(f'Found Akashi on {grid}') fleet = self.convert_radar_to_local((0, 0)) if fleet.distance_to(grid) > 1: + # Avoid clicking grid that overlaps with UI button area + if _ui_avoid_count < 3 and area_cross_area(grid.button, OS_UI_BUTTON_AREA, threshold=0): + logger.info(f'Akashi grid {grid} overlaps with UI button area, swipe camera to avoid') + vector = np.array(grid.location) - np.array(fleet.location) + vector = tuple((vector // 2).astype(int)) + self.map_swipe(vector) + return self.map_rescan_current(drop=drop, _ui_avoid_count=_ui_avoid_count + 1) self.device.click(grid) with self.config.temporary(STORY_ALLOW_SKIP=False): result = self.wait_until_walk_stable(drop=drop, walk_out_of_step=False)