diff --git a/autopcr/core/datamgr.py b/autopcr/core/datamgr.py index 7ab2a5d8..3a1c0b1c 100644 --- a/autopcr/core/datamgr.py +++ b/autopcr/core/datamgr.py @@ -78,7 +78,7 @@ class datamgr(BaseModel, Component[apiclient]): async def try_update_database(ver: int): async with _data_lck: if not assetmgr.ver or assetmgr.ver < ver: - await assetmgr.init(ver) + assetmgr.set_version(ver) if not dbmgr.ver or dbmgr.ver < assetmgr.ver: await dbmgr.update_db(assetmgr) db.update(dbmgr) diff --git a/autopcr/db/assetmgr.py b/autopcr/db/assetmgr.py index f3cc6709..613808fe 100644 --- a/autopcr/db/assetmgr.py +++ b/autopcr/db/assetmgr.py @@ -1,127 +1,291 @@ -#type: ignore -from typing import List -from ..util import aiorequests -from ..util.logger import instance as logger -from ..constants import CACHE_DIR -import os, pydantic -import UnityPy -from UnityPy.enums import ClassIDType -from ..util.logger import instance as logger -UnityPy.config.FALLBACK_UNITY_VERSION = "2021.3.20f1" - -class content(pydantic.BaseModel): - url: str = None - md5: str = None - type: str = None - category: str = None - size: int = 0 - children: List["content"] = None - - @property - def is_assets(self) -> bool: - return not self.url.startswith('manifest/') - - @staticmethod - def from_line(line: str, category: str) -> "content": - splits = line.split(',') - offset = len(splits) > 5 - return content( - url=splits[0], - md5=splits[1], - type=splits[2 + offset], - size=int(splits[3 + offset]), - category=category, - children=[] - ) - - @staticmethod - async def from_url(urlroot: str, url: str, category: str) -> List["content"]: - lines = (await (await aiorequests.get(f'{urlroot}{url}')).text).split('\n') - res = [content.from_line(line, category) for line in lines] - for child in res: - await child.download_children(urlroot) - return res - - async def download_children(self, urlroot: str): - if not self.is_assets: - self.children = await content.from_url(urlroot, self.url, self.category) - - def register_to(self, mgr: "assetmgr"): - mgr.registries[self.url] = self - for child in self.children: - child.register_to(mgr) - - async def download(self, urlgetter) -> bytes: - return await (await aiorequests.get(urlgetter(self.md5))).content - -class assetmgr: - def __init__(self): - self.ver = None - self.root = None - self.registries: dict[str, content] = {} - - res = 'https://l1-prod-patch-gzlj.bilibiligame.net/client_ob_771' - - @property - def manifest(self) -> str: - return f'{self.res}/Manifest' - - @property - def pool(self) -> str: - return f'{self.res}/pool' - - async def init(self, ver): - self.registries.clear() - - os.makedirs(os.path.join(CACHE_DIR, 'manifest'), exist_ok=True) - cacheFile = os.path.join(CACHE_DIR, 'manifest', f'{ver}.json') - try: - self.root = content.parse_file(cacheFile) - - logger.info(f'manifest version {ver} loaded from cache') - except: - self.root = content( - url='manifest/manifest_assetmanifest', - type='every', - category='AssetBundles/Android', - children=await content.from_url(f'{self.manifest}/AssetBundles/Android/{ver}/', 'manifest/manifest_assetmanifest', 'AssetBundles/Android') - ) - with open(cacheFile, 'w') as f: - f.write(self.root.json()) - - self.ver = ver - self.root.register_to(self) - - async def download(self, url: str) -> bytes: - logger.info(f"resolving {url}...") - - content = self.registries[url] - def genHash(hash): - return f'{self.pool}/{content.category}/{hash[:2]}/{hash}' - return await content.download(genHash) - - async def db(self) -> bytes: - ab = UnityPy.load(await self.download('a/masterdata_master.unity3d')) - asset = ab.objects[0].read() - return asset.script - - async def unit_icon(self, unit_id: int) -> bytes: - ab = UnityPy.load(await self.download(f'a/unit_icon_unit_{unit_id}.unity3d')) - for object in ab.objects: - if object.type == ClassIDType.Texture2D: - asset = object.read() - return asset.image - return None - - async def ex_equip_icon(self, equip_id: int) -> bytes: - ab = UnityPy.load(await self.download(f'a/icon_icon_extra_equip_{equip_id}.unity3d')) - for object in ab.objects: - if object.type == ClassIDType.Texture2D: - asset = object.read() - return asset.image - return None - - -# should lock before use -instance = assetmgr() - +#type: ignore +import asyncio +import json +import os +from typing import Dict, Set, Tuple + +import msgpack +import UnityPy +from UnityPy.enums import ClassIDType + +from ..constants import CACHE_DIR +from ..util import aiorequests +from ..util.logger import instance as logger + +UnityPy.config.FALLBACK_UNITY_VERSION = "2021.3.20f1" + +AssetEntry = Tuple[str, str] +AssetRegistry = Dict[str, AssetEntry] + +COMPACT_MANIFEST_FORMAT = 1 +MANIFEST_CATEGORY = "AssetBundles/Android" +ROOT_MANIFEST = "manifest/manifest_assetmanifest" + + +class assetmgr: + def __init__(self): + self.ver = None + self._loaded_ver = None + self.registries: AssetRegistry = {} + self._manifest_lock = asyncio.Lock() + + res = 'https://l1-prod-patch-gzlj.bilibiligame.net/client_ob_771' + + @property + def manifest(self) -> str: + return f'{self.res}/Manifest' + + @property + def pool(self) -> str: + return f'{self.res}/pool' + + @staticmethod + def _manifest_dir() -> str: + return os.path.join(CACHE_DIR, 'manifest') + + @classmethod + def _compact_cache_path(cls, ver: int) -> str: + return os.path.join(cls._manifest_dir(), f'{ver}.compact.msgpack') + + @classmethod + def _legacy_cache_path(cls, ver: int) -> str: + return os.path.join(cls._manifest_dir(), f'{ver}.json') + + @staticmethod + def _validate_registry(raw_assets, ver: int) -> AssetRegistry: + # 紧凑格式按 category 分组存储,避免同一 category 字符串在每个条目里重复一遍 + if not isinstance(raw_assets, dict): + raise ValueError(f'compact manifest {ver} assets must be a map') + + registry: AssetRegistry = {} + for category, urls in raw_assets.items(): + if not isinstance(category, str) or not category: + raise ValueError(f'compact manifest {ver} contains an invalid category') + if not isinstance(urls, dict): + raise ValueError(f'compact manifest {ver} assets for {category} must be a map') + for url, md5 in urls.items(): + if not isinstance(url, str) or not url: + raise ValueError(f'compact manifest {ver} contains an invalid URL') + if not isinstance(md5, str) or not md5: + raise ValueError(f'compact manifest {ver} contains an invalid md5 for {url}') + registry[url] = (md5, category) + + if not registry: + raise ValueError(f'compact manifest {ver} contains no assets') + return registry + + @classmethod + def _load_compact_cache(cls, path: str, ver: int) -> AssetRegistry: + with open(path, 'rb') as f: + payload = msgpack.unpackb(f.read(), raw=False) + + if not isinstance(payload, dict): + raise ValueError(f'compact manifest {ver} must be a map') + if payload.get('format') != COMPACT_MANIFEST_FORMAT: + raise ValueError(f'unsupported compact manifest format for version {ver}') + if payload.get('version') != ver: + raise ValueError(f'compact manifest version mismatch: expected {ver}') + return cls._validate_registry(payload.get('assets'), ver) + + @classmethod + def _load_legacy_cache(cls, path: str, ver: int) -> AssetRegistry: + with open(path, 'r', encoding='utf-8') as f: + root = json.load(f) + + # 同一 URL 会同时出现在 xxx_assetmanifest 与后继的 xxx_assetmanifest_s 中, + # 只有后者的 hash 在 pool 里真实存在,因此必须按先序遍历让后出现的条目覆盖前者 + registry: AssetRegistry = {} + stack = [root] + while stack: + node = stack.pop() + if not isinstance(node, dict): + raise ValueError(f'legacy manifest {ver} contains a non-object node') + + children = node.get('children') or [] + if not isinstance(children, list): + raise ValueError(f'legacy manifest {ver} contains invalid children') + stack.extend(reversed(children)) + + url = node.get('url') + if not isinstance(url, str) or not url or url.startswith('manifest/'): + continue + md5 = node.get('md5') + category = node.get('category') + if not isinstance(md5, str) or not md5: + raise ValueError(f'legacy manifest {ver} contains an invalid md5 for {url}') + if not isinstance(category, str) or not category: + raise ValueError(f'legacy manifest {ver} contains an invalid category for {url}') + registry[url] = (md5, category) + + if not registry: + raise ValueError(f'legacy manifest {ver} contains no assets') + return registry + + @classmethod + def _write_compact_cache(cls, path: str, ver: int, registry: AssetRegistry): + assets: Dict[str, Dict[str, str]] = {} + for url, (md5, category) in registry.items(): + assets.setdefault(category, {})[url] = md5 + payload = { + 'format': COMPACT_MANIFEST_FORMAT, + 'version': ver, + 'assets': assets, + } + data = msgpack.packb(payload, use_bin_type=True) + temporary_path = f'{path}.{os.getpid()}.tmp' + try: + with open(temporary_path, 'wb') as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + os.replace(temporary_path, path) + finally: + if os.path.exists(temporary_path): + os.remove(temporary_path) + + async def _fetch_manifest( + self, + urlroot: str, + manifest_url: str, + category: str, + registry: AssetRegistry, + visited: Set[str], + ): + if manifest_url in visited: + return + visited.add(manifest_url) + + response = await aiorequests.get(f'{urlroot}{manifest_url}') + lines = (await response.text).splitlines() + for line_number, raw_line in enumerate(lines, start=1): + line = raw_line.strip() + if not line: + continue + fields = line.split(',') + if len(fields) < 4: + raise ValueError(f'invalid manifest line {manifest_url}:{line_number}') + + url, md5 = fields[0], fields[1] + if not url: + raise ValueError(f'empty asset URL at {manifest_url}:{line_number}') + if url.startswith('manifest/'): + await self._fetch_manifest(urlroot, url, category, registry, visited) + else: + if not md5: + raise ValueError(f'empty asset md5 at {manifest_url}:{line_number}') + registry[url] = (md5, category) + + async def _fetch_registry(self, ver: int) -> AssetRegistry: + registry: AssetRegistry = {} + urlroot = f'{self.manifest}/{MANIFEST_CATEGORY}/{ver}/' + await self._fetch_manifest( + urlroot, + ROOT_MANIFEST, + MANIFEST_CATEGORY, + registry, + set(), + ) + if not registry: + raise ValueError(f'remote manifest {ver} contains no assets') + return registry + + async def _load_registry(self, ver: int) -> AssetRegistry: + os.makedirs(self._manifest_dir(), exist_ok=True) + compact_path = self._compact_cache_path(ver) + legacy_path = self._legacy_cache_path(ver) + registry = None + + try: + registry = self._load_compact_cache(compact_path, ver) + logger.info(f'compact manifest version {ver} loaded from cache') + except FileNotFoundError: + pass + except (OSError, ValueError, TypeError, msgpack.ExtraData, msgpack.FormatError, msgpack.StackError) as e: + logger.warning(f'failed to load compact manifest version {ver}: {e}') + + if registry is None: + try: + registry = self._load_legacy_cache(legacy_path, ver) + logger.info(f'legacy manifest version {ver} loaded from cache') + except FileNotFoundError: + pass + except (OSError, ValueError, TypeError, json.JSONDecodeError) as e: + logger.warning(f'failed to load legacy manifest version {ver}: {e}') + + if registry is None: + registry = await self._fetch_registry(ver) + logger.info(f'manifest version {ver} loaded from remote') + + # registry 已在内存可用,缓存写失败(如磁盘满)不应阻断本次加载 + try: + self._write_compact_cache(compact_path, ver, registry) + except OSError as e: + logger.warning(f'failed to write compact manifest version {ver}: {e}') + + return registry + + def set_version(self, ver: int): + self.ver = ver + + async def _ensure_loaded_locked(self, ver: int): + if self._loaded_ver == ver: + return + + registry = await self._load_registry(ver) + self.registries = registry + self._loaded_ver = ver + + async def ensure_loaded(self, ver=None): + target_ver = self.ver if ver is None else ver + if target_ver is None: + raise RuntimeError('asset manifest version is not set') + + # 稳态无锁快路径:其原子性依赖"此检查与调用方读取 registries 之间没有 await 点", + # registries 与 _loaded_ver 在 _ensure_loaded_locked 中也是无挂起点地成对更新 + if self._loaded_ver == target_ver: + return + + # 在锁内重取版本号,避免等锁期间版本切换导致按旧版本多做一次重载 + async with self._manifest_lock: + target_ver = self.ver if ver is None else ver + await self._ensure_loaded_locked(target_ver) + + async def init(self, ver): + self.set_version(ver) + await self.ensure_loaded(ver) + + async def _resolve_asset(self, url: str) -> AssetEntry: + await self.ensure_loaded() + return self.registries[url] + + async def download(self, url: str) -> bytes: + logger.info(f"resolving {url}...") + + md5, category = await self._resolve_asset(url) + download_url = f'{self.pool}/{category}/{md5[:2]}/{md5}' + return await (await aiorequests.get(download_url)).content + + async def db(self) -> bytes: + ab = UnityPy.load(await self.download('a/masterdata_master.unity3d')) + asset = ab.objects[0].read() + return asset.script + + async def unit_icon(self, unit_id: int) -> bytes: + ab = UnityPy.load(await self.download(f'a/unit_icon_unit_{unit_id}.unity3d')) + for object in ab.objects: + if object.type == ClassIDType.Texture2D: + asset = object.read() + return asset.image + return None + + async def ex_equip_icon(self, equip_id: int) -> bytes: + ab = UnityPy.load(await self.download(f'a/icon_icon_extra_equip_{equip_id}.unity3d')) + for object in ab.objects: + if object.type == ClassIDType.Texture2D: + asset = object.read() + return asset.image + return None + + +# should lock before use +instance = assetmgr() diff --git a/autopcr/model/handlers.py b/autopcr/model/handlers.py index 559080b8..97b286c8 100644 --- a/autopcr/model/handlers.py +++ b/autopcr/model/handlers.py @@ -534,6 +534,9 @@ async def update(self, mgr: datamgr, request): mgr.dispatch_units = self.dispatch_units mgr.princess_knight_info = self.princess_knight_info mgr.unit_role_list = self.unit_role_list + if self.receive_labyrinth_passport_count: + num = mgr.get_inventory(db.labyrinth_ticket) + mgr.set_inventory(db.labyrinth_ticket, num + self.receive_labyrinth_passport_count) @handles class HomeIndexResponse(responses.HomeIndexResponse): diff --git a/autopcr/module/modules/autosweep.py b/autopcr/module/modules/autosweep.py index 4c018e20..d0b92ac7 100644 --- a/autopcr/module/modules/autosweep.py +++ b/autopcr/module/modules/autosweep.py @@ -484,6 +484,16 @@ class Type(IntEnum): (109201, Type.Sweep), # 安 (109301, Type.Sweep), # 露 (109401, Type.Sweep), # 龙女 + (100201, Type.Present), # ue 送515 + (104201, Type.NormalShop), # 千歌 + (104001, Type.Sweep), # 香菜弓 + (105201, Type.Sweep), # 羊驼 + (101801, Type.Present), # 老师 送515 + (103001, Type.Sweep), # 扇子 + (108101, Type.MasterShop), # 瓜忍 + (108301, Type.MasterShop), # 瓜眼 + (108401, Type.MasterShop), # 圣千 + (108601, Type.MasterShop), # 圣锤 ] @staticmethod diff --git a/autopcr/module/modules/caravan.py b/autopcr/module/modules/caravan.py index 8ad470e6..e8978c45 100644 --- a/autopcr/module/modules/caravan.py +++ b/autopcr/module/modules/caravan.py @@ -330,6 +330,8 @@ def get_block_change(self) -> Dict[eBlockType, eBlockType]: change_type = db.caravan_dish[effect.id].effect_value if change_type == 1: ret[eBlockType.MILES] = eBlockType.TREASURE + elif change_type == 2: + ret[eBlockType.MILES] = eBlockType.SHOP else: raise ValueError(f"Unknown CHANGE_BLOCK_TYPE effect value: {change_type}") return ret diff --git a/autopcr/module/modules/labyrinth.py b/autopcr/module/modules/labyrinth.py index a22e4a4e..5161508b 100644 --- a/autopcr/module/modules/labyrinth.py +++ b/autopcr/module/modules/labyrinth.py @@ -305,6 +305,13 @@ async def do_task(self, client: pcrclient): raise SkipError(f'当前黎明界票数为{ticket_count},不超过保留数量{ticket_hold}') top = await client.labyrinth_top() + + if top.enter_id: + self._log("检测到已有黎明界开局,先撤退。") + await client.labyrinth_retire(top.enter_id) + top = await client.labyrinth_top() + ticket_count = client.data.get_inventory(db.labyrinth_ticket) + difficulty = self._max_cleared_difficulty(top, guild_id) if difficulty is None: raise AbortError(f'公会{guild_id}尚未通关黎明界,无法扫荡!') diff --git a/autopcr/module/modules/mirage.py b/autopcr/module/modules/mirage.py index fb4b6bae..17dd403a 100644 --- a/autopcr/module/modules/mirage.py +++ b/autopcr/module/modules/mirage.py @@ -50,11 +50,11 @@ async def do_task(self, client: pcrclient): boss_info = db.mirage_nemesis_quest[nemesis_id] max_level = max(boss_info.keys()) - if boss.area_level < max_level and not_max_stop: - self._warn(f"最高等级{db.get_quest_name(boss_info[max_level].quest_id)}未通关,不扫荡\n如欲扫荡已通关的,请关闭「非最高不扫荡」") - continue if boss.periodic_clear_count < setting.challenge_count_max: to_skip.append((boss_info[boss.area_level].quest_id, setting.challenge_count_max - boss.periodic_clear_count)) + elif boss.area_level < max_level and not_max_stop: + self._warn(f"最高等级{db.get_quest_name(boss_info[max_level].quest_id)}未通关,不扫荡\n如欲扫荡已通关的,请关闭「非最高不扫荡」") + continue if not to_skip: if not self.log: raise SkipError("本周追忆战・霸已扫荡")