From 1687473e76d20b61e77ea24293e75565adfaa405 Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Fri, 27 Oct 2023 15:31:03 +0800 Subject: [PATCH 01/31] =?UTF-8?q?chore:=20=E8=A7=84=E8=8C=83=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E5=8F=82=E6=95=B0=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/calculated.py | 18 +++++++++--------- utils/relic.py | 34 ++++++++++++++++++---------------- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/utils/calculated.py b/utils/calculated.py index f85510f3..f487922a 100644 --- a/utils/calculated.py +++ b/utils/calculated.py @@ -602,7 +602,7 @@ def is_blackscreen(self, threshold = 30): screenshot = cv.cvtColor(self.take_screenshot()[0], cv.COLOR_BGR2GRAY) return cv.mean(screenshot)[0] < threshold - def ocr_pos(self, characters:str = None, points = (0,0,0,0)): + def ocr_pos(self, characters: Optional[str]=None, points = (0,0,0,0)): """ 说明: 获取指定文字的坐标 @@ -625,7 +625,8 @@ def ocr_pos(self, characters:str = None, points = (0,0,0,0)): pos = data[characters] if characters in data else None return characters, pos - def ocr_pos_for_single_line(self, characters_list:list[str] = None, points = (0,0,0,0), number = False, debug = False, img_pk:tuple = None) -> Union[int, str]: + def ocr_pos_for_single_line(self, characters_list: Optional[List[str]]=None, points=(0,0,0,0), number=False, debug=False, img_pk: Optional[Tuple]=None + ) -> Optional[Union[int, str]]: """ 说明: 获取指定坐标的单行文字 @@ -661,7 +662,7 @@ def read_img(self, path, prefix='./picture/pc/'): """ return cv.imread(f'{prefix}{path}') - def part_ocr(self, points = (0,0,0,0), debug=False, left=False, number = False, img_pk:tuple = None, is_single_line = False, only_white=False + def part_ocr(self, points = (0,0,0,0), debug=False, left=False, number=False, img_pk: Optional[tuple]=None, is_single_line=False, only_white=False ) -> Union[str, dict[str, tuple[int, int]]]: """ 说明: @@ -992,7 +993,7 @@ def change_team(self): class Array2dict: - def __init__(self, arr:np.ndarray, key_index:int = -1, value_index:int = None): + def __init__(self, arr: np.ndarray, key_index: int = -1, value_index: Optional[int]=None): """ 说明: 将np数组转化为字典暂住内存,用于对数组短时间内的频繁查找 @@ -1010,10 +1011,10 @@ def __init__(self, arr:np.ndarray, key_index:int = -1, value_index:int = None): self.data_dict = {row[key_index]: row[value_index] for row in arr} log.debug(self.data_dict) - def __getitem__(self, key): + def __getitem__(self, key: Any) -> Any: return self.data_dict[key] -def get_data_hash(data:Any, key_filter:list[str] = None) -> str: +def get_data_hash(data: Any, key_filter: Optional[List[str]]=None) -> str: """ 说明: 求任意类型数据 (包括list和dict) 的哈希值 @@ -1028,12 +1029,11 @@ def get_data_hash(data:Any, key_filter:list[str] = None) -> str: tmp_data = dict(data).copy() # 注意dict'='为引用传递,此处需拷贝副本 [tmp_data.pop(key) if key in tmp_data else None for key in key_filter] else: - log.eror(_("不支持dict以外类型的类型使用键值过滤器")) - return None + raise ValueError(_("不支持dict以外类型的类型使用键值过滤器")) # pprint默认sort_dicts=True,对键值进行排序,以确保字典类型的唯一性 return hashlib.md5(pprint.pformat(tmp_data).encode('utf-8')).hexdigest() -def str_just(text:str, width:int, left = True): +def str_just(text: str, width: int, left=True): """ 说明: 封装str.rjust()&str.ljust(),以适应中文字符的实际宽度 diff --git a/utils/relic.py b/utils/relic.py index f61a13f4..70992b70 100644 --- a/utils/relic.py +++ b/utils/relic.py @@ -5,6 +5,7 @@ import questionary import numpy as np from collections import Counter +from typing import Any, Dict, List, Literal, Optional, Tuple, Union from .calculated import calculated, Array2dict, get_data_hash, str_just from .config import (read_json_file, modify_json_file, rewrite_json_file, @@ -256,7 +257,7 @@ def equip_loadout_for_char(self): self.calculated.relative_click((97,6) if IS_PC else (96,5)) # 退出[遗器]界面,返回[人物]界面 time.sleep(1) - def equip_loadout(self, relics_hash:list[str]): + def equip_loadout(self, relics_hash:List[str]): """ 说明: 装备当前[人物]-[遗器]-[遗器详情]页面内的指定遗器配装。 @@ -324,7 +325,7 @@ def save_loadout_for_char(self): self.calculated.relative_click((97,6) if IS_PC else (96,5)) # 退出[遗器]界面,返回[人物]界面 time.sleep(2) - def save_loadout(self, character_name:str=None, max_retries=3): + def save_loadout(self, character_name: Optional[str]=None, max_retries=3): """ 说明: 保存当前[人物]-[遗器]-[遗器详情]界面内的遗器配装 @@ -367,7 +368,7 @@ class Relic_filter: rarity_pos_list = [(77,38),(89,38),(77,42),(89,42)] if IS_PC else [(71,45),(86,45),(71,52),(86,52)] """稀有度筛选项的点击位点 (分别为2,3,4,5星稀有度)""" - def __init__(self, calculated:calculated): + def __init__(self, calculated: calculated): self.calculated = calculated # 记录上一次的筛选状态 self.pre_relic_set_index = -1 @@ -375,7 +376,7 @@ def __init__(self, calculated:calculated): self.pre_rarity = -1 """过去稀有度""" - def do(self, relic_set_index:int, rairty:int): + def do(self, relic_set_index: int, rairty: int): """ 说明: 在当前[人物]-[遗器]-[遗器详情]内进行遗器筛选 @@ -411,7 +412,7 @@ def do(self, relic_set_index:int, rairty:int): ... # 其他筛选条件 self.calculated.relative_click((3,92) if IS_PC else (4,92)) # 任意点击筛选框外退出[遗器筛选]界面 - def search_relic_set_for_filter(self, relic_set_index:int): + def search_relic_set_for_filter(self, relic_set_index: int): """ 说明: 在当前滑动[人物]-[遗器]-[遗器详情]-[遗器筛选]-[遗器套装筛选]界面内,搜索遗器套装名,并点击。 @@ -434,7 +435,8 @@ def search_relic_set_for_filter(self, relic_set_index:int): self.calculated.ocr_click(Relic.relic_set_name[relic_set_index, 1], points=points) - def search_relic(self, equip_indx:int, key_hash:str=None, key_data:dict=None, overtime=180, max_retries=3) -> tuple[int, int]: + def search_relic(self, equip_indx: int, key_hash: Optional[str]=None, key_data: Optional[Dict[str, Any]]=None, overtime=180, max_retries=3 + ) -> Optional[tuple[int, int]]: """ 说明: 在当前滑动[人物]-[遗器]-[遗器详情]界面内,搜索匹配的遗器。 @@ -496,7 +498,7 @@ def search_relic(self, equip_indx:int, key_hash:str=None, key_data:dict=None, ov break return None - def compare_relics(self, old_data:dict, new_data:dict) -> bool: + def compare_relics(self, old_data: Dict[str, Any], new_data: Dict[str, Any]) -> bool: """ 说明: 比对两者遗器数据,判断新遗器是否为旧遗器升级后 @@ -514,7 +516,7 @@ def compare_relics(self, old_data:dict, new_data:dict) -> bool: return False return True - def check_relic_data_hash(self, updata=False): + def check_relic_data_hash(self, updata=False) -> bool: """ 说明: 检查遗器数据是否发生手动修改 (应对json数据格式变动或手动矫正仪器数值), @@ -541,7 +543,7 @@ def check_relic_data_hash(self, updata=False): log.error(_(f"发现 {cnt} 件遗器的哈希值校验失败")) return False - def updata_relic_data(self, old_hash:str, new_hash:str, equip_indx:int, new_data:dict=None, delete_old_data=False): + def updata_relic_data(self, old_hash: str, new_hash: str, equip_indx: int, new_data: Optional[Dict[str, Any]]=None, delete_old_data=False): """ 说明: 更改仪器数据,先后修改遗器与配装文件 @@ -568,7 +570,7 @@ def updata_relic_data(self, old_hash:str, new_hash:str, equip_indx:int, new_data rewrite_json_file(LOADOUT_FILE_NAME, self.loadout_data) # 队伍配装文件无需修改 - def add_relic_data(self, data:dict, data_hash:str=None) -> bool: + def add_relic_data(self, data: Dict[str, Any], data_hash: Optional[str]=None) -> bool: """ 说明: 录入仪器数据 @@ -597,7 +599,7 @@ def ocr_character_name(self) -> str: log.info(_("创建新人物")) return character_name - def try_ocr_relic(self, equip_set_index:int = None, max_retries = 3) -> dict: + def try_ocr_relic(self, equip_set_index:Optional[int]=None, max_retries=3) -> Dict[str, Any]: """ 说明: 在规定次数内尝试OCR遗器数据 @@ -618,7 +620,7 @@ def try_ocr_relic(self, equip_set_index:int = None, max_retries = 3) -> dict: retry += 1 log.info(_(f"第 {retry} 次尝试重新OCR")) - def ocr_relic(self, equip_set_index:int = None) -> dict: + def ocr_relic(self, equip_set_index: Optional[int]=None) -> Dict[str, Any]: """ 说明: OCR当前静态[人物]-[遗器]-[遗器详情]界面内的遗器数据,单次用时约0.5s。 @@ -643,7 +645,7 @@ def ocr_relic(self, equip_set_index:int = None) -> dict: if relic_set_index < 0: raise RelicOCRException(_("遗器部位OCR错误")) relic_set_name = self.relic_set_name[relic_set_index, -1] - # [3] 稀有度识别 + # [3]稀有度识别 hue, __, __ = self.calculated.get_relative_pix_hsv((43,55) if IS_PC else (41,55)) # 识别指定位点色相 log.debug(f"hue = {hue}") if hue < 40: # [黄]模拟器测试结果的均值为 25 @@ -721,7 +723,7 @@ def ocr_relic(self, equip_set_index:int = None) -> dict: log.info(f"用时\033[1;92m『{seconds:.1f}秒』\033[0m") return result_data - def print_relic(self, data:dict): + def print_relic(self, data: Dict[str, Any]): """ 说明: 打印遗器信息, @@ -753,7 +755,7 @@ def print_relic(self, data:dict): print(_(" {name:<4}\t{value:>5}{pre} [ERROR]").format(name=name, value=value, pre=pre)) print('-'*50) - def get_loadout_brief(self, relics_hash:list[str]) -> str: + def get_loadout_brief(self, relics_hash: List[str]) -> str: """ 说明: 获取配装的简要信息 (包含内外圈套装信息与主词条信息) @@ -780,7 +782,7 @@ def get_loadout_brief(self, relics_hash:list[str]) -> str: msg += ".".join([name for idx, name in enumerate(base_stats_list) if idx > 1]) # 排除头部与手部 return msg - def get_subs_stats_detail(self, data:tuple[str, float], rarity:int, stats_index:int = None) -> tuple[int, int, float]: + def get_subs_stats_detail(self, data: Tuple[str, float], rarity: int, stats_index: Optional[int]=None) -> Optional[Tuple[int, int, float]]: """ 说明: 计算副词条的详细信息 (如强化次数、档位积分,以及提高原数据的小数精度) From f543c52e81d65e9595dad4bc5aafd257bfa90604 Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Sat, 28 Oct 2023 21:24:30 +0800 Subject: [PATCH 02/31] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=81=97?= =?UTF-8?q?=E5=99=A8=E4=BF=A1=E6=81=AF=E6=89=93=E5=8D=B0=E6=97=B6=E7=9A=84?= =?UTF-8?q?=E5=B0=8F=E6=95=B0=E7=B2=BE=E5=BA=A6=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/config.py | 2 ++ utils/relic.py | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/utils/config.py b/utils/config.py index 105acfd7..9cfbe912 100644 --- a/utils/config.py +++ b/utils/config.py @@ -339,6 +339,8 @@ class SRAData(metaclass=SRADataMeta): """是否在遗器OCR时开启对副词条的数据验证""" detail_for_relic: bool = True """是否在打印遗器信息时显示拓展信息""" + ndigits_for_relic: int = 2 + """在打印遗器信息时的小数精度""" def __init__(self) -> None: diff --git a/utils/relic.py b/utils/relic.py index 70992b70..4e0f791b 100644 --- a/utils/relic.py +++ b/utils/relic.py @@ -181,6 +181,8 @@ def __init__(self, title=_("崩坏:星穹铁道")): """是否在遗器OCR时开启对副词条的数据验证 (关闭后,会将is_detail强制关闭)""" self.is_detail = sra_config_obj.detail_for_relic and self.is_check_stats """是否在打印遗器信息时显示详细信息 (如各副词条的强化次数、档位积分,以及提高原数据的小数精度)""" + self.ndigits: Literal[0, 1, 2, 3] = sra_config_obj.ndigits_for_relic + """在打印遗器信息时的小数精度""" # 读取json文件,仅初始化时检查格式规范 self.relics_data = read_json_file(RELIC_FILE_NAME, schema=self.relics_schema) @@ -750,7 +752,8 @@ def print_relic(self, data: Dict[str, Any]): if ret: # 数据校验成功 level, score, result = ret tag = '>'*(level-1) # 强化次数的标识 - print(_(" {name:<4}\t{tag:<7}{value:>6.3f}{pre} [{score}]").format(name=name, tag=tag, value=result, score=score, pre=pre)) + print(_(" {name:<4}\t{tag:<7}{value:>7.{ndigits}f}{pre} [{score}]"). + format(name=name, tag=tag, value=result, score=score, pre=pre, ndigits=self.ndigits)) else: # 数据校验失败 print(_(" {name:<4}\t{value:>5}{pre} [ERROR]").format(name=name, value=value, pre=pre)) print('-'*50) @@ -814,7 +817,7 @@ def get_subs_stats_detail(self, data: Tuple[str, float], rarity: int, stats_inde if score < 0: # 总分小于零打补丁 (由于真实总分过大导致) level -= 1 score = math.ceil((value - a_*level) / d - 1.e-6) - result = round(a*level + d*score, 3) # 四舍五入 (考虑浮点数运算的数值损失) + result = round(a*level + d*score, 4) # 四舍五入 (考虑浮点数运算的数值损失) # 校验数据 check = result - value log.debug(f"[{a}, {d}], l={level}, s={score}, r={result}") From 465a700b1c5105c66f1ed4ef0f0d51498b1e7b79 Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Sat, 28 Oct 2023 21:29:36 +0800 Subject: [PATCH 03/31] =?UTF-8?q?chore:=20=E6=B7=BB=E5=8A=A0=E5=9B=9B?= =?UTF-8?q?=E6=98=9F=E4=BA=94=E6=98=9F=E9=81=97=E5=99=A8=E7=9A=84=E4=B8=BB?= =?UTF-8?q?=E8=AF=8D=E6=9D=A1=E7=BA=A7=E5=88=AB=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/relic.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/utils/relic.py b/utils/relic.py index 4e0f791b..91ae5257 100644 --- a/utils/relic.py +++ b/utils/relic.py @@ -103,6 +103,14 @@ class Relic: [(33.870, 4.233755), (16.935 , 2.116877), (16.935 , 2.116877), (3.4560, 0.4320), (3.4560, 0.4320), (4.320, 0.5400), # 五星遗器数值 (2.00, 0.30), (2.5920, 0.3240), (5.1840, 0.6480), (3.4560, 0.4320), (3.4560, 0.4320), (5.1840, 0.6480)]] """副属性词条档位:t0-基础值,t1-每提升一档的数值;l1-四星遗器数值,l2-五星遗器数值 <<数据来源:米游社@666bj>>""" + base_stats_tier = [ + [( 90.3168, 31.61088), (45.1584, 15.80544), (5.5296, 1.9354), (5.5296, 1.9354), (6.9120, 2.4192), (3.2256, 1.1), # 四星遗器数值 + (4.1472, 1.4515), ( 8.2944, 2.9030), (5.5296, 1.9354), (4.4237, 1.5483), (4.9766, 1.7418), ( 8.2944, 2.9030), (2.4883, 0.8709)], + [(112.896, 39.5136 ), (56.448, 19.7568 ), (6.9120, 2.4192), (6.9120, 2.4192), (8.6400, 3.0240), (4.032, 1.4), # 五星遗器数值 + (5.1840, 1.8144), (10.3680, 3.6288), (6.9120, 2.4192), (5.5296, 1.9354), (6.2208, 2.1773), (10.3680, 3.6288), (3.1104, 1.0886)]] + """主属性词条级别:t0-基础值,t1-每提升一级的数值;l1-四星遗器数值,l2-五星遗器数值 <<数据来源:米游社@666bj>>""" + for i in range(len(base_stats_tier)): + base_stats_tier[i][10:10] = [base_stats_tier[i][10]] * 6 # 复制属性伤害 # json数据格式规范 relics_schema = { # 遗器数据集 From c0364394a6b7705d1c41a4c72f2ed0533ae6b9c1 Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Sat, 28 Oct 2023 21:42:49 +0800 Subject: [PATCH 04/31] =?UTF-8?q?chore:=20=E4=BC=98=E5=8C=96print=5Frelic?= =?UTF-8?q?=E5=87=BD=E6=95=B0=EF=BC=8C=E4=BD=BF=E9=81=97=E5=99=A8=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E4=B8=8D=E4=BC=9A=E8=A2=AB=E5=85=B6=E4=BB=96=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E4=B8=AD=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/relic.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/utils/relic.py b/utils/relic.py index 91ae5257..715a3fca 100644 --- a/utils/relic.py +++ b/utils/relic.py @@ -739,20 +739,21 @@ def print_relic(self, data: Dict[str, Any]): 打印遗器信息, 可通过is_detail设置打印普通信息与拓展信息 """ - print(_("部位: {equip_set}").format(equip_set=data["equip_set"])) - print(_("套装: {relic_set}").format(relic_set=data["relic_set"])) - print(_("星级: {star}").format(star='★'*data["rarity"])) - print(_("等级: +{level}").format(level=data["level"])) - print(_("主词条:")) + token = [] + token.append(_("部位: {equip_set}").format(equip_set=data["equip_set"])) + token.append(_("套装: {relic_set}").format(relic_set=data["relic_set"])) + token.append(_("星级: {star}").format(star='★'*data["rarity"])) + token.append(_("等级: +{level}").format(level=data["level"])) + token.append(_("主词条:")) name, value = list(data["base_stats"].items())[0] pre = " " if name in self.not_pre_stats else "%" - print(_(" {name:<4}\t{value:>5}{pre}").format(name=name, value=value, pre=pre)) - print(_("副词条:")) + token.append(_(" {name:<4}\t{value:>5}{pre}").format(name=name, value=value, pre=pre)) + token.append(_("副词条:")) subs_stats_dict = Array2dict(self.subs_stats_name) for name, value in data["subs_stats"].items(): pre = " " if name in self.not_pre_stats else "%" if not self.is_detail or data["rarity"] not in [4,5]: # 不满足校验条件 - print(_(" {name:<4}\t{value:>5}{pre}").format(name=name, value=value, pre=pre)) + token.append(_(" {name:<4}\t{value:>5}{pre}").format(name=name, value=value, pre=pre)) continue stats_index = subs_stats_dict[name] # 增强信息并校验数据 @@ -760,11 +761,12 @@ def print_relic(self, data: Dict[str, Any]): if ret: # 数据校验成功 level, score, result = ret tag = '>'*(level-1) # 强化次数的标识 - print(_(" {name:<4}\t{tag:<7}{value:>7.{ndigits}f}{pre} [{score}]"). + token.append(_(" {name:<4}\t{tag:<7}{value:>7.{ndigits}f}{pre} [{score}]"). format(name=name, tag=tag, value=result, score=score, pre=pre, ndigits=self.ndigits)) else: # 数据校验失败 - print(_(" {name:<4}\t{value:>5}{pre} [ERROR]").format(name=name, value=value, pre=pre)) - print('-'*50) + token.append(_(" {name:<4}\t{value:>5}{pre} [ERROR]").format(name=name, value=value, pre=pre)) + token.append('-'*50) + print("\n".join(token)) def get_loadout_brief(self, relics_hash: List[str]) -> str: """ From 4c6e31f2b041705c48d9aa4760f75d742946c3ff Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Sat, 28 Oct 2023 21:46:49 +0800 Subject: [PATCH 05/31] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=81=97?= =?UTF-8?q?=E5=99=A8=E4=B8=BB=E8=AF=8D=E6=9D=A1=E6=95=B0=E6=8D=AE=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E7=B2=BE=E5=BA=A6=E6=8F=90=E5=8D=87=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/relic.py | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/utils/relic.py b/utils/relic.py index 715a3fca..9b254c48 100644 --- a/utils/relic.py +++ b/utils/relic.py @@ -747,7 +747,11 @@ def print_relic(self, data: Dict[str, Any]): token.append(_("主词条:")) name, value = list(data["base_stats"].items())[0] pre = " " if name in self.not_pre_stats else "%" - token.append(_(" {name:<4}\t{value:>5}{pre}").format(name=name, value=value, pre=pre)) + result = self.get_base_stats_detail((name, value), data["rarity"], data["level"]) + if result: + token.append(_(" {name:<4}\t{value:>7.{ndigits}f}{pre}").format(name=name, value=result, pre=pre, ndigits=self.ndigits)) + else: + token.append(_(" {name:<4}\t{value:>5}{pre} [ERROR]").format(name=name, value=value, pre=pre)) token.append(_("副词条:")) subs_stats_dict = Array2dict(self.subs_stats_name) for name, value in data["subs_stats"].items(): @@ -795,6 +799,37 @@ def get_loadout_brief(self, relics_hash: List[str]) -> str: msg += ".".join([name for idx, name in enumerate(base_stats_list) if idx > 1]) # 排除头部与手部 return msg + def get_base_stats_detail(self, data: Tuple[str, float], rarity: int, level: int, stats_index: Optional[int]=None) -> Optional[float]: + """ + 说明: + 计算主词条的详细信息 (提高原数据的小数精度) + 可以作为主词条校验函数 (可以检测出大部分的OCR错误) + 支持五星遗器与四星遗器 + 参数: + :param data: 遗器副词条键值对 + :param stats_index: 遗器副词条索引 + :param rarity: 遗器稀有度 + :param level: 遗器等级 + 返回: + :return result: 修正后数值 (提高了原数值精度) + """ + name, value = data + if not self.is_check_stats or rarity not in [4,5]: # 仅支持五星遗器与四星遗器 + return value + stats_index = np.where(self.base_stats_name[:, -1] == name)[0][0] if stats_index is None else stats_index + rarity_index = rarity - 4 # 0-四星,1-五星 + a, d = self.base_stats_tier[rarity_index][stats_index] + result = round(a + d*level, 4) # 四舍五入 (考虑浮点数运算的数值损失) + # 校验数据 + check = result - value + log.debug(f"[{a}, {d}], l={level} r={result}") + if check < 0 or \ + name in self.not_pre_stats and check >= 1 or \ + name not in self.not_pre_stats and check >= 0.1: + log.error(_(f"校验失败,原数据或计算方法有误: {data}")) + return None + return result + def get_subs_stats_detail(self, data: Tuple[str, float], rarity: int, stats_index: Optional[int]=None) -> Optional[Tuple[int, int, float]]: """ 说明: From abdec80b25e483b725fae7f9a9ca2dc9b811d123 Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Sun, 29 Oct 2023 13:52:14 +0800 Subject: [PATCH 06/31] =?UTF-8?q?feat:=20=E5=8F=AF=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E4=BF=AE=E6=94=B9json=E6=96=87=E4=BB=B6=E4=B8=AD=E9=80=9F?= =?UTF-8?q?=E5=BA=A6=E5=B1=9E=E6=80=A7=E7=9A=84=E5=B0=8F=E6=95=B0=E4=BD=8D?= =?UTF-8?q?=EF=BC=8C=E5=90=8C=E6=97=B6=E4=B8=8D=E5=BD=B1=E5=93=8D=E9=81=97?= =?UTF-8?q?=E5=99=A8=E5=93=88=E5=B8=8C=E5=80=BC=E8=AE=A1=E7=AE=97=E4=B8=8E?= =?UTF-8?q?=E6=A8=A1=E7=B3=8A=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/calculated.py | 10 ++++++---- utils/relic.py | 7 ++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/utils/calculated.py b/utils/calculated.py index f487922a..16c5ab52 100644 --- a/utils/calculated.py +++ b/utils/calculated.py @@ -1014,7 +1014,7 @@ def __init__(self, arr: np.ndarray, key_index: int = -1, value_index: Optional[i def __getitem__(self, key: Any) -> Any: return self.data_dict[key] -def get_data_hash(data: Any, key_filter: Optional[List[str]]=None) -> str: +def get_data_hash(data: Any, key_filter: Optional[List[str]]=None, speed_modified=False) -> str: """ 说明: 求任意类型数据 (包括list和dict) 的哈希值 @@ -1022,12 +1022,14 @@ def get_data_hash(data: Any, key_filter: Optional[List[str]]=None) -> str: 参数: :param data: 任意类型数据 :param key_filter: 键值过滤器 + :param speed_modified: 是否对速度属性进行修饰 (忽略小数位数值) """ if not key_filter: tmp_data = data - elif type(data) is dict: - tmp_data = dict(data).copy() # 注意dict'='为引用传递,此处需拷贝副本 - [tmp_data.pop(key) if key in tmp_data else None for key in key_filter] + elif isinstance(data, dict): + tmp_data = {key: value for key, value in data.items() if key not in key_filter} + if speed_modified and _("速度") in tmp_data["subs_stats"]: + tmp_data["subs_stats"][_("速度")] = float(int(tmp_data["subs_stats"][_("速度")])) # 去除小数部分 else: raise ValueError(_("不支持dict以外类型的类型使用键值过滤器")) # pprint默认sort_dicts=True,对键值进行排序,以确保字典类型的唯一性 diff --git a/utils/relic.py b/utils/relic.py index 9b254c48..f0ef4232 100644 --- a/utils/relic.py +++ b/utils/relic.py @@ -522,8 +522,9 @@ def compare_relics(self, old_data: Dict[str, Any], new_data: Dict[str, Any]) -> for key in old_data["subs_stats"].keys(): if key not in new_data["subs_stats"]: return False - if old_data["subs_stats"][key] > new_data["subs_stats"][key]: - return False + if key == _("速度") and int(old_data["subs_stats"][key]) > int(new_data["subs_stats"][key]) or \ + key != _("速度") and old_data["subs_stats"][key] > new_data["subs_stats"][key]: + return False # 考虑手动提高速度数据精度的情况 return True def check_relic_data_hash(self, updata=False) -> bool: @@ -536,7 +537,7 @@ def check_relic_data_hash(self, updata=False) -> bool: relics_data_copy = self.relics_data.copy() # 字典迭代过程中不允许修改key cnt = 0 for old_hash, data in relics_data_copy.items(): - new_hash = get_data_hash(data, self.relic_data_filter) + new_hash = get_data_hash(data, self.relic_data_filter, speed_modified=True) if old_hash != new_hash: equip_indx = equip_set_dict[data["equip_set"]] log.debug(f"(old={old_hash}, new={new_hash})") From ffc35028a90b72fb06fb4ac6755621d6f315a2fb Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Sun, 29 Oct 2023 14:51:06 +0800 Subject: [PATCH 07/31] =?UTF-8?q?chore:=20=E5=B0=86=E9=81=97=E5=99=A8?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E7=9A=84=E9=9D=99=E6=80=81=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E7=A7=BB=E8=87=B3=E7=8B=AC=E7=AB=8B=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/relic.py | 193 +++++++-------------------------------- utils/relic_constants.py | 178 ++++++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+), 161 deletions(-) create mode 100644 utils/relic_constants.py diff --git a/utils/relic.py b/utils/relic.py index f0ef4232..4f344a34 100644 --- a/utils/relic.py +++ b/utils/relic.py @@ -7,6 +7,7 @@ from collections import Counter from typing import Any, Dict, List, Literal, Optional, Tuple, Union +from .relic_constants import * from .calculated import calculated, Array2dict, get_data_hash, str_just from .config import (read_json_file, modify_json_file, rewrite_json_file, RELIC_FILE_NAME, LOADOUT_FILE_NAME, TEAM_FILE_NAME, _, sra_config_obj) @@ -43,136 +44,6 @@ class Relic: 1.[新增]本模块的所有识别位点均采用百分比相对坐标,以兼容不同平台支持不同分辨率 2.[新增]本模块首先会基于安卓模拟器进行测试,再基于PC端测试 """ - # 静态参数 - equip_set_name = [_("头部"), _("手部"), _("躯干"), _("脚部"), _("位面球"), _("连结绳")] - """遗器部位名称,已经按游戏界面顺序排序""" - equip_set_abbr = [_("头"), _("手"), _("衣"), _("鞋"), _("球"), _("绳")] - """遗器部位简称""" - relic_set_name = np.array([ # 注:因为数据有时要行取有时要列取,故采用数组存储 - [_("过客"), _("过客"), _("治疗"), _("云无留迹的过客")], - [_("枪手"), _("枪手"), _("快枪手"), _("野穗伴行的快枪手")], - [_("圣骑"), _("圣骑"), _("防御"), _("净庭教宗的圣骑士")], - [_("雪猎"), _("猎人"), _("冰套"), _("密林卧雪的猎人")], - [_("拳王"), _("拳王"), _("物理"), _("街头出身的拳王")], - [_("铁卫"), _("铁卫"), _("减伤"), _("戍卫风雪的铁卫")], - [_("火匠"), _("火匠"), _("火套"), _("熔岩锻铸的火匠")], - [_("天才"), _("天才"), _("量子"), _("繁星璀璨的天才")], - [_("乐队"), _("雷电"), _("雷套"), _("激奏雷电的乐队")], - [_("翔"), _("翔"), _("风套"), _("晨昏交界的翔鹰")], - [_("怪盗"), _("怪盗"), _("怪盗"), _("流星追迹的怪盗")], - [_("废"), _("废"), _("虚数"), _("盗匪荒漠的废土客")], - [_("者"), _("长存"), _("莳者"), _("宝命长存的莳者")], - [_("信使"), _("信使"), _("速度"), _("骇域漫游的信使")], - [_("黑塔"), _("太空"), _("空间站"), _("太空封印站")], - [_("仙"), _("仙"), _("仙舟"), _("不老者的仙舟")], - [_("公司"), _("公司"), _("命中"), _("泛银河商业公司")], - [_("贝洛"), _("贝洛"), _("防御"), _("筑城者的贝洛伯格")], - [_("螺丝"), _("差分"), _("差分"), _("星体差分机")], - [_("萨尔"), _("停转"), _("停转"), _("停转的萨尔索图")], - [_("利亚"), _("盗贼"), _("击破"), _("盗贼公国塔利亚")], - [_("瓦克"), _("瓦克"), _("翁瓦克"), _("生命的翁瓦克")], - [_("泰科"), _("繁星"), _("繁星"), _("繁星竞技场")], - [_("伊须"), _("龙骨"), _("龙骨"), _("折断的龙骨")] - ], dtype=np.str_) - """遗器套装名称:0-套装散件名的共有词(ocr-必须),1-套装名的特异词(ocr-可选,为了增强鲁棒性),2-玩家惯用简称(print),3-套装全称(json),已按[1.4游戏]遗器筛选界面排序""" - stats_name = np.array([ - [_("命值"), _("生"), _("生命值")], [_("击力"), _("攻"), _("攻击力")], [_("防御"), _("防"), _("防御力")], - [_("命值"), _("生"), _("生命值%")], [_("击力"), _("攻"), _("攻击力%")], [_("防御"), _("防"), _("防御力%")], - [_("度"), _("速"), _("速度")], [_("击率"), _("暴击"), _("暴击率")], [_("击伤"), _("爆伤"), _("暴击伤害")], [_("命中"), _("命中"), _("效果命中")], [_("治疗"), _("治疗"), _("治疗量加成")], - [_("理"), _("伤害"), _("物理属性伤害")], [_("火"), _("火伤"), _("火属性伤害")], [_("冰"), _("冰伤"), _("冰属性伤害")], [_("雷"), _("雷伤"), _("雷属性伤害")], [_("风"), _("风伤"), _("风属性伤害")], - [_("量"), _("量子"), _("量子属性伤害")], [_("数"), _("虚数"), _("虚数属性伤害")], - [_("抵抗"), _("效果抵抗"), _("效果抵抗")], [_("破"), _("击破"), _("击破特攻")], [_("恢复"), _("能"), _("能量恢复效率")] - ], dtype=np.str_) - """遗器属性名称:0-属性名的特异词(ocr-不区分大小词条),1-玩家惯用简称(print),2-属性全称(json-区分大小词条)""" - not_pre_stats = [_("生命值"), _("攻击力"), _("防御力"), _("速度")] - """遗器的整数属性名称""" - base_stats_name = np.concatenate((stats_name[:2],stats_name[3:-3],stats_name[-2:]), axis=0) - """遗器主属性名称""" - base_stats_name4equip = [base_stats_name[0:1], - base_stats_name[1:2], - np.vstack((base_stats_name[2:5],base_stats_name[6:10])), - base_stats_name[2:6], - np.vstack((base_stats_name[2:5],base_stats_name[10:17])), - np.vstack((base_stats_name[2:5],base_stats_name[-2:]))] - """遗器各部位主属性名称""" - subs_stats_name = np.vstack((stats_name[:10],stats_name[-3:-1])) - """遗器副属性名称,已按副词条顺序排序""" - subs_stats_tier = [ - [(27.096, 3.3870 ), (13.548 , 1.6935 ), (13.548 , 1.6935 ), (2.7648, 0.3456), (2.7648, 0.3456), (3.456, 0.4320), # 四星遗器数值 - (1.60, 0.20), (2.0736, 0.2592), (4.1472, 0.5184), (2.7648, 0.3456), (2.7648, 0.3456), (4.1472, 0.5184)], - [(33.870, 4.233755), (16.935 , 2.116877), (16.935 , 2.116877), (3.4560, 0.4320), (3.4560, 0.4320), (4.320, 0.5400), # 五星遗器数值 - (2.00, 0.30), (2.5920, 0.3240), (5.1840, 0.6480), (3.4560, 0.4320), (3.4560, 0.4320), (5.1840, 0.6480)]] - """副属性词条档位:t0-基础值,t1-每提升一档的数值;l1-四星遗器数值,l2-五星遗器数值 <<数据来源:米游社@666bj>>""" - base_stats_tier = [ - [( 90.3168, 31.61088), (45.1584, 15.80544), (5.5296, 1.9354), (5.5296, 1.9354), (6.9120, 2.4192), (3.2256, 1.1), # 四星遗器数值 - (4.1472, 1.4515), ( 8.2944, 2.9030), (5.5296, 1.9354), (4.4237, 1.5483), (4.9766, 1.7418), ( 8.2944, 2.9030), (2.4883, 0.8709)], - [(112.896, 39.5136 ), (56.448, 19.7568 ), (6.9120, 2.4192), (6.9120, 2.4192), (8.6400, 3.0240), (4.032, 1.4), # 五星遗器数值 - (5.1840, 1.8144), (10.3680, 3.6288), (6.9120, 2.4192), (5.5296, 1.9354), (6.2208, 2.1773), (10.3680, 3.6288), (3.1104, 1.0886)]] - """主属性词条级别:t0-基础值,t1-每提升一级的数值;l1-四星遗器数值,l2-五星遗器数值 <<数据来源:米游社@666bj>>""" - for i in range(len(base_stats_tier)): - base_stats_tier[i][10:10] = [base_stats_tier[i][10]] * 6 # 复制属性伤害 - - # json数据格式规范 - relics_schema = { # 遗器数据集 - "type": "object", - "additionalProperties": { # [主键]遗器哈希值 (由其键值遗器数据自动生成) - "type": "object", - "properties": { - "equip_set": { # 遗器部位 - "type": "string", - "enum": equip_set_name }, - "relic_set": { # 遗器套装 - "type": "string", - "enum": relic_set_name[:, -1].tolist() }, - "rarity": { # 遗器稀有度 (2-5星) - "type": "integer", - "minimum": 2, - "maximum": 5 }, - "level": { # 遗器等级 (0-15级) - "type": "integer", - "minimum": 0, - "maximum": 15 }, - "base_stats": { # 遗器主属性 (词条数为 1) - "type": "object", - "minProperties": 1, - "maxProperties": 1, - "properties": { - key: {"type": "number"} for key in base_stats_name[:, -1]}, - "additionalProperties": False }, - "subs_stats": { # 遗器副属性 (词条数为 1-4) - "type": "object", - "minProperties": 1, - "maxProperties": 4, - "properties": { - key: {"type": "number"} for key in subs_stats_name[:, -1]}, - "additionalProperties": False }, - "pre_ver_hash": {"type": "string"} # [外键]本次升级前的版本 - }, - "required": ["relic_set", "equip_set", "rarity", "level", "base_stats", "subs_stats"], # 需包含遗器的全部固有属性 - "additionalProperties": False - }} - loadout_schema = { # 人物遗器配装数据集 - "type": "object", - "additionalProperties": { # [主键]人物名称 (以OCR结果为准) - "type": "object", - "additionalProperties": { # [次主键]配装名称 (自定义) - "type": "array", # 配装组成 (6件遗器,按部位排序) - "minItems": 6, - "maxItems": 6, - "items": {"type": "string"} # [外键]遗器哈希值 - }}} - team_schema = { # 队伍遗器配装数据集 - "type": "object", - "additionalProperties": { # [主键]队伍名称 (自定义) - "type": "object", - "additionalProperties": { # [外键]队伍成员名称 (以OCR结果为准) - "type": "string" # [外键]各队伍成员的配装名称 - }, - "minProperties": 1, - "maxProperties": 4 - }} - relic_data_filter = ["pre_ver_hash"] - """遗器数据过滤器""" def __init__(self, title=_("崩坏:星穹铁道")): """ @@ -193,9 +64,9 @@ def __init__(self, title=_("崩坏:星穹铁道")): """在打印遗器信息时的小数精度""" # 读取json文件,仅初始化时检查格式规范 - self.relics_data = read_json_file(RELIC_FILE_NAME, schema=self.relics_schema) - self.loadout_data = read_json_file(LOADOUT_FILE_NAME, schema=self.loadout_schema) - self.team_data = read_json_file(TEAM_FILE_NAME, schema=self.team_schema) + self.relics_data = read_json_file(RELIC_FILE_NAME, schema = RELIC_SCHEMA) + self.loadout_data = read_json_file(LOADOUT_FILE_NAME, schema = LOADOUT_SCHEMA) + self.team_data = read_json_file(TEAM_FILE_NAME, schema = TEAM_SCHEMA) log.info(_("遗器数据载入完成")) log.info(_(f"共载入 {len(list(self.relics_data.keys()))} 件遗器数据")) @@ -277,7 +148,7 @@ def equip_loadout(self, relics_hash:List[str]): """ equip_pos_list = [(4,13),(9,13),(13,13),(18,13),(23,13),(27,13)] if IS_PC else [(5,14),(11,14),(17,14),(23,14),(28,14),(34,14)] relic_filter = self.Relic_filter(self.calculated) # 遗器筛选器初始化 - relic_set_name_dict = Array2dict(self.relic_set_name) + relic_set_name_dict = Array2dict(RELIC_SET_NAME) for equip_indx, equip_pos in enumerate(equip_pos_list): # 遗器部位循环 # 选择部位 log.info(_(f"选择部位:{self.equip_set_name[equip_indx]}")) @@ -349,7 +220,7 @@ def save_loadout(self, character_name: Optional[str]=None, max_retries=3): self.calculated.relative_click(equip_pos) time.sleep(1) tmp_data = self.try_ocr_relic(equip_indx, max_retries) - tmp_hash = get_data_hash(tmp_data, self.relic_data_filter) + tmp_hash = get_data_hash(tmp_data, RELIC_DATA_FILTER) log.debug("\n"+pp.pformat(tmp_data)) self.print_relic(tmp_data) if tmp_hash in self.relics_data: @@ -442,7 +313,7 @@ def search_relic_set_for_filter(self, relic_set_index: int): time.sleep(0.5) self.calculated.relative_click((35,35) if IS_PC else (35,32)) # 取消选中 points = ((28,33,42,63) if is_left else (53,33,67,63)) if IS_PC else ((22,29,41,65) if is_left else (53,29,72,65)) - self.calculated.ocr_click(Relic.relic_set_name[relic_set_index, 1], points=points) + self.calculated.ocr_click(RELIC_SET_NAME[relic_set_index, 1], points=points) def search_relic(self, equip_indx: int, key_hash: Optional[str]=None, key_data: Optional[Dict[str, Any]]=None, overtime=180, max_retries=3 @@ -533,11 +404,11 @@ def check_relic_data_hash(self, updata=False) -> bool: 检查遗器数据是否发生手动修改 (应对json数据格式变动或手动矫正仪器数值), 若发生修改,可选择更新仪器哈希值,并替换配装数据中相应的数值 """ - equip_set_dict = {key: value for value, key in enumerate(self.equip_set_name)} + equip_set_dict = {key: value for value, key in enumerate(EQUIP_SET_NAME)} relics_data_copy = self.relics_data.copy() # 字典迭代过程中不允许修改key cnt = 0 for old_hash, data in relics_data_copy.items(): - new_hash = get_data_hash(data, self.relic_data_filter, speed_modified=True) + new_hash = get_data_hash(data, RELIC_DATA_FILTER, speed_modified=True) if old_hash != new_hash: equip_indx = equip_set_dict[data["equip_set"]] log.debug(f"(old={old_hash}, new={new_hash})") @@ -587,7 +458,7 @@ def add_relic_data(self, data: Dict[str, Any], data_hash: Optional[str]=None) -> 录入仪器数据 """ if not data_hash: - data_hash = get_data_hash(data, self.relic_data_filter) + data_hash = get_data_hash(data, RELIC_DATA_FILTER) if data_hash not in self.relics_data: self.relics_data = modify_json_file(RELIC_FILE_NAME, data_hash, data) # 返回更新后的字典 return True @@ -646,16 +517,16 @@ def ocr_relic(self, equip_set_index: Optional[int]=None) -> Dict[str, Any]: img_pc = self.calculated.take_screenshot() # 仅截取一次图片 # [1]部位识别 if equip_set_index is None: - equip_set_index = self.calculated.ocr_pos_for_single_line(self.equip_set_name, points=(77,19,83,23) if IS_PC else (71,22,78,26), img_pk=img_pc) + equip_set_index = self.calculated.ocr_pos_for_single_line(EQUIP_SET_NAME, points=(77,19,83,23) if IS_PC else (71,22,78,26), img_pk=img_pc) if equip_set_index < 0: raise RelicOCRException(_("遗器套装OCR错误")) - equip_set_name = self.equip_set_name[equip_set_index] + equip_set_name = EQUIP_SET_NAME[equip_set_index] # [2]套装识别 - name_list = self.relic_set_name[:, 0].tolist() + name_list = RELIC_SET_NAME[:, 0].tolist() relic_set_index = self.calculated.ocr_pos_for_single_line(name_list, points=(77,15,92,19) if IS_PC else (71,17,88,21), img_pk=img_pc) if relic_set_index < 0: raise RelicOCRException(_("遗器部位OCR错误")) - relic_set_name = self.relic_set_name[relic_set_index, -1] + relic_set_name = RELIC_SET_NAME[relic_set_index, -1] # [3]稀有度识别 hue, __, __ = self.calculated.get_relative_pix_hsv((43,55) if IS_PC else (41,55)) # 识别指定位点色相 log.debug(f"hue = {hue}") @@ -675,7 +546,7 @@ def ocr_relic(self, equip_set_index: Optional[int]=None) -> Dict[str, Any]: if level > 15: raise RelicOCRException(_("遗器等级OCR错误")) # [5]主属性识别 - name_list = self.base_stats_name4equip[equip_set_index][:, 0].tolist() + name_list = BASE_STATS_NAME_FOR_EQUIP[equip_set_index][:, 0].tolist() base_stats_index = self.calculated.ocr_pos_for_single_line(name_list, points=(79.5,25,92,29) if IS_PC else (74,29,89,34), img_pk=img_pc) base_stats_value = self.calculated.ocr_pos_for_single_line(points=(93,25,98,29) if IS_PC else (91,29,98,34), number=True, img_pk=img_pc) if base_stats_index < 0: @@ -687,11 +558,11 @@ def ocr_relic(self, equip_set_index: Optional[int]=None) -> Dict[str, Any]: s = base_stats_value.split('%')[0] # 修正'48%1'如此的错误识别 base_stats_value = s[:-1] + '.' + s[-1:] # 添加未识别的小数点 base_stats_value = float(base_stats_value) - base_stats_name = str(self.base_stats_name4equip[equip_set_index][base_stats_index, -1]) + base_stats_name = str(BASE_STATS_NAME_FOR_EQUIP[equip_set_index][base_stats_index, -1]) # [6]副属性识别 (词条数量 2-4) subs_stats_name_points = [(79.5,29,85,33),(79.5,33,85,36.5),(79.5,36.5,85,40),(79.5,40,85,44)] if IS_PC else [(74,35,81,38),(74,39,81,43),(74,44,81,47),(74,48,81,52)] subs_stats_value_points = [(93,29,98,33),(93,33,98,36.5),(93,36.5,98,40),(93,40,98,44)] if IS_PC else [(92,35,98,38),(92,39,98,43),(92,44,98,47),(92,48,98,52)] - name_list = self.subs_stats_name[:, 0].tolist() + name_list = SUBS_STATS_NAME[:, 0].tolist() subs_stats_dict = {} total_level = 0 for name_point, value_point in zip(subs_stats_name_points, subs_stats_value_points): @@ -709,7 +580,7 @@ def ocr_relic(self, equip_set_index: Optional[int]=None) -> Dict[str, Any]: if tmp_index >= 0 and tmp_index < 3: tmp_index += 3 # 小词条转为大词条 tmp_value = float(tmp_value) - tmp_name = str(self.subs_stats_name[tmp_index, -1]) + tmp_name = str(SUBS_STATS_NAME[tmp_index, -1]) check = self.get_subs_stats_detail((tmp_name, tmp_value), rarity, tmp_index) if check is None: raise RelicOCRException(_("遗器副词条数值OCR错误")) @@ -747,16 +618,16 @@ def print_relic(self, data: Dict[str, Any]): token.append(_("等级: +{level}").format(level=data["level"])) token.append(_("主词条:")) name, value = list(data["base_stats"].items())[0] - pre = " " if name in self.not_pre_stats else "%" + pre = " " if name in NOT_PRE_STATS else "%" result = self.get_base_stats_detail((name, value), data["rarity"], data["level"]) if result: token.append(_(" {name:<4}\t{value:>7.{ndigits}f}{pre}").format(name=name, value=result, pre=pre, ndigits=self.ndigits)) else: token.append(_(" {name:<4}\t{value:>5}{pre} [ERROR]").format(name=name, value=value, pre=pre)) token.append(_("副词条:")) - subs_stats_dict = Array2dict(self.subs_stats_name) + subs_stats_dict = Array2dict(SUBS_STATS_NAME) for name, value in data["subs_stats"].items(): - pre = " " if name in self.not_pre_stats else "%" + pre = " " if name in NOT_PRE_STATS else "%" if not self.is_detail or data["rarity"] not in [4,5]: # 不满足校验条件 token.append(_(" {name:<4}\t{value:>5}{pre}").format(name=name, value=value, pre=pre)) continue @@ -778,8 +649,8 @@ def get_loadout_brief(self, relics_hash: List[str]) -> str: 说明: 获取配装的简要信息 (包含内外圈套装信息与主词条信息) """ - set_abbr_dict = Array2dict(self.relic_set_name, -1, 2) - stats_abbr_dict = Array2dict(self.base_stats_name, -1, 1) + set_abbr_dict = Array2dict(RELIC_SET_NAME, -1, 2) + stats_abbr_dict = Array2dict(BASE_STATS_NAME, -1, 1) outer_set_list, inner_set_list, base_stats_list = [], [], [] # 获取遗器数据 for equip_indx in range(len((relics_hash))): @@ -817,16 +688,16 @@ def get_base_stats_detail(self, data: Tuple[str, float], rarity: int, level: int name, value = data if not self.is_check_stats or rarity not in [4,5]: # 仅支持五星遗器与四星遗器 return value - stats_index = np.where(self.base_stats_name[:, -1] == name)[0][0] if stats_index is None else stats_index + stats_index = np.where(BASE_STATS_NAME[:, -1] == name)[0][0] if stats_index is None else stats_index rarity_index = rarity - 4 # 0-四星,1-五星 - a, d = self.base_stats_tier[rarity_index][stats_index] + a, d = BASE_STATS_TIER[rarity_index][stats_index] result = round(a + d*level, 4) # 四舍五入 (考虑浮点数运算的数值损失) # 校验数据 check = result - value log.debug(f"[{a}, {d}], l={level} r={result}") if check < 0 or \ - name in self.not_pre_stats and check >= 1 or \ - name not in self.not_pre_stats and check >= 0.1: + name in NOT_PRE_STATS and check >= 1 or \ + name not in NOT_PRE_STATS and check >= 0.1: log.error(_(f"校验失败,原数据或计算方法有误: {data}")) return None return result @@ -850,10 +721,10 @@ def get_subs_stats_detail(self, data: Tuple[str, float], rarity: int, stats_inde if not self.is_check_stats or rarity not in [4,5]: # 仅支持五星遗器与四星遗器 return (0,0,0) name, value = data - stats_index = np.where(self.subs_stats_name[:, -1] == name)[0][0] if stats_index is None else stats_index + stats_index = np.where(SUBS_STATS_NAME[:, -1] == name)[0][0] if stats_index is None else stats_index rarity_index = rarity - 4 # 0-四星,1-五星 - a, d = self.subs_stats_tier[rarity_index][stats_index] - if name in self.not_pre_stats: + a, d = SUBS_STATS_TIER[rarity_index][stats_index] + if name in NOT_PRE_STATS: a_ = int(a) # 从个分位截断小数 else: a_ = int(a * 10)/10 # 从十分位截断小数 @@ -868,8 +739,8 @@ def get_subs_stats_detail(self, data: Tuple[str, float], rarity: int, stats_inde check = result - value log.debug(f"[{a}, {d}], l={level}, s={score}, r={result}") if check < 0 or \ - name in self.not_pre_stats and check >= 1 or \ - name not in self.not_pre_stats and check >= 0.1 or \ + name in NOT_PRE_STATS and check >= 1 or \ + name not in NOT_PRE_STATS and check >= 0.1 or \ level > 6 or level < 1 or \ score > level*2 or score < 0: log.error(_(f"校验失败,原数据或计算方法有误: {data}")) diff --git a/utils/relic_constants.py b/utils/relic_constants.py new file mode 100644 index 00000000..57381fc0 --- /dev/null +++ b/utils/relic_constants.py @@ -0,0 +1,178 @@ +""" +遗器模块相关静态数据 +""" +import numpy as np +from .config import _ + + +EQUIP_SET_NAME = [_("头部"), _("手部"), _("躯干"), _("脚部"), _("位面球"), _("连结绳")] +"""遗器部位名称,已经按游戏界面顺序排序""" + +EQUIP_SET_ADDR = [_("头"), _("手"), _("衣"), _("鞋"), _("球"), _("绳")] +"""遗器部位简称""" + +# 注:因为数据有时要行取有时要列取,故采用数组存储 +RELIC_SET_NAME = np.array([ + [_("过客"), _("过客"), _("治疗"), _("云无留迹的过客")], + [_("枪手"), _("枪手"), _("快枪手"), _("野穗伴行的快枪手")], + [_("圣骑"), _("圣骑"), _("圣骑"), _("净庭教宗的圣骑士")], + [_("雪猎"), _("猎人"), _("冰套"), _("密林卧雪的猎人")], + [_("拳王"), _("拳王"), _("物理"), _("街头出身的拳王")], + [_("铁卫"), _("铁卫"), _("铁卫"), _("戍卫风雪的铁卫")], + [_("火匠"), _("火匠"), _("火套"), _("熔岩锻铸的火匠")], + [_("天才"), _("天才"), _("量子"), _("繁星璀璨的天才")], + [_("乐队"), _("雷电"), _("雷套"), _("激奏雷电的乐队")], + [_("翔"), _("翔"), _("风套"), _("晨昏交界的翔鹰")], + [_("怪盗"), _("怪盗"), _("怪盗"), _("流星追迹的怪盗")], + [_("废"), _("废"), _("虚数"), _("盗匪荒漠的废土客")], + [_("者"), _("长存"), _("莳者"), _("宝命长存的莳者")], + [_("信使"), _("信使"), _("信使"), _("骇域漫游的信使")], + [_("黑塔"), _("太空"), _("空间站"), _("太空封印站")], + [_("仙"), _("仙"), _("仙舟"), _("不老者的仙舟")], + [_("公司"), _("公司"), _("命中"), _("泛银河商业公司")], + [_("贝洛"), _("贝洛"), _("防御"), _("筑城者的贝洛伯格")], + [_("螺丝"), _("差分"), _("差分"), _("星体差分机")], + [_("萨尔"), _("停转"), _("停转"), _("停转的萨尔索图")], + [_("利亚"), _("盗贼"), _("击破"), _("盗贼公国塔利亚")], + [_("瓦克"), _("瓦克"), _("翁瓦克"), _("生命的翁瓦克")], + [_("泰科"), _("繁星"), _("繁星"), _("繁星竞技场")], + [_("伊须"), _("龙骨"), _("龙骨"), _("折断的龙骨")] +], dtype=np.str_) +"""遗器套装名称:0-套装散件名的共有词(ocr-必须),1-套装名的特异词(ocr-可选,为了增强鲁棒性),2-玩家惯用简称(print),3-套装全称(json),已按[1.4游戏]遗器筛选界面排序""" + +STATS_NAME = np.array([ + [_("命值"), _("生"), _("生命值")], + [_("击力"), _("攻"), _("攻击力")], + [_("防御"), _("防"), _("防御力")], + [_("命值"), _("生"), _("生命值%")], + [_("击力"), _("攻"), _("攻击力%")], + [_("防御"), _("防"), _("防御力%")], + [_("度"), _("速"), _("速度")], + [_("击率"), _("暴击"), _("暴击率")], + [_("击伤"), _("爆伤"), _("暴击伤害")], + [_("命中"), _("命中"), _("效果命中")], + [_("治疗"), _("治疗"), _("治疗量加成")], + [_("理"), _("伤害"), _("物理属性伤害")], + [_("火"), _("火伤"), _("火属性伤害")], + [_("冰"), _("冰伤"), _("冰属性伤害")], + [_("雷"), _("雷伤"), _("雷属性伤害")], + [_("风"), _("风伤"), _("风属性伤害")], + [_("量"), _("量子"), _("量子属性伤害")], + [_("数"), _("虚数"), _("虚数属性伤害")], + [_("抵抗"), _("效果抵抗"), _("效果抵抗")], + [_("破"), _("击破"), _("击破特攻")], + [_("恢复"), _("能"), _("能量恢复效率")] +], dtype=np.str_) +"""遗器属性名称:0-属性名的特异词(ocr-不区分大小词条),1-玩家惯用简称(print),2-属性全称(json-区分大小词条)""" + +NOT_PRE_STATS = [_("生命值"), _("攻击力"), _("防御力"), _("速度")] +"""遗器的整数属性名称""" + +BASE_STATS_NAME = np.concatenate((STATS_NAME[:2],STATS_NAME[3:-3],STATS_NAME[-2:]), axis=0) +"""遗器主属性名称""" + +BASE_STATS_NAME_FOR_EQUIP = [ + BASE_STATS_NAME[0:1], + BASE_STATS_NAME[1:2], + np.vstack((BASE_STATS_NAME[2:5],BASE_STATS_NAME[6:10])), + BASE_STATS_NAME[2:6], + np.vstack((BASE_STATS_NAME[2:5],BASE_STATS_NAME[10:17])), + np.vstack((BASE_STATS_NAME[2:5],BASE_STATS_NAME[-2:])) +] +"""遗器各部位主属性名称""" + +SUBS_STATS_NAME = np.vstack((STATS_NAME[:10],STATS_NAME[-3:-1])) +"""遗器副属性名称,已按副词条顺序排序""" + +SUBS_STATS_TIER = [ + [(27.096, 3.3870 ), (13.548 , 1.6935 ), (13.548 , 1.6935 ), (2.7648, 0.3456), (2.7648, 0.3456), (3.456, 0.4320), # 四星遗器数值 + (1.60, 0.20), (2.0736, 0.2592), (4.1472, 0.5184), (2.7648, 0.3456), (2.7648, 0.3456), (4.1472, 0.5184)], + [(33.870, 4.233755), (16.935 , 2.116877), (16.935 , 2.116877), (3.4560, 0.4320), (3.4560, 0.4320), (4.320, 0.5400), # 五星遗器数值 + (2.00, 0.30), (2.5920, 0.3240), (5.1840, 0.6480), (3.4560, 0.4320), (3.4560, 0.4320), (5.1840, 0.6480)]] +"""副属性词条档位:t0-基础值,t1-每提升一档的数值;l1-四星遗器数值,l2-五星遗器数值 <<数据来源:米游社@666bj>>""" + +BASE_STATS_TIER = [ + [( 90.3168, 31.61088), (45.1584, 15.80544), (5.5296, 1.9354), (5.5296, 1.9354), (6.9120, 2.4192), (3.2256, 1.1), # 四星遗器数值 + (4.1472, 1.4515), ( 8.2944, 2.9030), (5.5296, 1.9354), (4.4237, 1.5483), (4.9766, 1.7418), ( 8.2944, 2.9030), (2.4883, 0.8709)], + [(112.896, 39.5136 ), (56.448, 19.7568 ), (6.9120, 2.4192), (6.9120, 2.4192), (8.6400, 3.0240), (4.032, 1.4), # 五星遗器数值 + (5.1840, 1.8144), (10.3680, 3.6288), (6.9120, 2.4192), (5.5296, 1.9354), (6.2208, 2.1773), (10.3680, 3.6288), (3.1104, 1.0886)]] +"""主属性词条级别:t0-基础值,t1-每提升一级的数值;l1-四星遗器数值,l2-五星遗器数值 <<数据来源:米游社@666bj>>""" + +for i in range(len(BASE_STATS_TIER)): + BASE_STATS_TIER[i][10:10] = [BASE_STATS_TIER[i][10]] * 6 # 复制属性伤害 + + +RELIC_SCHEMA = { # 遗器数据集 + "type": "object", + "additionalProperties": { # [主键]遗器哈希值 (由其键值遗器数据自动生成) + "type": "object", + "properties": { + "equip_set": { # 遗器部位 + "type": "string", + "enum": EQUIP_SET_NAME + }, + "relic_set": { # 遗器套装 + "type": "string", + "enum": RELIC_SET_NAME[:, -1].tolist() + }, + "rarity": { # 遗器稀有度 (2-5星) + "type": "integer", + "minimum": 2, + "maximum": 5 + }, + "level": { # 遗器等级 (0-15级) + "type": "integer", + "minimum": 0, + "maximum": 15 + }, + "base_stats": { # 遗器主属性 (词条数为 1) + "type": "object", + "minProperties": 1, + "maxProperties": 1, + "properties": { + key: {"type": "number"} for key in BASE_STATS_NAME[:, -1] + }, + "additionalProperties": False + }, + "subs_stats": { # 遗器副属性 (词条数为 1-4) + "type": "object", + "minProperties": 1, + "maxProperties": 4, + "properties": { + key: {"type": "number"} for key in SUBS_STATS_NAME[:, -1] + }, + "additionalProperties": False + }, + "pre_ver_hash": {"type": "string"} # [外键]本次升级前的版本 + }, + "required": ["relic_set", "equip_set", "rarity", "level", "base_stats", "subs_stats"], # 需包含遗器的全部固有属性 + "additionalProperties": False +}} +"""遗器数据json格式规范""" + +LOADOUT_SCHEMA = { # 人物遗器配装数据集 + "type": "object", + "additionalProperties": { # [主键]人物名称 (以OCR结果为准) + "type": "object", + "additionalProperties": { # [次主键]配装名称 (自定义) + "type": "array", # 配装组成 (6件遗器,按部位排序) + "minItems": 6, + "maxItems": 6, + "items": {"type": "string"} # [外键]遗器哈希值 +}}} +"""人物配装数据json格式规范""" + +TEAM_SCHEMA = { # 队伍遗器配装数据集 + "type": "object", + "additionalProperties": { # [主键]队伍名称 (自定义) + "type": "object", + "additionalProperties": { # [外键]队伍成员名称 (以OCR结果为准) + "type": "string" # [外键]各队伍成员的配装名称 + }, + "minProperties": 1, + "maxProperties": 4 +}} +"""队伍配装数据json格式规范""" + +RELIC_DATA_FILTER = ["pre_ver_hash"] +"""遗器数据过滤器""" \ No newline at end of file From 040e9d5ec0102c5fd421b95a5e804275e8792ce6 Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Sun, 29 Oct 2023 15:19:41 +0800 Subject: [PATCH 08/31] add questionary module from 'tmbo/questionary/pull/330' --- requirements.txt | 1 + utils/questionary/NOTICE | 51 ++ utils/questionary/README.md | 114 ++++ utils/questionary/questionary/__init__.py | 54 ++ utils/questionary/questionary/constants.py | 49 ++ utils/questionary/questionary/form.py | 122 ++++ utils/questionary/questionary/prompt.py | 236 +++++++ .../questionary/prompts/__init__.py | 29 + .../questionary/prompts/autocomplete.py | 214 +++++++ .../questionary/prompts/checkbox.py | 300 +++++++++ .../questionary/questionary/prompts/common.py | 579 ++++++++++++++++++ .../questionary/prompts/confirm.py | 133 ++++ .../questionary/prompts/password.py | 61 ++ utils/questionary/questionary/prompts/path.py | 243 ++++++++ .../prompts/press_any_key_to_continue.py | 61 ++ .../questionary/prompts/rawselect.py | 79 +++ .../questionary/questionary/prompts/select.py | 258 ++++++++ utils/questionary/questionary/prompts/text.py | 101 +++ utils/questionary/questionary/py.typed | 0 utils/questionary/questionary/question.py | 134 ++++ utils/questionary/questionary/styles.py | 16 + utils/questionary/questionary/utils.py | 78 +++ utils/questionary/questionary/version.py | 1 + 23 files changed, 2914 insertions(+) create mode 100644 utils/questionary/NOTICE create mode 100644 utils/questionary/README.md create mode 100644 utils/questionary/questionary/__init__.py create mode 100644 utils/questionary/questionary/constants.py create mode 100644 utils/questionary/questionary/form.py create mode 100644 utils/questionary/questionary/prompt.py create mode 100644 utils/questionary/questionary/prompts/__init__.py create mode 100644 utils/questionary/questionary/prompts/autocomplete.py create mode 100644 utils/questionary/questionary/prompts/checkbox.py create mode 100644 utils/questionary/questionary/prompts/common.py create mode 100644 utils/questionary/questionary/prompts/confirm.py create mode 100644 utils/questionary/questionary/prompts/password.py create mode 100644 utils/questionary/questionary/prompts/path.py create mode 100644 utils/questionary/questionary/prompts/press_any_key_to_continue.py create mode 100644 utils/questionary/questionary/prompts/rawselect.py create mode 100644 utils/questionary/questionary/prompts/select.py create mode 100644 utils/questionary/questionary/prompts/text.py create mode 100644 utils/questionary/questionary/py.typed create mode 100644 utils/questionary/questionary/question.py create mode 100644 utils/questionary/questionary/styles.py create mode 100644 utils/questionary/questionary/utils.py create mode 100644 utils/questionary/questionary/version.py diff --git a/requirements.txt b/requirements.txt index 9922074b..fa58ac5d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,3 +30,4 @@ pluggy httpcore pydantic jsonschema +prompt-toolkit<=3.0.36,>=2.0 diff --git a/utils/questionary/NOTICE b/utils/questionary/NOTICE new file mode 100644 index 00000000..051758ad --- /dev/null +++ b/utils/questionary/NOTICE @@ -0,0 +1,51 @@ +Tom Bocklisch +Copyright 2019 Tom Bocklisch + +---- + +This product includes software from PyInquirer (https://github.com/CITGuru/PyInquirer), +under the MIT License. + +Copyright 2018 Oyetoke Toby and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +---- + +This product includes software from whaaaaat (https://github.com/finklabs/whaaaaat), +under the MIT License. + +Copyright 2016 Fink Labs GmbH and inquirerpy contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE diff --git a/utils/questionary/README.md b/utils/questionary/README.md new file mode 100644 index 00000000..90d04d3c --- /dev/null +++ b/utils/questionary/README.md @@ -0,0 +1,114 @@ +# Questionary + +[![Version](https://img.shields.io/pypi/v/questionary.svg)](https://pypi.org/project/questionary/) +[![License](https://img.shields.io/pypi/l/questionary.svg)](#) +[![Continuous Integration](https://github.com/tmbo/questionary/workflows/Continuous%20Integration/badge.svg)](#) +[![Coverage](https://coveralls.io/repos/github/tmbo/questionary/badge.svg?branch=master)](https://coveralls.io/github/tmbo/questionary?branch=master) +[![Supported Python Versions](https://img.shields.io/pypi/pyversions/questionary.svg)](https://pypi.python.org/pypi/questionary) +[![Documentation](https://readthedocs.org/projects/questionary/badge/?version=latest)](https://questionary.readthedocs.io/en/latest/?badge=latest) + +✨ Questionary is a Python library for effortlessly building pretty command line interfaces ✨ + +* [Features](#features) +* [Installation](#installation) +* [Usage](#usage) +* [Documentation](#documentation) +* [Support](#support) + + +![Example](https://raw.githubusercontent.com/tmbo/questionary/master/docs/images/example.gif) + +```python3 +import questionary + +questionary.text("What's your first name").ask() +questionary.password("What's your secret?").ask() +questionary.confirm("Are you amazed?").ask() + +questionary.select( + "What do you want to do?", + choices=["Order a pizza", "Make a reservation", "Ask for opening hours"], +).ask() + +questionary.rawselect( + "What do you want to do?", + choices=["Order a pizza", "Make a reservation", "Ask for opening hours"], +).ask() + +questionary.checkbox( + "Select toppings", choices=["foo", "bar", "bazz"] +).ask() + +questionary.path("Path to the projects version file").ask() +``` + +Used and supported by + +[](https://github.com/RasaHQ/rasa) + +## Features + +Questionary supports the following input prompts: + + * [Text](https://questionary.readthedocs.io/en/stable/pages/types.html#text) + * [Password](https://questionary.readthedocs.io/en/stable/pages/types.html#password) + * [File Path](https://questionary.readthedocs.io/en/stable/pages/types.html#file-path) + * [Confirmation](https://questionary.readthedocs.io/en/stable/pages/types.html#confirmation) + * [Select](https://questionary.readthedocs.io/en/stable/pages/types.html#select) + * [Raw select](https://questionary.readthedocs.io/en/stable/pages/types.html#raw-select) + * [Checkbox](https://questionary.readthedocs.io/en/stable/pages/types.html#checkbox) + * [Autocomplete](https://questionary.readthedocs.io/en/stable/pages/types.html#autocomplete) + +There is also a helper to [print formatted text](https://questionary.readthedocs.io/en/stable/pages/types.html#printing-formatted-text) +for when you want to spice up your printed messages a bit. + +## Installation + +Use the package manager [pip](https://pip.pypa.io/en/stable/) to install Questionary: + +```bash +$ pip install questionary +✨🎂✨ +``` + +## Usage + +```python +import questionary + +questionary.select( + "What do you want to do?", + choices=[ + 'Order a pizza', + 'Make a reservation', + 'Ask for opening hours' + ]).ask() # returns value of selection +``` + +That's all it takes to create a prompt! Have a [look at the documentation](https://questionary.readthedocs.io/) +for some more examples. + +## Documentation + +Documentation for Questionary is available [here](https://questionary.readthedocs.io/). + +## Support + +Please [open an issue](https://github.com/tmbo/questionary/issues/new) +with enough information for us to reproduce your problem. +A [minimal, reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) +would be very helpful. + +## Contributing + +Contributions are very much welcomed and appreciated. Head over to the documentation on [how to contribute](https://questionary.readthedocs.io/en/stable/pages/contributors.html#steps-for-submitting-code). + +## Authors and Acknowledgment + +Questionary is written and maintained by Tom Bocklisch and Kian Cross. + +It is based on the great work by [Oyetoke Toby](https://github.com/CITGuru/PyInquirer) +and [Mark Fink](https://github.com/finklabs/whaaaaat). + +## License +Licensed under the [MIT License](https://github.com/tmbo/questionary/blob/master/LICENSE). Copyright 2021 Tom Bocklisch. diff --git a/utils/questionary/questionary/__init__.py b/utils/questionary/questionary/__init__.py new file mode 100644 index 00000000..1d14551f --- /dev/null +++ b/utils/questionary/questionary/__init__.py @@ -0,0 +1,54 @@ +# noinspection PyUnresolvedReferences +from prompt_toolkit.styles import Style +from prompt_toolkit.validation import ValidationError +from prompt_toolkit.validation import Validator + +from .version import __version__ +from .form import Form +from .form import FormField +from .form import form +from .prompt import prompt +from .prompt import unsafe_prompt + +# import the shortcuts to create single question prompts +from .prompts.autocomplete import autocomplete +from .prompts.checkbox import checkbox +from .prompts.common import Choice +from .prompts.common import Separator +from .prompts.common import print_formatted_text as print +from .prompts.confirm import confirm +from .prompts.password import password +from .prompts.path import path +from .prompts.press_any_key_to_continue import press_any_key_to_continue +from .prompts.rawselect import rawselect +from .prompts.select import select +from .prompts.text import text +from .question import Question + +__all__ = [ + "__version__", + # question types + "autocomplete", + "checkbox", + "confirm", + "password", + "path", + "press_any_key_to_continue", + "rawselect", + "select", + "text", + # utility methods + "print", + "form", + "prompt", + "unsafe_prompt", + # commonly used classes + "Form", + "FormField", + "Question", + "Choice", + "Style", + "Separator", + "Validator", + "ValidationError", +] diff --git a/utils/questionary/questionary/constants.py b/utils/questionary/questionary/constants.py new file mode 100644 index 00000000..768c481f --- /dev/null +++ b/utils/questionary/questionary/constants.py @@ -0,0 +1,49 @@ +from . import Style + +# Value to display as an answer when "affirming" a confirmation question +YES = "Yes" + +# Value to display as an answer when "denying" a confirmation question +NO = "No" + +# Instruction text for a confirmation question (yes is default) +YES_OR_NO = "(Y/n)" + +# Instruction text for a confirmation question (no is default) +NO_OR_YES = "(y/N)" + +# Instruction for multiline input +INSTRUCTION_MULTILINE = "(Finish with 'Alt+Enter' or 'Esc then Enter')\n>" + +# Selection token used to indicate the selection cursor in a list +DEFAULT_SELECTED_POINTER = "»" + +# Item prefix to identify selected items in a checkbox list +INDICATOR_SELECTED = "●" + +# Item prefix to identify unselected items in a checkbox list +INDICATOR_UNSELECTED = "○" + +# Prefix displayed in front of questions +DEFAULT_QUESTION_PREFIX = "?" + +# Message shown when a user aborts a question prompt using CTRL-C +DEFAULT_KBI_MESSAGE = "Cancelled by user" + +# Default text shown when the input is invalid +INVALID_INPUT = "Invalid input" + +# Default message style +DEFAULT_STYLE = Style( + [ + ("qmark", "fg:#5f819d"), # token in front of the question + ("question", "bold"), # question text + ("answer", "fg:#FF9D00 bold"), # submitted answer text behind the question + ("pointer", ""), # pointer used in select and checkbox prompts + ("selected", ""), # style for a selected item of a checkbox + ("separator", ""), # separator in lists + ("instruction", ""), # user instructions for select, rawselect, checkbox + ("text", ""), # any other text + ("instruction", ""), # user instructions for select, rawselect, checkbox + ] +) diff --git a/utils/questionary/questionary/form.py b/utils/questionary/questionary/form.py new file mode 100644 index 00000000..b5d865f2 --- /dev/null +++ b/utils/questionary/questionary/form.py @@ -0,0 +1,122 @@ + +from typing import Any +from typing import Dict +from typing import NamedTuple +from typing import Sequence + +from .constants import DEFAULT_KBI_MESSAGE +from .question import Question + + +class FormField(NamedTuple): + """ + Represents a question within a form + + Args: + key: The name of the form field. + question: The question to ask in the form field. + """ + + key: str + question: Question + + +def form(**kwargs: Question) -> "Form": + """Create a form with multiple questions. + + The parameter name of a question will be the key for the answer in + the returned dict. + + Args: + kwargs: Questions to ask in the form. + """ + return Form(*(FormField(k, q) for k, q in kwargs.items())) + + +class Form: + """Multi question prompts. Questions are asked one after another. + + All the answers are returned as a dict with one entry per question. + + This class should not be invoked directly, instead use :func:`form`. + """ + + form_fields: Sequence[FormField] + + def __init__(self, *form_fields: FormField) -> None: + self.form_fields = form_fields + + def unsafe_ask(self, patch_stdout: bool = False) -> Dict[str, Any]: + """Ask the questions synchronously and return user response. + + Does not catch keyboard interrupts. + + Args: + patch_stdout: Ensure that the prompt renders correctly if other threads + are printing to stdout. + + Returns: + The answers from the form. + """ + return {f.key: f.question.unsafe_ask(patch_stdout) for f in self.form_fields} + + async def unsafe_ask_async(self, patch_stdout: bool = False) -> Dict[str, Any]: + """Ask the questions using asyncio and return user response. + + Does not catch keyboard interrupts. + + Args: + patch_stdout: Ensure that the prompt renders correctly if other threads + are printing to stdout. + + Returns: + The answers from the form. + """ + return { + f.key: await f.question.unsafe_ask_async(patch_stdout) + for f in self.form_fields + } + + def ask( + self, patch_stdout: bool = False, kbi_msg: str = DEFAULT_KBI_MESSAGE + ) -> Dict[str, Any]: + """Ask the questions synchronously and return user response. + + Args: + patch_stdout: Ensure that the prompt renders correctly if other threads + are printing to stdout. + + kbi_msg: The message to be printed on a keyboard interrupt. + + Returns: + The answers from the form. + """ + try: + return self.unsafe_ask(patch_stdout) + except KeyboardInterrupt: + print("") + print(kbi_msg) + print("") + return {} + + async def ask_async( + self, patch_stdout: bool = False, kbi_msg: str = DEFAULT_KBI_MESSAGE + ) -> Dict[str, Any]: + """Ask the questions using asyncio and return user response. + + Args: + patch_stdout: Ensure that the prompt renders correctly if other threads + are printing to stdout. + + kbi_msg: The message to be printed on a keyboard interrupt. + + Returns: + The answers from the form. + """ + try: + return await self.unsafe_ask_async(patch_stdout) + except KeyboardInterrupt: + print("") + print(kbi_msg) + print("") + return {} diff --git a/utils/questionary/questionary/prompt.py b/utils/questionary/questionary/prompt.py new file mode 100644 index 00000000..b4d18017 --- /dev/null +++ b/utils/questionary/questionary/prompt.py @@ -0,0 +1,236 @@ +from typing import Any +from typing import Dict +from typing import Iterable +from typing import Mapping +from typing import Optional +from typing import Union + +from prompt_toolkit.output import ColorDepth + +from . import utils +from .constants import DEFAULT_KBI_MESSAGE +from .prompts import AVAILABLE_PROMPTS +from .prompts import prompt_by_name +from .prompts.common import print_formatted_text + + +class PromptParameterException(ValueError): + """Received a prompt with a missing parameter.""" + + def __init__(self, message: str, errors: Optional[BaseException] = None) -> None: + # Call the base class constructor with the parameters it needs + super().__init__(f"You must provide a `{message}` value", errors) + + +def prompt( + questions: Union[Dict[str, Any], Iterable[Mapping[str, Any]]], + answers: Optional[Mapping[str, Any]] = None, + patch_stdout: bool = False, + true_color: bool = False, + kbi_msg: str = DEFAULT_KBI_MESSAGE, + **kwargs: Any, +) -> Dict[str, Any]: + """Prompt the user for input on all the questions. + + Catches keyboard interrupts and prints a message. + + See :func:`unsafe_prompt` for possible question configurations. + + Args: + questions: A list of question configs representing questions to + ask. A question config may have the following options: + + * type - The type of question. + * name - An ID for the question (to identify it in the answers :obj:`dict`). + + * when - Callable to conditionally show the question. This function + takes a :obj:`dict` representing the current answers. + + * filter - Function that the answer is passed to. The return value of this + function is saved as the answer. + + Additional options correspond to the parameter names for + particular question types. + + answers: Default answers. + + patch_stdout: Ensure that the prompt renders correctly if other threads + are printing to stdout. + + kbi_msg: The message to be printed on a keyboard interrupt. + true_color: Use true color output. + + color_depth: Color depth to use. If ``true_color`` is set to true then this + value is ignored. + + type: Default ``type`` value to use in question config. + filter: Default ``filter`` value to use in question config. + name: Default ``name`` value to use in question config. + when: Default ``when`` value to use in question config. + default: Default ``default`` value to use in question config. + kwargs: Additional options passed to every question. + + Returns: + Dictionary of question answers. + """ + + try: + return unsafe_prompt(questions, answers, patch_stdout, true_color, **kwargs) + except KeyboardInterrupt: + print("") + print(kbi_msg) + print("") + return {} + + +def unsafe_prompt( + questions: Union[Dict[str, Any], Iterable[Mapping[str, Any]]], + answers: Optional[Mapping[str, Any]] = None, + patch_stdout: bool = False, + true_color: bool = False, + **kwargs: Any, +) -> Dict[str, Any]: + """Prompt the user for input on all the questions. + + Won't catch keyboard interrupts. + + Args: + questions: A list of question configs representing questions to + ask. A question config may have the following options: + + * type - The type of question. + * name - An ID for the question (to identify it in the answers :obj:`dict`). + + * when - Callable to conditionally show the question. This function + takes a :obj:`dict` representing the current answers. + + * filter - Function that the answer is passed to. The return value of this + function is saved as the answer. + + Additional options correspond to the parameter names for + particular question types. + + answers: Default answers. + + patch_stdout: Ensure that the prompt renders correctly if other threads + are printing to stdout. + + true_color: Use true color output. + + color_depth: Color depth to use. If ``true_color`` is set to true then this + value is ignored. + + type: Default ``type`` value to use in question config. + filter: Default ``filter`` value to use in question config. + name: Default ``name`` value to use in question config. + when: Default ``when`` value to use in question config. + default: Default ``default`` value to use in question config. + kwargs: Additional options passed to every question. + + Returns: + Dictionary of question answers. + + Raises: + KeyboardInterrupt: raised on keyboard interrupt + """ + + if isinstance(questions, dict): + questions = [questions] + + answers = dict(answers or {}) + + for question_config in questions: + question_config = dict(question_config) + # import the question + if "type" not in question_config: + raise PromptParameterException("type") + # every type except 'print' needs a name + if "name" not in question_config and question_config["type"] != "print": + raise PromptParameterException("name") + + _kwargs = kwargs.copy() + _kwargs.update(question_config) + + _type = _kwargs.pop("type") + _filter = _kwargs.pop("filter", None) + name = _kwargs.pop("name", None) if _type == "print" else _kwargs.pop("name") + when = _kwargs.pop("when", None) + + if true_color: + _kwargs["color_depth"] = ColorDepth.TRUE_COLOR + + if when: + # at least a little sanity check! + if callable(question_config["when"]): + try: + if not question_config["when"](answers): + continue + except Exception as exception: + raise ValueError( + f"Problem in 'when' check of " f"{name} question: {exception}" + ) from exception + else: + raise ValueError( + "'when' needs to be function that accepts a dict argument" + ) + + # handle 'print' type + if _type == "print": + try: + message = _kwargs.pop("message") + except KeyError as e: + raise PromptParameterException("message") from e + + # questions can take 'input' arg but print_formatted_text does not + # Remove 'input', if present, to avoid breaking during tests + _kwargs.pop("input", None) + + print_formatted_text(message, **_kwargs) + if name: + answers[name] = None + continue + + choices = question_config.get("choices") + if choices is not None and callable(choices): + calculated_choices = choices(answers) + question_config["choices"] = calculated_choices + kwargs["choices"] = calculated_choices + + if _filter: + # at least a little sanity check! + if not callable(_filter): + raise ValueError( + "'filter' needs to be function that accepts an argument" + ) + + if callable(question_config.get("default")): + _kwargs["default"] = question_config["default"](answers) + + create_question_func = prompt_by_name(_type) + + if not create_question_func: + raise ValueError( + f"No question type '{_type}' found. " + f"Known question types are {', '.join(AVAILABLE_PROMPTS)}." + ) + + missing_args = list(utils.missing_arguments(create_question_func, _kwargs)) + if missing_args: + raise PromptParameterException(missing_args[0]) + + question = create_question_func(**_kwargs) + + answer = question.unsafe_ask(patch_stdout) + + if answer is not None: + if _filter: + try: + answer = _filter(answer) + except Exception as exception: + raise ValueError( + f"Problem processing 'filter' of {name} " + f"question: {exception}" + ) from exception + answers[name] = answer + + return answers diff --git a/utils/questionary/questionary/prompts/__init__.py b/utils/questionary/questionary/prompts/__init__.py new file mode 100644 index 00000000..ee7b8f7f --- /dev/null +++ b/utils/questionary/questionary/prompts/__init__.py @@ -0,0 +1,29 @@ +from . import autocomplete +from . import checkbox +from . import confirm +from . import password +from . import path +from . import press_any_key_to_continue +from . import rawselect +from . import select +from . import text + +AVAILABLE_PROMPTS = { + "autocomplete": autocomplete.autocomplete, + "confirm": confirm.confirm, + "text": text.text, + "select": select.select, + "rawselect": rawselect.rawselect, + "password": password.password, + "checkbox": checkbox.checkbox, + "path": path.path, + "press_any_key_to_continue": press_any_key_to_continue.press_any_key_to_continue, + # backwards compatible names + "list": select.select, + "rawlist": rawselect.rawselect, + "input": text.text, +} + + +def prompt_by_name(name): + return AVAILABLE_PROMPTS.get(name) diff --git a/utils/questionary/questionary/prompts/autocomplete.py b/utils/questionary/questionary/prompts/autocomplete.py new file mode 100644 index 00000000..79d78baf --- /dev/null +++ b/utils/questionary/questionary/prompts/autocomplete.py @@ -0,0 +1,214 @@ +from typing import Any +from typing import Callable +from typing import Dict +from typing import Iterable +from typing import List +from typing import Optional +from typing import Tuple +from typing import Union + +from prompt_toolkit.completion import CompleteEvent +from prompt_toolkit.completion import Completer +from prompt_toolkit.completion import Completion +from prompt_toolkit.document import Document +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.lexers import SimpleLexer +from prompt_toolkit.shortcuts.prompt import CompleteStyle +from prompt_toolkit.shortcuts.prompt import PromptSession +from prompt_toolkit.styles import Style + +from ..constants import DEFAULT_QUESTION_PREFIX +from ..prompts.common import build_validator +from ..question import Question +from ..styles import merge_styles_default + + +class WordCompleter(Completer): + choices_source: Union[List[str], Callable[[], List[str]]] + ignore_case: bool + meta_information: Dict[str, Any] + match_middle: bool + + def __init__( + self, + choices: Union[List[str], Callable[[], List[str]]], + ignore_case: bool = True, + meta_information: Optional[Dict[str, Any]] = None, + match_middle: bool = True, + ) -> None: + self.choices_source = choices + self.ignore_case = ignore_case + self.meta_information = meta_information or {} + self.match_middle = match_middle + + def _choices(self) -> Iterable[str]: + return ( + self.choices_source() + if callable(self.choices_source) + else self.choices_source + ) + + def _choice_matches(self, word_before_cursor: str, choice: str) -> int: + """Match index if found, -1 if not.""" + + if self.ignore_case: + choice = choice.lower() + + if self.match_middle: + return choice.find(word_before_cursor) + elif choice.startswith(word_before_cursor): + return 0 + else: + return -1 + + @staticmethod + def _display_for_choice(choice: str, index: int, word_before_cursor: str) -> HTML: + return HTML("{}{}{}").format( + choice[:index], + choice[index : index + len(word_before_cursor)], # noqa: E203 + choice[index + len(word_before_cursor) : len(choice)], # noqa: E203 + ) + + def get_completions( + self, document: Document, complete_event: CompleteEvent + ) -> Iterable[Completion]: + choices = self._choices() + + # Get word/text before cursor. + word_before_cursor = document.text_before_cursor + + if self.ignore_case: + word_before_cursor = word_before_cursor.lower() + + for choice in choices: + index = self._choice_matches(word_before_cursor, choice) + if index == -1: + # didn't find a match + continue + + display_meta = self.meta_information.get(choice, "") + display = self._display_for_choice(choice, index, word_before_cursor) + + yield Completion( + choice, + start_position=-len(choice), + display=display.formatted_text, + display_meta=display_meta, + style="class:answer", + selected_style="class:selected", + ) + + +def autocomplete( + message: str, + choices: List[str], + default: str = "", + qmark: str = DEFAULT_QUESTION_PREFIX, + completer: Optional[Completer] = None, + meta_information: Optional[Dict[str, Any]] = None, + ignore_case: bool = True, + match_middle: bool = True, + complete_style: CompleteStyle = CompleteStyle.COLUMN, + validate: Any = None, + style: Optional[Style] = None, + **kwargs: Any, +) -> Question: + """Prompt the user to enter a message with autocomplete help. + + Example: + >>> import questionary + >>> questionary.autocomplete( + ... 'Choose ant specie', + ... choices=[ + ... 'Camponotus pennsylvanicus', + ... 'Linepithema humile', + ... 'Eciton burchellii', + ... "Atta colombica", + ... 'Polyergus lucidus', + ... 'Polyergus rufescens', + ... ]).ask() + ? Choose ant specie Atta colombica + 'Atta colombica' + + .. image:: ../images/autocomplete.gif + + This is just a really basic example, the prompt can be customised using the + parameters. + + + Args: + message: Question text + + choices: Items shown in the selection, this contains items as strings + + default: Default return value (single value). + + qmark: Question prefix displayed in front of the question. + By default this is a ``?`` + + completer: A prompt_toolkit :class:`prompt_toolkit.completion.Completion` + implementation. If not set, a questionary completer implementation + will be used. + + meta_information: A dictionary with information/anything about choices. + + ignore_case: If true autocomplete would ignore case. + + match_middle: If true autocomplete would search in every string position + not only in string begin. + + complete_style: How autocomplete menu would be shown, it could be ``COLUMN`` + ``MULTI_COLUMN`` or ``READLINE_LIKE`` from + :class:`prompt_toolkit.shortcuts.CompleteStyle`. + + validate: Require the entered value to pass a validation. The + value can not be submitted until the validator accepts + it (e.g. to check minimum password length). + + This can either be a function accepting the input and + returning a boolean, or an class reference to a + subclass of the prompt toolkit Validator class. + + style: A custom color and style for the question parts. You can + configure colors as well as font types for different elements. + + Returns: + :class:`Question`: Question instance, ready to be prompted (using ``.ask()``). + """ + merged_style = merge_styles_default([style]) + + def get_prompt_tokens() -> List[Tuple[str, str]]: + return [("class:qmark", qmark), ("class:question", " {} ".format(message))] + + def get_meta_style(meta: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + if meta: + for key in meta: + meta[key] = HTML("{}").format(meta[key]) + + return meta + + validator = build_validator(validate) + + if completer is None: + if not choices: + raise ValueError("No choices is given, you should use Text question.") + # use the default completer + completer = WordCompleter( + choices, + ignore_case=ignore_case, + meta_information=get_meta_style(meta_information), + match_middle=match_middle, + ) + + p: PromptSession = PromptSession( + get_prompt_tokens, + lexer=SimpleLexer("class:answer"), + style=merged_style, + completer=completer, + validator=validator, + complete_style=complete_style, + **kwargs, + ) + p.default_buffer.reset(Document(default)) + + return Question(p.app) diff --git a/utils/questionary/questionary/prompts/checkbox.py b/utils/questionary/questionary/prompts/checkbox.py new file mode 100644 index 00000000..35db6ff0 --- /dev/null +++ b/utils/questionary/questionary/prompts/checkbox.py @@ -0,0 +1,300 @@ +from typing import Any +from typing import Callable +from typing import Dict +from typing import List +from typing import Optional +from typing import Sequence +from typing import Tuple +from typing import Union + +from prompt_toolkit.application import Application +from prompt_toolkit.formatted_text import FormattedText +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.keys import Keys +from prompt_toolkit.styles import Style + +from .. import utils +from ..constants import DEFAULT_QUESTION_PREFIX +from ..constants import DEFAULT_SELECTED_POINTER +from ..constants import INVALID_INPUT +from ..prompts import common +from ..prompts.common import Choice +from ..prompts.common import InquirerControl +from ..prompts.common import Separator +from ..question import Question +from ..styles import merge_styles_default + + +def checkbox( + message: str, + choices: Sequence[Union[str, Choice, Dict[str, Any]]], + default: Optional[str] = None, + validate: Callable[[List[str]], Union[bool, str]] = lambda a: True, + qmark: str = DEFAULT_QUESTION_PREFIX, + pointer: Optional[str] = DEFAULT_SELECTED_POINTER, + style: Optional[Style] = None, + initial_choice: Optional[Union[str, Choice, Dict[str, Any]]] = None, + use_arrow_keys: bool = True, + use_jk_keys: bool = True, + use_emacs_keys: bool = True, + instruction: Optional[str] = None, + show_description: bool = True, + **kwargs: Any, +) -> Question: + """Ask the user to select from a list of items. + + This is a multiselect, the user can choose one, none or many of the + items. + + Example: + >>> import questionary + >>> questionary.checkbox( + ... 'Select toppings', + ... choices=[ + ... "Cheese", + ... "Tomato", + ... "Pineapple", + ... ]).ask() + ? Select toppings done (2 selections) + ['Cheese', 'Pineapple'] + + .. image:: ../images/checkbox.gif + + This is just a really basic example, the prompt can be customised using the + parameters. + + + Args: + message: Question text + + choices: Items shown in the selection, this can contain :class:`Choice` or + or :class:`Separator` objects or simple items as strings. Passing + :class:`Choice` objects, allows you to configure the item more + (e.g. preselecting it or disabling it). + + default: Default return value (single value). If you want to preselect + multiple items, use ``Choice("foo", checked=True)`` instead. + + validate: Require the entered value to pass a validation. The + value can not be submitted until the validator accepts + it (e.g. to check minimum password length). + + This should be a function accepting the input and + returning a boolean. Alternatively, the return value + may be a string (indicating failure), which contains + the error message to be displayed. + + qmark: Question prefix displayed in front of the question. + By default this is a ``?``. + + pointer: Pointer symbol in front of the currently highlighted element. + By default this is a ``»``. + Use ``None`` to disable it. + + style: A custom color and style for the question parts. You can + configure colors as well as font types for different elements. + + initial_choice: A value corresponding to a selectable item in the choices, + to initially set the pointer position to. + + use_arrow_keys: Allow the user to select items from the list using + arrow keys. + + use_jk_keys: Allow the user to select items from the list using + `j` (down) and `k` (up) keys. + + use_emacs_keys: Allow the user to select items from the list using + `Ctrl+N` (down) and `Ctrl+P` (up) keys. + instruction: A message describing how to navigate the menu. + + show_description: Display description of current selection if available. + + Returns: + :class:`Question`: Question instance, ready to be prompted (using ``.ask()``). + """ + + if not (use_arrow_keys or use_jk_keys or use_emacs_keys): + raise ValueError( + "Some option to move the selection is required. Arrow keys or j/k or " + "Emacs keys." + ) + + merged_style = merge_styles_default( + [ + # Disable the default inverted colours bottom-toolbar behaviour (for + # the error message). However it can be re-enabled with a custom + # style. + Style([("bottom-toolbar", "noreverse")]), + style, + ] + ) + + if not callable(validate): + raise ValueError("validate must be callable") + + ic = InquirerControl( + choices, + default, + pointer=pointer, + initial_choice=initial_choice, + show_description=show_description, + ) + + def get_prompt_tokens() -> List[Tuple[str, str]]: + tokens = [] + + tokens.append(("class:qmark", qmark)) + tokens.append(("class:question", " {} ".format(message))) + + if ic.is_answered: + nbr_selected = len(ic.selected_options) + if nbr_selected == 0: + tokens.append(("class:answer", "done")) + elif nbr_selected == 1: + if isinstance(ic.get_selected_values()[0].title, list): + ts = ic.get_selected_values()[0].title + tokens.append( + ( + "class:answer", + "".join([token[1] for token in ts]), # type:ignore + ) + ) + else: + tokens.append( + ( + "class:answer", + "[{}]".format(ic.get_selected_values()[0].title), + ) + ) + else: + tokens.append( + ("class:answer", "done ({} selections)".format(nbr_selected)) + ) + else: + if instruction is not None: + tokens.append(("class:instruction", instruction)) + else: + tokens.append( + ( + "class:instruction", + "(Use arrow keys to move, " + " to select, " + " to toggle, " + " to invert)", + ) + ) + return tokens + + def get_selected_values() -> List[Any]: + return [c.value for c in ic.get_selected_values()] + + def perform_validation(selected_values: List[str]) -> bool: + verdict = validate(selected_values) + valid = verdict is True + + if not valid: + if verdict is False: + error_text = INVALID_INPUT + else: + error_text = str(verdict) + + error_message = FormattedText([("class:validation-toolbar", error_text)]) + + ic.error_message = ( + error_message if not valid and ic.submission_attempted else None # type: ignore[assignment] + ) + + return valid + + layout = common.create_inquirer_layout(ic, get_prompt_tokens, **kwargs) + + bindings = KeyBindings() + + @bindings.add(Keys.ControlQ, eager=True) + @bindings.add(Keys.ControlC, eager=True) + def _(event): + event.app.exit(exception=KeyboardInterrupt, style="class:aborting") + + @bindings.add(" ", eager=True) + def toggle(_event): + pointed_choice = ic.get_pointed_at().value + if pointed_choice in ic.selected_options: + ic.selected_options.remove(pointed_choice) + else: + ic.selected_options.append(pointed_choice) + + perform_validation(get_selected_values()) + + @bindings.add("i", eager=True) + def invert(_event): + inverted_selection = [ + c.value + for c in ic.choices + if not isinstance(c, Separator) + and c.value not in ic.selected_options + and not c.disabled + ] + ic.selected_options = inverted_selection + + perform_validation(get_selected_values()) + + @bindings.add("a", eager=True) + def all(_event): + all_selected = True # all choices have been selected + for c in ic.choices: + if ( + not isinstance(c, Separator) + and c.value not in ic.selected_options + and not c.disabled + ): + # add missing ones + ic.selected_options.append(c.value) + all_selected = False + if all_selected: + ic.selected_options = [] + + perform_validation(get_selected_values()) + + def move_cursor_down(event): + ic.select_next() + while not ic.is_selection_valid(): + ic.select_next() + + def move_cursor_up(event): + ic.select_previous() + while not ic.is_selection_valid(): + ic.select_previous() + + if use_arrow_keys: + bindings.add(Keys.Down, eager=True)(move_cursor_down) + bindings.add(Keys.Up, eager=True)(move_cursor_up) + + if use_jk_keys: + bindings.add("j", eager=True)(move_cursor_down) + bindings.add("k", eager=True)(move_cursor_up) + + if use_emacs_keys: + bindings.add(Keys.ControlN, eager=True)(move_cursor_down) + bindings.add(Keys.ControlP, eager=True)(move_cursor_up) + + @bindings.add(Keys.ControlM, eager=True) + def set_answer(event): + selected_values = get_selected_values() + ic.submission_attempted = True + + if perform_validation(selected_values): + ic.is_answered = True + event.app.exit(result=selected_values) + + @bindings.add(Keys.Any) + def other(_event): + """Disallow inserting other text.""" + + return Question( + Application( + layout=layout, + key_bindings=bindings, + style=merged_style, + **utils.used_kwargs(kwargs, Application.__init__), + ) + ) diff --git a/utils/questionary/questionary/prompts/common.py b/utils/questionary/questionary/prompts/common.py new file mode 100644 index 00000000..634136d0 --- /dev/null +++ b/utils/questionary/questionary/prompts/common.py @@ -0,0 +1,579 @@ +import inspect +from typing import Any +from typing import Callable +from typing import Dict +from typing import List +from typing import Optional +from typing import Sequence +from typing import Tuple +from typing import Union + +from prompt_toolkit import PromptSession +from prompt_toolkit.filters import Always +from prompt_toolkit.filters import Condition +from prompt_toolkit.filters import IsDone +from prompt_toolkit.layout import ConditionalContainer +from prompt_toolkit.layout import FormattedTextControl +from prompt_toolkit.layout import HSplit +from prompt_toolkit.layout import Layout +from prompt_toolkit.layout import Window +from prompt_toolkit.styles import Style +from prompt_toolkit.validation import ValidationError +from prompt_toolkit.validation import Validator + +from ..constants import DEFAULT_SELECTED_POINTER +from ..constants import DEFAULT_STYLE +from ..constants import INDICATOR_SELECTED +from ..constants import INDICATOR_UNSELECTED +from ..constants import INVALID_INPUT + +# This is a cut-down version of `prompt_toolkit.formatted_text.AnyFormattedText` +# which does not exist in v2 of prompt_toolkit +FormattedText = Union[ + str, + List[Tuple[str, str]], + List[Tuple[str, str, Callable[[Any], None]]], + None, +] + + +class Choice: + """One choice in a :meth:`select`, :meth:`rawselect` or :meth:`checkbox`. + + Args: + title: Text shown in the selection list. + + value: Value returned, when the choice is selected. If this argument + is `None` or unset, then the value of `title` is used. + + disabled: If set, the choice can not be selected by the user. The + provided text is used to explain, why the selection is + disabled. + + checked: Preselect this choice when displaying the options. + + shortcut_key: Key shortcut used to select this item. + + description: Optional description of the item that can be displayed. + """ + + title: FormattedText + """Display string for the choice""" + + value: Optional[Any] + """Value of the choice""" + + disabled: Optional[str] + """Whether the choice can be selected""" + + checked: Optional[bool] + """Whether the choice is initially selected""" + + shortcut_key: Optional[str] + """A shortcut key for the choice""" + + description: Optional[str] + """Choice description""" + + def __init__( + self, + title: FormattedText, + value: Optional[Any] = None, + disabled: Optional[str] = None, + checked: Optional[bool] = False, + shortcut_key: Optional[Union[str, bool]] = True, + description: Optional[str] = None, + ) -> None: + self.disabled = disabled + self.title = title + self.checked = checked if checked is not None else False + self.description = description + + if value is not None: + self.value = value + elif isinstance(title, list): + self.value = "".join([token[1] for token in title]) + else: + self.value = title + + if shortcut_key is not None: + if isinstance(shortcut_key, bool): + self.auto_shortcut = shortcut_key + self.shortcut_key = None + else: + self.shortcut_key = str(shortcut_key) + self.auto_shortcut = False + else: + self.shortcut_key = None + self.auto_shortcut = True + + @staticmethod + def build(c: Union[str, "Choice", Dict[str, Any]]) -> "Choice": + """Create a choice object from different representations. + + Args: + c: Either a :obj:`str`, :class:`Choice` or :obj:`dict` with + ``name``, ``value``, ``disabled``, ``checked`` and + ``key`` properties. + + Returns: + An instance of the :class:`Choice` object. + """ + + if isinstance(c, Choice): + return c + elif isinstance(c, str): + return Choice(c, c) + else: + return Choice( + c.get("name"), + c.get("value"), + c.get("disabled", None), + c.get("checked"), + c.get("key"), + c.get("description", None), + ) + + def get_shortcut_title(self): + if self.shortcut_key is None: + return "-) " + else: + return "{}) ".format(self.shortcut_key) + + +class Separator(Choice): + """Used to space/separate choices group.""" + + default_separator: str = "-" * 15 + """The default separator used if none is specified""" + + line: str + """The string being used as a separator""" + + def __init__(self, line: Optional[str] = None) -> None: + """Create a separator in a list. + + Args: + line: Text to be displayed in the list, by default uses ``---``. + """ + + self.line = line or self.default_separator + super().__init__(self.line, None, "-") + + +class InquirerControl(FormattedTextControl): + SHORTCUT_KEYS = [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "0", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + ] + + choices: List[Choice] + default: Optional[Union[str, Choice, Dict[str, Any]]] + selected_options: List[Any] + use_indicator: bool + use_shortcuts: bool + use_arrow_keys: bool + pointer: Optional[str] + pointed_at: int + is_answered: bool + show_description: bool + + def __init__( + self, + choices: Sequence[Union[str, Choice, Dict[str, Any]]], + default: Optional[Union[str, Choice, Dict[str, Any]]] = None, + pointer: Optional[str] = DEFAULT_SELECTED_POINTER, + use_indicator: bool = True, + use_shortcuts: bool = False, + show_selected: bool = False, + show_description: bool = True, + use_arrow_keys: bool = True, + initial_choice: Optional[Union[str, Choice, Dict[str, Any]]] = None, + **kwargs: Any, + ): + self.use_indicator = use_indicator + self.use_shortcuts = use_shortcuts + self.show_selected = show_selected + self.show_description = show_description + self.use_arrow_keys = use_arrow_keys + self.default = default + self.pointer = pointer + + if isinstance(default, Choice): + default = default.value + + choices_values = [ + choice.value for choice in choices if isinstance(choice, Choice) + ] + + if ( + default is not None + and default not in choices + and default not in choices_values + ): + raise ValueError( + f"Invalid `default` value passed. The value (`{default}`) " + f"does not exist in the set of choices. Please make sure the " + f"default value is one of the available choices." + ) + + if initial_choice is None: + pointed_at = None + elif initial_choice in choices: + pointed_at = choices.index(initial_choice) + elif initial_choice in choices_values: + for k, choice in enumerate(choices): + if isinstance(choice, Choice): + if choice.value == initial_choice: + pointed_at = k + break + + else: + raise ValueError( + f"Invalid `initial_choice` value passed. The value " + f"(`{initial_choice}`) does not exist in " + f"the set of choices. Please make sure the initial value is " + f"one of the available choices." + ) + + self.is_answered = False + self.choices = [] + self.submission_attempted = False + self.error_message = None + self.selected_options = [] + + self._init_choices(choices, pointed_at) + self._assign_shortcut_keys() + + super().__init__(self._get_choice_tokens, **kwargs) + + if not self.is_selection_valid(): + raise ValueError( + f"Invalid 'initial_choice' value ('{initial_choice}'). " + f"It must be a selectable value." + ) + + def _is_selected(self, choice: Choice): + if isinstance(self.default, Choice): + compare_default = self.default == choice + else: + compare_default = self.default == choice.value + return choice.checked or compare_default and self.default is not None + + def _assign_shortcut_keys(self): + available_shortcuts = self.SHORTCUT_KEYS[:] + + # first, make sure we do not double assign a shortcut + for c in self.choices: + if c.shortcut_key is not None: + if c.shortcut_key in available_shortcuts: + available_shortcuts.remove(c.shortcut_key) + else: + raise ValueError( + "Invalid shortcut '{}'" + "for choice '{}'. Shortcuts " + "should be single characters or numbers. " + "Make sure that all your shortcuts are " + "unique.".format(c.shortcut_key, c.title) + ) + + shortcut_idx = 0 + for c in self.choices: + if c.auto_shortcut and not c.disabled: + c.shortcut_key = available_shortcuts[shortcut_idx] + shortcut_idx += 1 + + if shortcut_idx == len(available_shortcuts): + break # fail gracefully if we run out of shortcuts + + def _init_choices( + self, + choices: Sequence[Union[str, Choice, Dict[str, Any]]], + pointed_at: Optional[int], + ): + # helper to convert from question format to internal format + self.choices = [] + + if pointed_at is not None: + self.pointed_at = pointed_at + + for i, c in enumerate(choices): + choice = Choice.build(c) + + if self._is_selected(choice): + self.selected_options.append(choice.value) + + if pointed_at is None and not choice.disabled: + # find the first (available) choice + self.pointed_at = pointed_at = i + + self.choices.append(choice) + + @property + def choice_count(self) -> int: + return len(self.choices) + + def _get_choice_tokens(self): + tokens = [] + + def append(index: int, choice: Choice): + # use value to check if option has been selected + selected = choice.value in self.selected_options + + if index == self.pointed_at: + if self.pointer is not None: + tokens.append(("class:pointer", " {} ".format(self.pointer))) + else: + tokens.append(("class:text", " " * 3)) + + tokens.append(("[SetCursorPosition]", "")) + else: + pointer_length = len(self.pointer) if self.pointer is not None else 1 + tokens.append(("class:text", " " * (2 + pointer_length))) + + if isinstance(choice, Separator): + tokens.append(("class:separator", "{}".format(choice.title))) + elif choice.disabled: # disabled + if isinstance(choice.title, list): + tokens.append( + ("class:selected" if selected else "class:disabled", "- ") + ) + tokens.extend(choice.title) + else: + tokens.append( + ( + "class:selected" if selected else "class:disabled", + "- {}".format(choice.title), + ) + ) + + tokens.append( + ( + "class:selected" if selected else "class:disabled", + "{}".format( + "" + if isinstance(choice.disabled, bool) + else " ({})".format(choice.disabled) + ), + ) + ) + else: + shortcut = choice.get_shortcut_title() if self.use_shortcuts else "" + + if selected: + if self.use_indicator: + indicator = INDICATOR_SELECTED + " " + else: + indicator = "" + + tokens.append(("class:selected", "{}".format(indicator))) + else: + if self.use_indicator: + indicator = INDICATOR_UNSELECTED + " " + else: + indicator = "" + + tokens.append(("class:text", "{}".format(indicator))) + + if isinstance(choice.title, list): + tokens.extend(choice.title) + elif selected: + tokens.append( + ("class:selected", "{}{}".format(shortcut, choice.title)) + ) + elif index == self.pointed_at: + tokens.append( + ("class:highlighted", "{}{}".format(shortcut, choice.title)) + ) + else: + tokens.append(("class:text", "{}{}".format(shortcut, choice.title))) + + tokens.append(("", "\n")) + + # prepare the select choices + for i, c in enumerate(self.choices): + append(i, c) + + if self.show_selected: + current = self.get_pointed_at() + + answer = current.get_shortcut_title() if self.use_shortcuts else "" + + answer += ( + current.title if isinstance(current.title, str) else current.title[0][1] + ) + + tokens.append(("class:text", " Answer: {}".format(answer))) + + if self.show_description: + current = self.get_pointed_at() + + description = current.description + + if description is not None: + tokens.append(("class:text", " Description: {}".format(description))) + else: + tokens.pop() # Remove last newline. + return tokens + + def is_selection_a_separator(self) -> bool: + selected = self.choices[self.pointed_at] + return isinstance(selected, Separator) + + def is_selection_disabled(self) -> Optional[str]: + return self.choices[self.pointed_at].disabled + + def is_selection_valid(self) -> bool: + return not self.is_selection_disabled() and not self.is_selection_a_separator() + + def select_previous(self) -> None: + self.pointed_at = (self.pointed_at - 1) % self.choice_count + + def select_next(self) -> None: + self.pointed_at = (self.pointed_at + 1) % self.choice_count + + def get_pointed_at(self) -> Choice: + return self.choices[self.pointed_at] + + def get_selected_values(self) -> List[Choice]: + # get values not labels + return [ + c + for c in self.choices + if (not isinstance(c, Separator) and c.value in self.selected_options) + ] + + +def build_validator(validate: Any) -> Optional[Validator]: + if validate: + if inspect.isclass(validate) and issubclass(validate, Validator): + return validate() + elif isinstance(validate, Validator): + return validate + elif callable(validate): + + class _InputValidator(Validator): + def validate(self, document): + verdict = validate(document.text) + if verdict is not True: + if verdict is False: + verdict = INVALID_INPUT + raise ValidationError( + message=verdict, cursor_position=len(document.text) + ) + + return _InputValidator() + return None + + +def _fix_unecessary_blank_lines(ps: PromptSession) -> None: + """This is a fix for additional empty lines added by prompt toolkit. + + This assumes the layout of the default session doesn't change, if it + does, this needs an update.""" + + default_container = ps.layout.container + + default_buffer_window = ( + default_container.get_children()[0].content.get_children()[1].content # type: ignore[attr-defined] + ) + + assert isinstance(default_buffer_window, Window) + # this forces the main window to stay as small as possible, avoiding + # empty lines in selections + default_buffer_window.dont_extend_height = Always() + default_buffer_window.always_hide_cursor = Always() + + +def create_inquirer_layout( + ic: InquirerControl, + get_prompt_tokens: Callable[[], List[Tuple[str, str]]], + **kwargs: Any, +) -> Layout: + """Create a layout combining question and inquirer selection.""" + + ps: PromptSession = PromptSession( + get_prompt_tokens, reserve_space_for_menu=0, **kwargs + ) + _fix_unecessary_blank_lines(ps) + + validation_prompt: PromptSession = PromptSession( + bottom_toolbar=lambda: ic.error_message, **kwargs + ) + + return Layout( + HSplit( + [ + ps.layout.container, + ConditionalContainer(Window(ic), filter=~IsDone()), + ConditionalContainer( + validation_prompt.layout.container, + filter=Condition(lambda: ic.error_message is not None), + ), + ] + ) + ) + + +def print_formatted_text(text: str, style: Optional[str] = None, **kwargs: Any) -> None: + """Print formatted text. + + Sometimes you want to spice up your printed messages a bit, + :meth:`questionary.print` is a helper to do just that. + + Example: + + >>> import questionary + >>> questionary.print("Hello World 🦄", style="bold italic fg:darkred") + Hello World 🦄 + + .. image:: ../images/print.gif + + Args: + text: Text to be printed. + style: Style used for printing. The style argument uses the + prompt :ref:`toolkit style strings `. + """ + from prompt_toolkit import print_formatted_text as pt_print + from prompt_toolkit.formatted_text import FormattedText as FText + + if style is not None: + text_style = Style([("text", style)]) + else: + text_style = DEFAULT_STYLE + + pt_print(FText([("class:text", text)]), style=text_style, **kwargs) diff --git a/utils/questionary/questionary/prompts/confirm.py b/utils/questionary/questionary/prompts/confirm.py new file mode 100644 index 00000000..1fb2c52b --- /dev/null +++ b/utils/questionary/questionary/prompts/confirm.py @@ -0,0 +1,133 @@ +from typing import Any +from typing import Optional + +from prompt_toolkit import PromptSession +from prompt_toolkit.formatted_text import to_formatted_text +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.keys import Keys +from prompt_toolkit.styles import Style + +from ..constants import DEFAULT_QUESTION_PREFIX +from ..constants import NO +from ..constants import NO_OR_YES +from ..constants import YES +from ..constants import YES_OR_NO +from ..question import Question +from ..styles import merge_styles_default + + +def confirm( + message: str, + default: bool = True, + qmark: str = DEFAULT_QUESTION_PREFIX, + style: Optional[Style] = None, + auto_enter: bool = True, + instruction: Optional[str] = None, + **kwargs: Any, +) -> Question: + """A yes or no question. The user can either confirm or deny. + + This question type can be used to prompt the user for a confirmation + of a yes-or-no question. If the user just hits enter, the default + value will be returned. + + Example: + >>> import questionary + >>> questionary.confirm("Are you amazed?").ask() + ? Are you amazed? Yes + True + + .. image:: ../images/confirm.gif + + This is just a really basic example, the prompt can be customised using the + parameters. + + + Args: + message: Question text. + + default: Default value will be returned if the user just hits + enter. + + qmark: Question prefix displayed in front of the question. + By default this is a ``?``. + + style: A custom color and style for the question parts. You can + configure colors as well as font types for different elements. + + auto_enter: If set to `False`, the user needs to press the 'enter' key to + accept their answer. If set to `True`, a valid input will be + accepted without the need to press 'Enter'. + + instruction: A message describing how to proceed through the + confirmation prompt. + Returns: + :class:`Question`: Question instance, ready to be prompted (using `.ask()`). + """ + merged_style = merge_styles_default([style]) + + status = {"answer": None, "complete": False} + + def get_prompt_tokens(): + tokens = [] + + tokens.append(("class:qmark", qmark)) + tokens.append(("class:question", " {} ".format(message))) + + if instruction is not None: + tokens.append(("class:instruction", instruction)) + elif not status["complete"]: + _instruction = YES_OR_NO if default else NO_OR_YES + tokens.append(("class:instruction", "{} ".format(_instruction))) + + if status["answer"] is not None: + answer = YES if status["answer"] else NO + tokens.append(("class:answer", answer)) + + return to_formatted_text(tokens) + + def exit_with_result(event): + status["complete"] = True + event.app.exit(result=status["answer"]) + + bindings = KeyBindings() + + @bindings.add(Keys.ControlQ, eager=True) + @bindings.add(Keys.ControlC, eager=True) + def _(event): + event.app.exit(exception=KeyboardInterrupt, style="class:aborting") + + @bindings.add("n") + @bindings.add("N") + def key_n(event): + status["answer"] = False + if auto_enter: + exit_with_result(event) + + @bindings.add("y") + @bindings.add("Y") + def key_y(event): + status["answer"] = True + if auto_enter: + exit_with_result(event) + + @bindings.add(Keys.ControlH) + def key_backspace(event): + status["answer"] = None + + @bindings.add(Keys.ControlM, eager=True) + def set_answer(event): + if status["answer"] is None: + status["answer"] = default + + exit_with_result(event) + + @bindings.add(Keys.Any) + def other(event): + """Disallow inserting other text.""" + + return Question( + PromptSession( + get_prompt_tokens, key_bindings=bindings, style=merged_style, **kwargs + ).app + ) diff --git a/utils/questionary/questionary/prompts/password.py b/utils/questionary/questionary/prompts/password.py new file mode 100644 index 00000000..35ae893a --- /dev/null +++ b/utils/questionary/questionary/prompts/password.py @@ -0,0 +1,61 @@ +from typing import Any +from typing import Optional + +from .. import Style +from ..constants import DEFAULT_QUESTION_PREFIX +from ..prompts import text +from ..question import Question + + +def password( + message: str, + default: str = "", + validate: Any = None, + qmark: str = DEFAULT_QUESTION_PREFIX, + style: Optional[Style] = None, + **kwargs: Any, +) -> Question: + """A text input where a user can enter a secret which won't be displayed on the CLI. + + This question type can be used to prompt the user for information + that should not be shown in the command line. The typed text will be + replaced with ``*``. + + Example: + >>> import questionary + >>> questionary.password("What's your secret?").ask() + ? What's your secret? ******** + 'secret42' + + .. image:: ../images/password.gif + + This is just a really basic example, the prompt can be customised using the + parameters. + + Args: + message: Question text. + + default: Default value will be returned if the user just hits + enter. + + validate: Require the entered value to pass a validation. The + value can not be submitted until the validator accepts + it (e.g. to check minimum password length). + + This can either be a function accepting the input and + returning a boolean, or an class reference to a + subclass of the prompt toolkit Validator class. + + qmark: Question prefix displayed in front of the question. + By default this is a ``?``. + + style: A custom color and style for the question parts. You can + configure colors as well as font types for different elements. + + Returns: + :class:`Question`: Question instance, ready to be prompted (using ``.ask()``). + """ + + return text.text( + message, default, validate, qmark, style, is_password=True, **kwargs + ) diff --git a/utils/questionary/questionary/prompts/path.py b/utils/questionary/questionary/prompts/path.py new file mode 100644 index 00000000..e43bd0fd --- /dev/null +++ b/utils/questionary/questionary/prompts/path.py @@ -0,0 +1,243 @@ +import os +from typing import Any +from typing import Callable +from typing import Iterable +from typing import List +from typing import Optional +from typing import Tuple + +from prompt_toolkit.completion import CompleteEvent +from prompt_toolkit.completion import Completion +from prompt_toolkit.completion import PathCompleter +from prompt_toolkit.completion.base import Completer +from prompt_toolkit.document import Document +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.key_binding.key_processor import KeyPressEvent +from prompt_toolkit.keys import Keys +from prompt_toolkit.lexers import SimpleLexer +from prompt_toolkit.shortcuts.prompt import CompleteStyle +from prompt_toolkit.shortcuts.prompt import PromptSession +from prompt_toolkit.styles import Style + +from ..constants import DEFAULT_QUESTION_PREFIX +from ..prompts.common import build_validator +from ..question import Question +from ..styles import merge_styles_default + + +class GreatUXPathCompleter(PathCompleter): + """Wraps :class:`prompt_toolkit.completion.PathCompleter`. + + Makes sure completions for directories end with a path separator. Also make sure + the right path separator is used. Checks if `get_paths` returns list of existing + directories. + """ + + def __init__( + self, + only_directories: bool = False, + get_paths: Optional[Callable[[], List[str]]] = None, + file_filter: Optional[Callable[[str], bool]] = None, + min_input_len: int = 0, + expanduser: bool = False, + ) -> None: + """Adds validation of 'get_paths' to :class:`prompt_toolkit.completion.PathCompleter`. + + Args: + only_directories (bool): If True, only directories will be + returned, but no files. Defaults to False. + get_paths (Callable[[], List[str]], optional): Callable which + returns a list of directories to look into when the user enters a + relative path. If None, set to (lambda: ["."]). Defaults to None. + file_filter (Callable[[str], bool], optional): Callable which + takes a filename and returns whether this file should show up in the + completion. ``None`` when no filtering has to be done. Defaults to None. + min_input_len (int): Don't do autocompletion when the input string + is shorter. Defaults to 0. + expanduser (bool): If True, tilde (~) is expanded. Defaults to + False. + + Raises: + ValueError: If any of the by `get_paths` returned directories does not + exist. + """ + # if get_paths is None, make it return the current working dir + get_paths = get_paths or (lambda: ["."]) + # validation of get_paths + for current_path in get_paths(): + if not os.path.isdir(current_path): + raise ( + ValueError( + "\n Completer for file paths 'get_paths' must return only existing directories, but" + f" '{current_path}' does not exist." + ) + ) + # call PathCompleter __init__ + super().__init__( + only_directories=only_directories, + get_paths=get_paths, + file_filter=file_filter, + min_input_len=min_input_len, + expanduser=expanduser, + ) + + def get_completions( + self, document: Document, complete_event: CompleteEvent + ) -> Iterable[Completion]: + """Get completions. + + Wraps :class:`prompt_toolkit.completion.PathCompleter`. Makes sure completions + for directories end with a path separator. Also make sure the right path + separator is used. + """ + completions = super(GreatUXPathCompleter, self).get_completions( + document, complete_event + ) + + for completion in completions: + # check if the display value ends with a path separator. + # first check if display is properly set + styled_display = completion.display[0] + # styled display is a formatted text (a tuple of the text and its style) + # second tuple entry is the text + if styled_display[1][-1] == "/": + # replace separator with the OS specific one + display_text = styled_display[1][:-1] + os.path.sep + # update the styled display with the modified text + completion.display[0] = (styled_display[0], display_text) + # append the separator to the text as well - unclear why the normal + # path completer omits it from the text. this improves UX for the + # user, as they don't need to type the separator after auto-completing + # a directory + completion.text += os.path.sep + yield completion + + +def path( + message: str, + default: str = "", + qmark: str = DEFAULT_QUESTION_PREFIX, + validate: Any = None, + completer: Optional[Completer] = None, + style: Optional[Style] = None, + only_directories: bool = False, + get_paths: Optional[Callable[[], List[str]]] = None, + file_filter: Optional[Callable[[str], bool]] = None, + complete_style: CompleteStyle = CompleteStyle.MULTI_COLUMN, + **kwargs: Any, +) -> Question: + """A text input for a file or directory path with autocompletion enabled. + + Example: + >>> import questionary + >>> questionary.path( + >>> "What's the path to the projects version file?" + >>> ).ask() + ? What's the path to the projects version file? ./pyproject.toml + './pyproject.toml' + + .. image:: ../images/path.gif + + This is just a really basic example, the prompt can be customized using the + parameters. + + Args: + message: Question text. + + default: Default return value (single value). + + qmark: Question prefix displayed in front of the question. + By default this is a ``?``. + + complete_style: How autocomplete menu would be shown, it could be ``COLUMN`` + ``MULTI_COLUMN`` or ``READLINE_LIKE`` from + :class:`prompt_toolkit.shortcuts.CompleteStyle`. + + validate: Require the entered value to pass a validation. The + value can not be submitted until the validator accepts + it (e.g. to check minimum password length). + + This can either be a function accepting the input and + returning a boolean, or an class reference to a + subclass of the prompt toolkit Validator class. + + completer: A custom completer to use in the prompt. For more information, + see `this `_. + + style: A custom color and style for the question parts. You can + configure colors as well as font types for different elements. + + only_directories: Only show directories in auto completion. This option + does not do anything if a custom ``completer`` is + passed. + + get_paths: Set a callable to generate paths to traverse for suggestions. This option + does not do anything if a custom ``completer`` is + passed. + + file_filter: Optional callable to filter suggested paths. Only paths + where the passed callable evaluates to ``True`` will show up in + the suggested paths. This does not validate the typed path, e.g. + it is still possible for the user to enter a path manually, even + though this filter evaluates to ``False``. If in addition to + filtering suggestions you also want to validate the result, use + ``validate`` in combination with the ``file_filter``. + + Returns: + :class:`Question`: Question instance, ready to be prompted (using ``.ask()``). + """ # noqa: W505, E501 + merged_style = merge_styles_default([style]) + + def get_prompt_tokens() -> List[Tuple[str, str]]: + return [("class:qmark", qmark), ("class:question", " {} ".format(message))] + + validator = build_validator(validate) + + completer = completer or GreatUXPathCompleter( + get_paths=get_paths, + only_directories=only_directories, + file_filter=file_filter, + expanduser=True, + ) + + bindings = KeyBindings() + + @bindings.add(Keys.ControlM, eager=True) + def set_answer(event: KeyPressEvent): + if event.current_buffer.complete_state is not None: + event.current_buffer.complete_state = None + elif event.app.current_buffer.validate(set_cursor=True): + # When the validation succeeded, accept the input. + result_path = event.app.current_buffer.document.text + if result_path.endswith(os.path.sep): + result_path = result_path[:-1] + + event.app.exit(result=result_path) + event.app.current_buffer.append_to_history() + + @bindings.add(os.path.sep, eager=True) + def next_segment(event: KeyPressEvent): + b = event.app.current_buffer + + if b.complete_state: + b.complete_state = None + + current_path = b.document.text + if not current_path.endswith(os.path.sep): + b.insert_text(os.path.sep) + + b.start_completion(select_first=False) + + p: PromptSession = PromptSession( + get_prompt_tokens, + lexer=SimpleLexer("class:answer"), + style=merged_style, + completer=completer, + validator=validator, + complete_style=complete_style, + key_bindings=bindings, + **kwargs, + ) + p.default_buffer.reset(Document(default)) + + return Question(p.app) diff --git a/utils/questionary/questionary/prompts/press_any_key_to_continue.py b/utils/questionary/questionary/prompts/press_any_key_to_continue.py new file mode 100644 index 00000000..402eaeaf --- /dev/null +++ b/utils/questionary/questionary/prompts/press_any_key_to_continue.py @@ -0,0 +1,61 @@ +from typing import Any +from typing import Optional + +from prompt_toolkit import PromptSession +from prompt_toolkit.formatted_text import to_formatted_text +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.keys import Keys +from prompt_toolkit.styles import Style + +from ..question import Question +from ..styles import merge_styles_default + + +def press_any_key_to_continue( + message: Optional[str] = None, + style: Optional[Style] = None, + **kwargs: Any, +): + """Wait until user presses any key to continue. + + Example: + >>> import questionary + >>> questionary.press_any_key_to_continue().ask() + Press any key to continue... + '' + + Args: + message: Question text. Defaults to ``"Press any key to continue..."`` + + style: A custom color and style for the question parts. You can + configure colors as well as font types for different elements. + + Returns: + :class:`Question`: Question instance, ready to be prompted (using ``.ask()``). + """ + merged_style = merge_styles_default([style]) + + if message is None: + message = "Press any key to continue..." + + def get_prompt_tokens(): + tokens = [] + + tokens.append(("class:question", f" {message} ")) + + return to_formatted_text(tokens) + + def exit_with_result(event): + event.app.exit(result=None) + + bindings = KeyBindings() + + @bindings.add(Keys.Any) + def any_key(event): + exit_with_result(event) + + return Question( + PromptSession( + get_prompt_tokens, key_bindings=bindings, style=merged_style, **kwargs + ).app + ) diff --git a/utils/questionary/questionary/prompts/rawselect.py b/utils/questionary/questionary/prompts/rawselect.py new file mode 100644 index 00000000..3fe74573 --- /dev/null +++ b/utils/questionary/questionary/prompts/rawselect.py @@ -0,0 +1,79 @@ +from typing import Any +from typing import Dict +from typing import Optional +from typing import Sequence +from typing import Union + +from prompt_toolkit.styles import Style + +from ..constants import DEFAULT_QUESTION_PREFIX +from ..constants import DEFAULT_SELECTED_POINTER +from ..prompts import select +from ..prompts.common import Choice +from ..question import Question + + +def rawselect( + message: str, + choices: Sequence[Union[str, Choice, Dict[str, Any]]], + default: Optional[str] = None, + qmark: str = DEFAULT_QUESTION_PREFIX, + pointer: Optional[str] = DEFAULT_SELECTED_POINTER, + style: Optional[Style] = None, + **kwargs: Any, +) -> Question: + """Ask the user to select one item from a list of choices using shortcuts. + + The user can only select one option. + + Example: + >>> import questionary + >>> questionary.rawselect( + ... "What do you want to do?", + ... choices=[ + ... "Order a pizza", + ... "Make a reservation", + ... "Ask for opening hours" + ... ]).ask() + ? What do you want to do? Order a pizza + 'Order a pizza' + + .. image:: ../images/rawselect.gif + + This is just a really basic example, the prompt can be customised using the + parameters. + + Args: + message: Question text. + + choices: Items shown in the selection, this can contain :class:`Choice` or + or :class:`Separator` objects or simple items as strings. Passing + :class:`Choice` objects, allows you to configure the item more + (e.g. preselecting it or disabling it). + + default: Default return value (single value). + + qmark: Question prefix displayed in front of the question. + By default this is a ``?``. + + pointer: Pointer symbol in front of the currently highlighted element. + By default this is a ``»``. + Use ``None`` to disable it. + + style: A custom color and style for the question parts. You can + configure colors as well as font types for different elements. + + Returns: + :class:`Question`: Question instance, ready to be prompted (using ``.ask()``). + """ + return select.select( + message, + choices, + default, + qmark, + pointer, + style, + use_shortcuts=True, + use_arrow_keys=False, + **kwargs, + ) diff --git a/utils/questionary/questionary/prompts/select.py b/utils/questionary/questionary/prompts/select.py new file mode 100644 index 00000000..6f3ef8a6 --- /dev/null +++ b/utils/questionary/questionary/prompts/select.py @@ -0,0 +1,258 @@ +# -*- coding: utf-8 -*- + +from typing import Any +from typing import Dict +from typing import Optional +from typing import Sequence +from typing import Union + +from prompt_toolkit.application import Application +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.keys import Keys +from prompt_toolkit.styles import Style + +from .. import utils +from ..constants import DEFAULT_QUESTION_PREFIX +from ..constants import DEFAULT_SELECTED_POINTER +from ..prompts import common +from ..prompts.common import Choice +from ..prompts.common import InquirerControl +from ..prompts.common import Separator +from ..question import Question +from ..styles import merge_styles_default + + +def select( + message: str, + choices: Sequence[Union[str, Choice, Dict[str, Any]]], + default: Optional[Union[str, Choice, Dict[str, Any]]] = None, + qmark: str = DEFAULT_QUESTION_PREFIX, + pointer: Optional[str] = DEFAULT_SELECTED_POINTER, + style: Optional[Style] = None, + use_shortcuts: bool = False, + use_arrow_keys: bool = True, + use_indicator: bool = False, + use_jk_keys: bool = True, + use_emacs_keys: bool = True, + show_selected: bool = False, + show_description: bool = True, + instruction: Optional[str] = None, + **kwargs: Any, +) -> Question: + """A list of items to select **one** option from. + + The user can pick one option and confirm it (if you want to allow + the user to select multiple options, use :meth:`questionary.checkbox` instead). + + Example: + >>> import questionary + >>> questionary.select( + ... "What do you want to do?", + ... choices=[ + ... "Order a pizza", + ... "Make a reservation", + ... "Ask for opening hours" + ... ]).ask() + ? What do you want to do? Order a pizza + 'Order a pizza' + + .. image:: ../images/select.gif + + This is just a really basic example, the prompt can be customised using the + parameters. + + + Args: + message: Question text + + choices: Items shown in the selection, this can contain :class:`Choice` or + or :class:`Separator` objects or simple items as strings. Passing + :class:`Choice` objects, allows you to configure the item more + (e.g. preselecting it or disabling it). + + default: A value corresponding to a selectable item in the choices, + to initially set the pointer position to. + + qmark: Question prefix displayed in front of the question. + By default this is a ``?``. + + pointer: Pointer symbol in front of the currently highlighted element. + By default this is a ``»``. + Use ``None`` to disable it. + + instruction: A hint on how to navigate the menu. + It's ``(Use shortcuts)`` if only ``use_shortcuts`` is set + to True, ``(Use arrow keys or shortcuts)`` if ``use_arrow_keys`` + & ``use_shortcuts`` are set and ``(Use arrow keys)`` by default. + + style: A custom color and style for the question parts. You can + configure colors as well as font types for different elements. + + use_indicator: Flag to enable the small indicator in front of the + list highlighting the current location of the selection + cursor. + + use_shortcuts: Allow the user to select items from the list using + shortcuts. The shortcuts will be displayed in front of + the list items. Arrow keys, j/k keys and shortcuts are + not mutually exclusive. + + use_arrow_keys: Allow the user to select items from the list using + arrow keys. Arrow keys, j/k keys and shortcuts are not + mutually exclusive. + + use_jk_keys: Allow the user to select items from the list using + `j` (down) and `k` (up) keys. Arrow keys, j/k keys and + shortcuts are not mutually exclusive. + + use_emacs_keys: Allow the user to select items from the list using + `Ctrl+N` (down) and `Ctrl+P` (up) keys. Arrow keys, j/k keys, + emacs keys and shortcuts are not mutually exclusive. + + show_selected: Display current selection choice at the bottom of list. + + show_description: Display description of current selection if available. + + Returns: + :class:`Question`: Question instance, ready to be prompted (using ``.ask()``). + """ + if not (use_arrow_keys or use_shortcuts or use_jk_keys or use_emacs_keys): + raise ValueError( + ( + "Some option to move the selection is required. " + "Arrow keys, j/k keys, emacs keys, or shortcuts." + ) + ) + + if use_shortcuts and use_jk_keys: + if any(getattr(c, "shortcut_key", "") in ["j", "k"] for c in choices): + raise ValueError( + "A choice is trying to register j/k as a " + "shortcut key when they are in use as arrow keys " + "disable one or the other." + ) + + if choices is None or len(choices) == 0: + raise ValueError("A list of choices needs to be provided.") + + if use_shortcuts and len(choices) > len(InquirerControl.SHORTCUT_KEYS): + raise ValueError( + "A list with shortcuts supports a maximum of {} " + "choices as this is the maximum number " + "of keyboard shortcuts that are available. You" + "provided {} choices!" + "".format(len(InquirerControl.SHORTCUT_KEYS), len(choices)) + ) + + merged_style = merge_styles_default([style]) + + ic = InquirerControl( + choices, + default, + pointer=pointer, + use_indicator=use_indicator, + use_shortcuts=use_shortcuts, + show_selected=show_selected, + show_description=show_description, + use_arrow_keys=use_arrow_keys, + initial_choice=default, + ) + + def get_prompt_tokens(): + # noinspection PyListCreation + tokens = [("class:qmark", qmark), ("class:question", " {} ".format(message))] + + if ic.is_answered: + if isinstance(ic.get_pointed_at().title, list): + tokens.append( + ( + "class:answer", + "".join([token[1] for token in ic.get_pointed_at().title]), + ) + ) + else: + tokens.append(("class:answer", ic.get_pointed_at().title)) + else: + if instruction: + tokens.append(("class:instruction", instruction)) + else: + if use_shortcuts and use_arrow_keys: + instruction_msg = "(Use shortcuts or arrow keys)" + elif use_shortcuts and not use_arrow_keys: + instruction_msg = "(Use shortcuts)" + else: + instruction_msg = "(Use arrow keys)" + tokens.append(("class:instruction", instruction_msg)) + + return tokens + + layout = common.create_inquirer_layout(ic, get_prompt_tokens, **kwargs) + + bindings = KeyBindings() + + @bindings.add(Keys.ControlQ, eager=True) + @bindings.add(Keys.ControlC, eager=True) + def _(event): + event.app.exit(exception=KeyboardInterrupt, style="class:aborting") + + if use_shortcuts: + # add key bindings for choices + for i, c in enumerate(ic.choices): + if c.shortcut_key is None and not c.disabled and not use_arrow_keys: + raise RuntimeError( + "{} does not have a shortcut and arrow keys " + "for movement are disabled. " + "This choice is not reachable.".format(c.title) + ) + if isinstance(c, Separator) or c.shortcut_key is None: + continue + + # noinspection PyShadowingNames + def _reg_binding(i, keys): + # trick out late evaluation with a "function factory": + # https://stackoverflow.com/a/3431699 + @bindings.add(keys, eager=True) + def select_choice(event): + ic.pointed_at = i + + _reg_binding(i, c.shortcut_key) + + def move_cursor_down(event): + ic.select_next() + while not ic.is_selection_valid(): + ic.select_next() + + def move_cursor_up(event): + ic.select_previous() + while not ic.is_selection_valid(): + ic.select_previous() + + if use_arrow_keys: + bindings.add(Keys.Down, eager=True)(move_cursor_down) + bindings.add(Keys.Up, eager=True)(move_cursor_up) + + if use_jk_keys: + bindings.add("j", eager=True)(move_cursor_down) + bindings.add("k", eager=True)(move_cursor_up) + + if use_emacs_keys: + bindings.add(Keys.ControlN, eager=True)(move_cursor_down) + bindings.add(Keys.ControlP, eager=True)(move_cursor_up) + + @bindings.add(Keys.ControlM, eager=True) + def set_answer(event): + ic.is_answered = True + event.app.exit(result=ic.get_pointed_at().value) + + @bindings.add(Keys.Any) + def other(event): + """Disallow inserting other text.""" + + return Question( + Application( + layout=layout, + key_bindings=bindings, + style=merged_style, + **utils.used_kwargs(kwargs, Application.__init__), + ) + ) diff --git a/utils/questionary/questionary/prompts/text.py b/utils/questionary/questionary/prompts/text.py new file mode 100644 index 00000000..0812104c --- /dev/null +++ b/utils/questionary/questionary/prompts/text.py @@ -0,0 +1,101 @@ +from typing import Any +from typing import List +from typing import Optional +from typing import Tuple + +from prompt_toolkit.document import Document +from prompt_toolkit.lexers import Lexer +from prompt_toolkit.lexers import SimpleLexer +from prompt_toolkit.shortcuts.prompt import PromptSession +from prompt_toolkit.styles import Style + +from ..constants import DEFAULT_QUESTION_PREFIX +from ..constants import INSTRUCTION_MULTILINE +from ..prompts.common import build_validator +from ..question import Question +from ..styles import merge_styles_default + + +def text( + message: str, + default: str = "", + validate: Any = None, + qmark: str = DEFAULT_QUESTION_PREFIX, + style: Optional[Style] = None, + multiline: bool = False, + instruction: Optional[str] = None, + lexer: Optional[Lexer] = None, + **kwargs: Any, +) -> Question: + """Prompt the user to enter a free text message. + + This question type can be used to prompt the user for some text input. + + Example: + >>> import questionary + >>> questionary.text("What's your first name?").ask() + ? What's your first name? Tom + 'Tom' + + .. image:: ../images/text.gif + + This is just a really basic example, the prompt can be customised using the + parameters. + + Args: + message: Question text. + + default: Default value will be returned if the user just hits + enter. + + validate: Require the entered value to pass a validation. The + value can not be submitted until the validator accepts + it (e.g. to check minimum password length). + + This can either be a function accepting the input and + returning a boolean, or an class reference to a + subclass of the prompt toolkit Validator class. + + qmark: Question prefix displayed in front of the question. + By default this is a ``?``. + + style: A custom color and style for the question parts. You can + configure colors as well as font types for different elements. + + multiline: If ``True``, multiline input will be enabled. + + instruction: Write instructions for the user if needed. If ``None`` + and ``multiline=True``, some instructions will appear. + + lexer: Supply a valid lexer to style the answer. Leave empty to + use a simple one by default. + + kwargs: Additional arguments, they will be passed to prompt toolkit. + + Returns: + :class:`Question`: Question instance, ready to be prompted (using ``.ask()``). + """ + merged_style = merge_styles_default([style]) + lexer = lexer or SimpleLexer("class:answer") + validator = build_validator(validate) + + if instruction is None and multiline: + instruction = INSTRUCTION_MULTILINE + + def get_prompt_tokens() -> List[Tuple[str, str]]: + result = [("class:qmark", qmark), ("class:question", " {} ".format(message))] + if instruction: + result.append(("class:instruction", " {} ".format(instruction))) + return result + + p: PromptSession = PromptSession( + get_prompt_tokens, + style=merged_style, + validator=validator, + lexer=lexer, + multiline=multiline, + **kwargs, + ) + p.default_buffer.reset(Document(default)) + + return Question(p.app) diff --git a/utils/questionary/questionary/py.typed b/utils/questionary/questionary/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/utils/questionary/questionary/question.py b/utils/questionary/questionary/question.py new file mode 100644 index 00000000..28b4a12f --- /dev/null +++ b/utils/questionary/questionary/question.py @@ -0,0 +1,134 @@ +import sys +from typing import Any + +import prompt_toolkit.patch_stdout +from prompt_toolkit import Application + +from . import utils +from .constants import DEFAULT_KBI_MESSAGE + + +class Question: + """A question to be prompted. + + This is an internal class. Questions should be created using the + predefined questions (e.g. text or password).""" + + application: "Application[Any]" + should_skip_question: bool + default: Any + + def __init__(self, application: "Application[Any]") -> None: + self.application = application + self.should_skip_question = False + self.default = None + + async def ask_async( + self, patch_stdout: bool = False, kbi_msg: str = DEFAULT_KBI_MESSAGE + ) -> Any: + """Ask the question using asyncio and return user response. + + Args: + patch_stdout: Ensure that the prompt renders correctly if other threads + are printing to stdout. + + kbi_msg: The message to be printed on a keyboard interrupt. + + Returns: + `Any`: The answer from the question. + """ + + try: + sys.stdout.flush() + return await self.unsafe_ask_async(patch_stdout) + except KeyboardInterrupt: + print("\n{}\n".format(kbi_msg)) + return None + + def ask( + self, patch_stdout: bool = False, kbi_msg: str = DEFAULT_KBI_MESSAGE + ) -> Any: + """Ask the question synchronously and return user response. + + Args: + patch_stdout: Ensure that the prompt renders correctly if other threads + are printing to stdout. + + kbi_msg: The message to be printed on a keyboard interrupt. + + Returns: + `Any`: The answer from the question. + """ + + try: + return self.unsafe_ask(patch_stdout) + except KeyboardInterrupt: + print("\n{}\n".format(kbi_msg)) + return None + + def unsafe_ask(self, patch_stdout: bool = False) -> Any: + """Ask the question synchronously and return user response. + + Does not catch keyboard interrupts. + + Args: + patch_stdout: Ensure that the prompt renders correctly if other threads + are printing to stdout. + + Returns: + `Any`: The answer from the question. + """ + + if self.should_skip_question: + return self.default + + if patch_stdout: + with prompt_toolkit.patch_stdout.patch_stdout(): + return self.application.run() + else: + return self.application.run() + + def skip_if(self, condition: bool, default: Any = None) -> "Question": + """Skip the question if flag is set and return the default instead. + + Args: + condition: A conditional boolean value. + default: The default value to return. + + Returns: + :class:`Question`: `self`. + """ + + self.should_skip_question = condition + self.default = default + return self + + async def unsafe_ask_async(self, patch_stdout: bool = False) -> Any: + """Ask the question using asyncio and return user response. + + Does not catch keyboard interrupts. + + Args: + patch_stdout: Ensure that the prompt renders correctly if other threads + are printing to stdout. + + Returns: + `Any`: The answer from the question. + """ + + if self.should_skip_question: + return self.default + + if not utils.ACTIVATED_ASYNC_MODE: + await utils.activate_prompt_toolkit_async_mode() + + if patch_stdout: + with prompt_toolkit.patch_stdout.patch_stdout(): + r = self.application.run_async() + else: + r = self.application.run_async() + + if utils.is_prompt_toolkit_3(): + return await r + else: + return await r.to_asyncio_future() # type: ignore[attr-defined] diff --git a/utils/questionary/questionary/styles.py b/utils/questionary/questionary/styles.py new file mode 100644 index 00000000..52adb101 --- /dev/null +++ b/utils/questionary/questionary/styles.py @@ -0,0 +1,16 @@ + +from typing import List +from typing import Optional + +import prompt_toolkit.styles + +from .constants import DEFAULT_STYLE + + +def merge_styles_default(styles: List[Optional[prompt_toolkit.styles.Style]]): + """Merge a list of styles with the Questionary default style.""" + filtered_styles: list[prompt_toolkit.styles.BaseStyle] = [DEFAULT_STYLE] + # prompt_toolkit's merge_styles works with ``None`` elements, but it's + # type-hints says it doesn't. + filtered_styles.extend([s for s in styles if s is not None]) + return prompt_toolkit.styles.merge_styles(filtered_styles) diff --git a/utils/questionary/questionary/utils.py b/utils/questionary/questionary/utils.py new file mode 100644 index 00000000..d4fb9419 --- /dev/null +++ b/utils/questionary/questionary/utils.py @@ -0,0 +1,78 @@ +import inspect +from typing import Any +from typing import Callable +from typing import Dict +from typing import List +from typing import Set + +ACTIVATED_ASYNC_MODE = False + + +def is_prompt_toolkit_3() -> bool: + from prompt_toolkit import __version__ as ptk_version + + return ptk_version.startswith("3.") + + +def default_values_of(func: Callable[..., Any]) -> List[str]: + """Return all parameter names of ``func`` with a default value.""" + + signature = inspect.signature(func) + return [ + k + for k, v in signature.parameters.items() + if v.default is not inspect.Parameter.empty + or v.kind != inspect.Parameter.POSITIONAL_OR_KEYWORD + ] + + +def arguments_of(func: Callable[..., Any]) -> List[str]: + """Return the parameter names of the function ``func``.""" + + return list(inspect.signature(func).parameters.keys()) + + +def used_kwargs(kwargs: Dict[str, Any], func: Callable[..., Any]) -> Dict[str, Any]: + """Returns only the kwargs which can be used by a function. + + Args: + kwargs: All available kwargs. + func: The function which should be called. + + Returns: + Subset of kwargs which are accepted by ``func``. + """ + + possible_arguments = arguments_of(func) + + return {k: v for k, v in kwargs.items() if k in possible_arguments} + + +def required_arguments(func: Callable[..., Any]) -> List[str]: + """Return all arguments of a function that do not have a default value.""" + defaults = default_values_of(func) + args = arguments_of(func) + + if defaults: + args = args[: -len(defaults)] + return args # all args without default values + + +def missing_arguments(func: Callable[..., Any], argdict: Dict[str, Any]) -> Set[str]: + """Return all arguments that are missing to call func.""" + return set(required_arguments(func)) - set(argdict.keys()) + + +async def activate_prompt_toolkit_async_mode() -> None: + """Configure prompt toolkit to use the asyncio event loop. + + Needs to be async, so we use the right event loop in py 3.5""" + global ACTIVATED_ASYNC_MODE + + if not is_prompt_toolkit_3(): + # Tell prompt_toolkit to use asyncio for the event loop. + import prompt_toolkit as pt + + pt.eventloop.use_asyncio_event_loop() # type: ignore[attr-defined] + + ACTIVATED_ASYNC_MODE = True diff --git a/utils/questionary/questionary/version.py b/utils/questionary/questionary/version.py new file mode 100644 index 00000000..cf52fbca --- /dev/null +++ b/utils/questionary/questionary/version.py @@ -0,0 +1 @@ +__version__ = "2.0.2-beta" From d077bdd98ad66d6428784b0b15bf5f00567f7f4c Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Sun, 29 Oct 2023 15:39:09 +0800 Subject: [PATCH 09/31] =?UTF-8?q?chore:=20=E6=94=B9=E7=94=A8=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E7=9A=84questionary=E6=A8=A1=E5=9D=97=EF=BC=8C?= =?UTF-8?q?=E4=BD=BF=E4=B9=8B=E5=85=B7=E5=A4=87show=5Fdescription=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/relic.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/utils/relic.py b/utils/relic.py index 4f344a34..5fc8a28e 100644 --- a/utils/relic.py +++ b/utils/relic.py @@ -2,11 +2,13 @@ import time import math import pprint -import questionary import numpy as np from collections import Counter from typing import Any, Dict, List, Literal, Optional, Tuple, Union +from .questionary.questionary import select +# 改用本地的questionary模块,使之具备show_description功能,基于'tmbo/questionary/pull/330' +# from questionary import select # questionary原项目更新并具备当前功能后,可进行替换 from .relic_constants import * from .calculated import calculated, Array2dict, get_data_hash, str_just from .config import (read_json_file, modify_json_file, rewrite_json_file, @@ -72,7 +74,7 @@ def __init__(self, title=_("崩坏:星穹铁道")): # 校验遗器哈希值 if not self.check_relic_data_hash(): - option = questionary.select(_("是否依据当前遗器数据更新哈希值:"), [_("是"), _("否")]).ask() + option = select(_("是否依据当前遗器数据更新哈希值:"), [_("是"), _("否")]).ask() if option == _("是"): self.check_relic_data_hash(updata=True) @@ -86,7 +88,7 @@ def relic_entrance(self): option = None # 保存上一次的选择 while True: self.calculated.switch_cmd() - option = questionary.select(title, options, default=option).ask() + option = select(title, options, default=option).ask() if option == _("保存当前人物的配装"): self.calculated.switch_window() self.save_loadout_for_char() @@ -122,7 +124,7 @@ def equip_loadout_for_char(self): options_map = {str_just(loadout_name, 12) + self.get_loadout_brief(hash_list): hash_list for loadout_name, hash_list in character_data.items()} options = list(options_map.keys()) options.append(_("返回上一级")) - option = questionary.select(title, options).ask() + option = select(title, options).ask() if option == _("返回上一级"): return relic_hash = options_map[option] From 455018f12574b5420d07c83fdffa18ac12eae195 Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Sun, 29 Oct 2023 15:56:56 +0800 Subject: [PATCH 10/31] =?UTF-8?q?feat:=20=E9=80=89=E6=8B=A9=E9=81=97?= =?UTF-8?q?=E5=99=A8=E9=85=8D=E8=A3=85=E6=97=B6=E5=8F=AF=E6=89=93=E5=8D=B0?= =?UTF-8?q?=E9=85=8D=E8=A3=85=E7=9A=84=E8=AF=A6=E7=BB=86=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/relic.py | 85 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 74 insertions(+), 11 deletions(-) diff --git a/utils/relic.py b/utils/relic.py index 5fc8a28e..29ce4bec 100644 --- a/utils/relic.py +++ b/utils/relic.py @@ -108,26 +108,25 @@ def equip_loadout_for_team(self): """ ... - def equip_loadout_for_char(self): + def equip_loadout_for_char(self, character_name :Optional[str]=None): """ 说明: 装备当前[人物]界面本人物的遗器配装 """ # 识别当前人物名称 - character_name = self.ocr_character_name() + character_name = self.ocr_character_name() if character_name is None else character_name character_data = self.loadout_data[character_name] # 选择配装 if not character_data: # 字典为空 log.info(_("当前人物配装记录为空")) return - title = _("请选择将要进行装备的配装:") - options_map = {str_just(loadout_name, 12) + self.get_loadout_brief(hash_list): hash_list for loadout_name, hash_list in character_data.items()} - options = list(options_map.keys()) - options.append(_("返回上一级")) - option = select(title, options).ask() + option = select( + _("请选择将要进行装备的配装:"), + choices = self.get_loadout_choice_options(character_name) + [(_("返回上一级"))], + show_description = True, # 需questionary具备对show_description的相关支持 + ).ask() if option == _("返回上一级"): return - relic_hash = options_map[option] self.calculated.switch_window() # 进行配装 self.calculated.relative_click((12,40) if IS_PC else (16,48)) # 点击遗器,进入[人物]-[遗器]界面 @@ -136,7 +135,7 @@ def equip_loadout_for_char(self): time.sleep(2) self.calculated.relative_click((82,12) if IS_PC else (78,12)) # 点击遗器[对比],将遗器详情的背景由星空变为纯黑 time.sleep(1) - self.equip_loadout(relic_hash) + self.equip_loadout(option) self.calculated.relative_click((97,6) if IS_PC else (96,5)) # 退出[遗器]界面,返回[人物]界面 time.sleep(1) @@ -646,6 +645,70 @@ def print_relic(self, data: Dict[str, Any]): token.append('-'*50) print("\n".join(token)) + def get_loadout_choice_options(self, character_name:str) -> List[Dict[str, Any]]: + """ + 说明: + 获取该人物配装记录的选项列表,包含配装的简要信息与详细信息 + 参数: + :param character_name: 人物名称 + 返回: + :return choice_options: 人物配装记录的选项表,格式如下: + [{ + "name":str = 配装名称+配装简要信息, + "value":list[str] = 遗器哈希值列表 (作为选项的返回值), + "description":str = 配装详细信息 + },...] + """ + character_data = self.loadout_data[character_name] + choice_options = [{ + "name": str_just(loadout_name, 12) + " " + self.get_loadout_brief(hash_list), + "value": hash_list, + "description": '\n' + self.get_loadout_detail(hash_list, 5) # 需questionary具备对show_description的相关支持 + } for loadout_name, hash_list in character_data.items()] + return choice_options + + def get_loadout_detail(self, relics_hash: List[str], tab_num: int=0) -> str: + """ + 说明: + 获取配装的详细信息 (各属性数值统计) + """ + stats_total_value = [0 for _ in range(len(STATS_NAME))] + stats_name_dict = Array2dict(STATS_NAME) + base_stats_dict = Array2dict(BASE_STATS_NAME) + subs_stats_dict = Array2dict(SUBS_STATS_NAME) + for equip_indx in range(len((relics_hash))): + tmp_data = self.relics_data[relics_hash[equip_indx]] + rarity = tmp_data["rarity"] + level = tmp_data["level"] + stats_list = [(key, self.get_base_stats_detail((key, value), rarity, level, base_stats_dict[key])) + for key, value in tmp_data["base_stats"].items()] # 获取数值精度提高后的主词条 + stats_list.extend([(key, self.get_subs_stats_detail((key, value), rarity, subs_stats_dict[key])[-1]) + for key, value in tmp_data["subs_stats"].items()]) # 获取数值精度提高后的副词条 + for key, value in stats_list: + stats_total_value[stats_name_dict[key]] += value # 数值统计 + token_list = [] + has_ = False # 标记有无属性伤害 + for index, value in enumerate(stats_total_value): + name = STATS_NAME[index, -1] + if index in range(11, 18): + if index == 17 and not has_ and value == 0 : # 无属性伤害的情形 + name = _("属性伤害") + elif value == 0: continue + else: has_ = True + pre = " " if name in NOT_PRE_STATS else "%" + token_list.append(_("{name}{value:>7.{ndigits}f}{pre}").format(name=str_just(name, 15), value=value, pre=pre, ndigits=self.ndigits)) + msg = "" + column = 2 # 栏数 (可调节) + tab = " " * tab_num + for index in range(len(token_list)): # 分栏输出 (纵向顺序,横向逆序,保证余数项在左栏) + i = index // column + j = index % column + n = (column-j-1) * len(token_list) // column + i + msg += token_list[n] if j != 0 else tab+token_list[n] + msg += "\n" if j == column-1 else " " + msg += "\n\n" + tab + _("(未计算遗器套装的属性加成)") # 【待扩展】 + return msg + def get_loadout_brief(self, relics_hash: List[str]) -> str: """ 说明: @@ -720,9 +783,9 @@ def get_subs_stats_detail(self, data: Tuple[str, float], rarity: int, stats_inde :return score: 档位总积分: 1档记0分, 2档记1分, 3档记2分 :return result: 修正后数值 (提高了原数值精度) """ - if not self.is_check_stats or rarity not in [4,5]: # 仅支持五星遗器与四星遗器 - return (0,0,0) name, value = data + if not self.is_check_stats or rarity not in [4,5]: # 仅支持五星遗器与四星遗器 + return (-1, -1, value) stats_index = np.where(SUBS_STATS_NAME[:, -1] == name)[0][0] if stats_index is None else stats_index rarity_index = rarity - 4 # 0-四星,1-五星 a, d = SUBS_STATS_TIER[rarity_index][stats_index] From 6c7582aa58b094fdf9551b706f43a5c476bd24a8 Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Wed, 1 Nov 2023 16:07:13 +0800 Subject: [PATCH 11/31] =?UTF-8?q?chore:=20=E6=9B=B4=E6=94=B9=E9=98=9F?= =?UTF-8?q?=E4=BC=8D=E9=85=8D=E8=A3=85=E6=95=B0=E6=8D=AEjson=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E8=A7=84=E8=8C=83=EF=BC=8C=E4=BB=A5=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E6=9C=AA=E6=9D=A5=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/relic.py | 6 +++--- utils/relic_constants.py | 32 ++++++++++++++++++++++++-------- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/utils/relic.py b/utils/relic.py index 29ce4bec..10607b81 100644 --- a/utils/relic.py +++ b/utils/relic.py @@ -66,9 +66,9 @@ def __init__(self, title=_("崩坏:星穹铁道")): """在打印遗器信息时的小数精度""" # 读取json文件,仅初始化时检查格式规范 - self.relics_data = read_json_file(RELIC_FILE_NAME, schema = RELIC_SCHEMA) - self.loadout_data = read_json_file(LOADOUT_FILE_NAME, schema = LOADOUT_SCHEMA) - self.team_data = read_json_file(TEAM_FILE_NAME, schema = TEAM_SCHEMA) + self.relics_data: Dict[str, Dict[str, Any]] = read_json_file(RELIC_FILE_NAME, schema = RELIC_SCHEMA) + self.loadout_data: Dict[str, Dict[str, List[str]]] = read_json_file(LOADOUT_FILE_NAME, schema = LOADOUT_SCHEMA) + self.team_data: Dict[str, Dict[str, Any]] = read_json_file(TEAM_FILE_NAME, schema = TEAM_SCHEMA) log.info(_("遗器数据载入完成")) log.info(_(f"共载入 {len(list(self.relics_data.keys()))} 件遗器数据")) diff --git a/utils/relic_constants.py b/utils/relic_constants.py index 57381fc0..755ccfd1 100644 --- a/utils/relic_constants.py +++ b/utils/relic_constants.py @@ -102,7 +102,7 @@ BASE_STATS_TIER[i][10:10] = [BASE_STATS_TIER[i][10]] * 6 # 复制属性伤害 -RELIC_SCHEMA = { # 遗器数据集 +RELIC_SCHEMA = { "type": "object", "additionalProperties": { # [主键]遗器哈希值 (由其键值遗器数据自动生成) "type": "object", @@ -150,7 +150,7 @@ }} """遗器数据json格式规范""" -LOADOUT_SCHEMA = { # 人物遗器配装数据集 +LOADOUT_SCHEMA = { "type": "object", "additionalProperties": { # [主键]人物名称 (以OCR结果为准) "type": "object", @@ -162,16 +162,32 @@ }}} """人物配装数据json格式规范""" -TEAM_SCHEMA = { # 队伍遗器配装数据集 - "type": "object", +TEAM_SCHEMA_PART = { "additionalProperties": { # [主键]队伍名称 (自定义) "type": "object", - "additionalProperties": { # [外键]队伍成员名称 (以OCR结果为准) - "type": "string" # [外键]各队伍成员的配装名称 + "properties": { + "team_members": { # 队伍成员 (无序,1-4人) + "type": "object", + "additionalProperties": { # [外键]队伍成员名称 (以OCR结果为准) + "type": "string" # [外键]各队伍成员的配装名称 + }, + "minProperties": 1, + "maxProperties": 4 + }, + # 【可扩展】如"visible","ordered"等其他队伍属性 }, - "minProperties": 1, - "maxProperties": 4 + "required": ["team_members"], # 需包含遗器的全部固有属性 + "additionalProperties": False }} +TEAM_SCHEMA = { + "type": "object", + "properties": { + "compatible": # [主键]非互斥队伍组别 (默认,不可更改) + TEAM_SCHEMA_PART, + }, + "additionalProperties": # [主键]互斥队伍组别名称 (自定义,例如用于忘却之庭上下半配队)【待扩展】 + TEAM_SCHEMA_PART, +} """队伍配装数据json格式规范""" RELIC_DATA_FILTER = ["pre_ver_hash"] From f9c716de2d5278d9beb3b85b0e71811a138cbaeb Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Wed, 1 Nov 2023 16:13:07 +0800 Subject: [PATCH 12/31] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E9=98=9F=E4=BC=8D=E9=85=8D=E8=A3=85=E9=81=97=E5=99=A8?= =?UTF-8?q?=E5=86=B2=E7=AA=81=E7=9A=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/relic.py | 61 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/utils/relic.py b/utils/relic.py index 10607b81..0b21245a 100644 --- a/utils/relic.py +++ b/utils/relic.py @@ -77,6 +77,9 @@ def __init__(self, title=_("崩坏:星穹铁道")): option = select(_("是否依据当前遗器数据更新哈希值:"), [_("是"), _("否")]).ask() if option == _("是"): self.check_relic_data_hash(updata=True) + # 校验队伍配装规范 + if not self.check_team_data(): + log.error(_("怀疑为手动错误修改json文件导致")) def relic_entrance(self): """ @@ -398,7 +401,63 @@ def compare_relics(self, old_data: Dict[str, Any], new_data: Dict[str, Any]) -> key != _("速度") and old_data["subs_stats"][key] > new_data["subs_stats"][key]: return False # 考虑手动提高速度数据精度的情况 return True - + + def check_team_data(self) -> bool: + """ + 说明: + 检查队伍配装数据是否满足规范 + """ + ret = True + for group_name, team_group in self.team_data.items(): + for team_name, team_data in team_group.items(): + loadout_dict = self.HashList2dict() + [loadout_dict.add(self.loadout_data[char_name][loadout_name], char_name) for char_name, loadout_name in team_data["team_members"].items()] + for equip_index, char_names, element in loadout_dict.find_duplicate_hash(): + log.error(_("编队遗器冲突:'{}'队伍的{}间的'{}'遗器冲突,遗器哈希值:{}").format(team_name, char_names, EQUIP_SET_NAME[equip_index], element)) + ret = False + if group_name != "compatible": ... # 互斥队伍组别【待扩展】 + return ret + + class HashList2dict: + """ + 说明: + 将队伍或队伍组别的配装按遗器部位进行字典统计,以查找可能存在的重复遗器 + """ + + def __init__(self): + self.hash_dict_for_equip: List[Dict[str, List[str]]] = [{},{},{},{},{},{}] + """按遗器部位分别的配装统计,key-遗器哈希值,value-装备者的名称""" + + def add(self, relics_hash: List[str], char_name: str) -> 'Relic.HashList2dict': + """ + 说明: + 添加一组数据 + 参数: + :param relics_hash: 配装遗器哈希值列表 + :param char_name: 配装装备者的名称 + """ + for equip_index, element in enumerate(relics_hash): + if element in self.hash_dict_for_equip[equip_index]: + self.hash_dict_for_equip[equip_index][element].append(char_name) + else: + self.hash_dict_for_equip[equip_index][element] = [char_name] + return self + + def find_duplicate_hash(self) -> List[Tuple[int, List[str], str]]: + """ + 说明: + 按遗器部位遍历字典,查找可能存在的重复遗器 + 返回: + :return ret[list(tuple)]: + 0-遗器部位索引,1-装备者的名称序列,2-遗器哈希值 + """ + ret = [] + for equip_index in range(len(self.hash_dict_for_equip)): + for element, char_names in self.hash_dict_for_equip[equip_index].items(): + if len(char_names) > 1: + ret.append((equip_index, char_names, element)) + return ret + def check_relic_data_hash(self, updata=False) -> bool: """ 说明: From cafcdd9bd7b36fd59a1ceac1c60357c87dc3a7d5 Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Wed, 1 Nov 2023 16:22:32 +0800 Subject: [PATCH 13/31] =?UTF-8?q?fix:=20=E5=86=8D=E6=AC=A1=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E7=94=B1=E4=BA=8E=E8=83=8C=E6=99=AF=E5=85=89=E7=82=B9?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E7=9A=84=E4=BA=BA=E7=89=A9=E5=90=8D=E7=A7=B0?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/relic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/relic.py b/utils/relic.py index 0b21245a..1196d22d 100644 --- a/utils/relic.py +++ b/utils/relic.py @@ -534,7 +534,7 @@ def ocr_character_name(self) -> str: :return character_name: 人物名称 """ str = self.calculated.ocr_pos_for_single_line(points=(10.4,6,18,9) if IS_PC else (13,4,22,9)) # 识别人物名称 (主角名称为玩家自定义,无法适用预选列表) - character_name = re.sub(r"[.’,,。、·'-_——\"/\\]", '', str) # 删除由于背景光点造成的误判 + character_name = re.sub(r"[.’,,。、·'-_——「」/|\[\]\"\\]", '', str) # 删除由于背景光点造成的误判 log.info(_(f"识别人物: {character_name}")) if character_name not in self.loadout_data: self.loadout_data = modify_json_file(LOADOUT_FILE_NAME, character_name, {}) From b1d6ee90e8821458d4d42ac730b4254fdb3cd54e Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Wed, 1 Nov 2023 16:26:59 +0800 Subject: [PATCH 14/31] =?UTF-8?q?chore:=20=E6=9B=B4=E6=94=B9=E9=83=A8?= =?UTF-8?q?=E5=88=86=E9=94=99=E8=AF=AF=E7=9A=84=E6=8A=9B=E5=87=BA=E6=96=B9?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/config.py | 2 +- utils/relic.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/utils/config.py b/utils/config.py index 9cfbe912..5827a69e 100644 --- a/utils/config.py +++ b/utils/config.py @@ -53,7 +53,7 @@ def read_json_file(filename: str, path=False, schema:dict=None) -> dict: try: jsonschema.validate(data, schema) except jsonschema.exceptions.ValidationError as e: - log.error(_(f"JSON 数据不符合格式规范: {e}")) + raise Exception(_(f"JSON 数据不符合格式规范: {e}")) if path: return data, file_path else: diff --git a/utils/relic.py b/utils/relic.py index 1196d22d..b85c7d27 100644 --- a/utils/relic.py +++ b/utils/relic.py @@ -818,11 +818,11 @@ def get_base_stats_detail(self, data: Tuple[str, float], rarity: int, level: int result = round(a + d*level, 4) # 四舍五入 (考虑浮点数运算的数值损失) # 校验数据 check = result - value - log.debug(f"[{a}, {d}], l={level} r={result}") if check < 0 or \ name in NOT_PRE_STATS and check >= 1 or \ name not in NOT_PRE_STATS and check >= 0.1: log.error(_(f"校验失败,原数据或计算方法有误: {data}")) + log.debug(f"[{a}, {d}], l={level} r={result}") return None return result @@ -861,12 +861,12 @@ def get_subs_stats_detail(self, data: Tuple[str, float], rarity: int, stats_inde result = round(a*level + d*score, 4) # 四舍五入 (考虑浮点数运算的数值损失) # 校验数据 check = result - value - log.debug(f"[{a}, {d}], l={level}, s={score}, r={result}") if check < 0 or \ name in NOT_PRE_STATS and check >= 1 or \ name not in NOT_PRE_STATS and check >= 0.1 or \ level > 6 or level < 1 or \ score > level*2 or score < 0: log.error(_(f"校验失败,原数据或计算方法有误: {data}")) + log.debug(f"[{a}, {d}], l={level}, s={score}, r={result}") return None return (level, score, result) \ No newline at end of file From c6844d7c1086d796c0241ce56c9c0dcb412539cf Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Wed, 1 Nov 2023 16:38:58 +0800 Subject: [PATCH 15/31] =?UTF-8?q?chore:=20=E7=BE=8E=E5=8C=96=E9=81=97?= =?UTF-8?q?=E5=99=A8=E6=A8=A1=E5=9D=97=E7=9B=B8=E5=85=B3=E6=89=93=E5=8D=B0?= =?UTF-8?q?=E5=87=BD=E6=95=B0=EF=BC=8C=E5=85=BC=E5=AE=B9=E5=90=8E=E7=BB=AD?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/relic.py | 49 +++++++++++++++++++++++++------------------------ 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/utils/relic.py b/utils/relic.py index b85c7d27..93a45c9d 100644 --- a/utils/relic.py +++ b/utils/relic.py @@ -6,9 +6,9 @@ from collections import Counter from typing import Any, Dict, List, Literal, Optional, Tuple, Union -from .questionary.questionary import select +from .questionary.questionary import select, Choice # 改用本地的questionary模块,使之具备show_description功能,基于'tmbo/questionary/pull/330' -# from questionary import select # questionary原项目更新并具备当前功能后,可进行替换 +# from questionary import select, Choice # questionary原项目更新并具备当前功能后,可进行替换 from .relic_constants import * from .calculated import calculated, Array2dict, get_data_hash, str_just from .config import (read_json_file, modify_json_file, rewrite_json_file, @@ -125,11 +125,12 @@ def equip_loadout_for_char(self, character_name :Optional[str]=None): return option = select( _("请选择将要进行装备的配装:"), - choices = self.get_loadout_choice_options(character_name) + [(_("返回上一级"))], + choices = self.get_loadout_choice_options(character_name) + [(_("<返回上一级>"))], show_description = True, # 需questionary具备对show_description的相关支持 ).ask() - if option == _("返回上一级"): + if option == _("<返回上一级>"): return + loadout_name, relic_hash = option self.calculated.switch_window() # 进行配装 self.calculated.relative_click((12,40) if IS_PC else (16,48)) # 点击遗器,进入[人物]-[遗器]界面 @@ -138,7 +139,7 @@ def equip_loadout_for_char(self, character_name :Optional[str]=None): time.sleep(2) self.calculated.relative_click((82,12) if IS_PC else (78,12)) # 点击遗器[对比],将遗器详情的背景由星空变为纯黑 time.sleep(1) - self.equip_loadout(option) + self.equip_loadout(relic_hash) self.calculated.relative_click((97,6) if IS_PC else (96,5)) # 退出[遗器]界面,返回[人物]界面 time.sleep(1) @@ -704,29 +705,27 @@ def print_relic(self, data: Dict[str, Any]): token.append('-'*50) print("\n".join(token)) - def get_loadout_choice_options(self, character_name:str) -> List[Dict[str, Any]]: + def get_loadout_choice_options(self, character_name:str) -> List[Choice]: """ 说明: - 获取该人物配装记录的选项列表,包含配装的简要信息与详细信息 + 获取该人物配装记录的选项表 参数: :param character_name: 人物名称 返回: - :return choice_options: 人物配装记录的选项表,格式如下: - [{ - "name":str = 配装名称+配装简要信息, - "value":list[str] = 遗器哈希值列表 (作为选项的返回值), - "description":str = 配装详细信息 - },...] + :return choice_options: 人物配装记录的选项表,Choice构造参数如下: + :return title: 配装名称+配装简要信息, + :return value: 元组(配装名称, 遗器哈希值列表), + :return description: 配装各属性数值统计 """ character_data = self.loadout_data[character_name] - choice_options = [{ - "name": str_just(loadout_name, 12) + " " + self.get_loadout_brief(hash_list), - "value": hash_list, - "description": '\n' + self.get_loadout_detail(hash_list, 5) # 需questionary具备对show_description的相关支持 - } for loadout_name, hash_list in character_data.items()] + choice_options = [Choice( + title = str_just(loadout_name, 14) + " " + self.get_loadout_brief(relics_hash), + value = (loadout_name, relics_hash), + description = '\n' + self.get_loadout_detail(relics_hash, 5, True) + ) for loadout_name, relics_hash in character_data.items()] return choice_options - def get_loadout_detail(self, relics_hash: List[str], tab_num: int=0) -> str: + def get_loadout_detail(self, relics_hash: List[str], tab_num: int=0, flag=False) -> str: """ 说明: 获取配装的详细信息 (各属性数值统计) @@ -765,7 +764,8 @@ def get_loadout_detail(self, relics_hash: List[str], tab_num: int=0) -> str: n = (column-j-1) * len(token_list) // column + i msg += token_list[n] if j != 0 else tab+token_list[n] msg += "\n" if j == column-1 else " " - msg += "\n\n" + tab + _("(未计算遗器套装的属性加成)") # 【待扩展】 + if msg[-1] != "\n": msg += "\n" + if flag: msg += "\n" + tab + _("(未计算遗器套装的属性加成)") # 【待扩展】 return msg def get_loadout_brief(self, relics_hash: List[str]) -> str: @@ -789,10 +789,11 @@ def get_loadout_brief(self, relics_hash: List[str]) -> str: outer_set_cnt = Counter(outer_set_list) inner_set_cnt = Counter(inner_set_list) # 生成信息 - msg = "外:" + '+'.join([str(cnt) + name for name, cnt in outer_set_cnt.items()]) + " " - msg += "内:" + '+'.join([str(cnt) + name for name, cnt in inner_set_cnt.items()]) + " " - # msg += " ".join([self.equip_set_abbr[idx]+":"+name for idx, name in enumerate(base_stats_list) if idx > 1]) - msg += ".".join([name for idx, name in enumerate(base_stats_list) if idx > 1]) # 排除头部与手部 + inner = _("外:") + '+'.join([str(cnt) + name for name, cnt in outer_set_cnt.items()]) + " " + outer = _("内:") + '+'.join([str(cnt) + name for name, cnt in inner_set_cnt.items()]) + " " + # stats = " ".join([self.equip_set_abbr[idx]+":"+name for idx, name in enumerate(base_stats_list) if idx > 1]) + stats = ".".join([name for idx, name in enumerate(base_stats_list) if idx > 1]) # 排除头部与手部 + msg = str_just(stats, 17) + " " + str_just(outer, 10) + " " + inner # 将长度最不定的外圈信息放至最后 return msg def get_base_stats_detail(self, data: Tuple[str, float], rarity: int, level: int, stats_index: Optional[int]=None) -> Optional[float]: From cd1bb2e012874fcd3bf2478b67f0a4202b860241 Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Wed, 1 Nov 2023 16:54:05 +0800 Subject: [PATCH 16/31] =?UTF-8?q?chore:=20=E9=87=8D=E6=9E=84=E9=81=97?= =?UTF-8?q?=E5=99=A8=E7=9A=84save=E4=B8=8Eequip=E5=87=BD=E6=95=B0=EF=BC=8C?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E5=90=8E=E7=BB=AD=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/relic.py | 62 ++++++++++++++++++++++++++------------------------ 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/utils/relic.py b/utils/relic.py index 93a45c9d..d055467e 100644 --- a/utils/relic.py +++ b/utils/relic.py @@ -135,18 +135,12 @@ def equip_loadout_for_char(self, character_name :Optional[str]=None): # 进行配装 self.calculated.relative_click((12,40) if IS_PC else (16,48)) # 点击遗器,进入[人物]-[遗器]界面 time.sleep(0.5) - self.calculated.relative_click((38,26) if IS_PC else (36,21)) # 点击头部遗器,进入[人物]-[遗器]-[遗器详情]界面 - time.sleep(2) - self.calculated.relative_click((82,12) if IS_PC else (78,12)) # 点击遗器[对比],将遗器详情的背景由星空变为纯黑 - time.sleep(1) self.equip_loadout(relic_hash) - self.calculated.relative_click((97,6) if IS_PC else (96,5)) # 退出[遗器]界面,返回[人物]界面 - time.sleep(1) def equip_loadout(self, relics_hash:List[str]): """ 说明: - 装备当前[人物]-[遗器]-[遗器详情]页面内的指定遗器配装。 + 装备当前[人物]-[遗器]页面内的指定遗器配装。 遗器将强制替换 (1-替换己方已装备的遗器,2-替换对方已装备的遗器) 参数: :param relics_hash: 遗器配装哈希值列表 @@ -154,9 +148,13 @@ def equip_loadout(self, relics_hash:List[str]): equip_pos_list = [(4,13),(9,13),(13,13),(18,13),(23,13),(27,13)] if IS_PC else [(5,14),(11,14),(17,14),(23,14),(28,14),(34,14)] relic_filter = self.Relic_filter(self.calculated) # 遗器筛选器初始化 relic_set_name_dict = Array2dict(RELIC_SET_NAME) + self.calculated.relative_click((38,26) if IS_PC else (36,21)) # 点击头部遗器,进入[人物]-[遗器]-[遗器详情]界面 + time.sleep(2) + self.calculated.relative_click((82,12) if IS_PC else (78,12)) # 点击遗器[对比],将遗器详情的背景由星空变为纯黑 + time.sleep(1) for equip_indx, equip_pos in enumerate(equip_pos_list): # 遗器部位循环 # 选择部位 - log.info(_(f"选择部位:{self.equip_set_name[equip_indx]}")) + log.info(_(f"选择部位:{EQUIP_SET_NAME[equip_indx]}")) self.calculated.relative_click(equip_pos) time.sleep(0.5) # 获取遗器数据 @@ -186,6 +184,8 @@ def equip_loadout(self, relics_hash:List[str]): time.sleep(0.5) elif button == 2: log.info(_("已装备")) + self.calculated.relative_click((97,6) if IS_PC else (96,5)) # 退出[遗器详情]界面,返回[人物]-[遗器]界面 + time.sleep(0.5) log.info(_("配装装备完毕")) def save_loadout_for_team(self): @@ -201,27 +201,34 @@ def save_loadout_for_char(self): 保存当前[人物]界面本人物的遗器配装 """ character_name = self.ocr_character_name() # 识别当前人物名称 - self.calculated.relative_click((12,40) if IS_PC else (16,48)) # 点击遗器 - time.sleep(1) - self.calculated.relative_click((38,26) if IS_PC else (36,21)) # 点击头部遗器,进入[人物]-[遗器]界面 - time.sleep(2) - self.calculated.relative_click((82,12) if IS_PC else (78,12)) # 点击遗器[对比],将遗器详情的背景由星空变为纯黑 + character_data = self.loadout_data[character_name] + self.calculated.relative_click((12,40) if IS_PC else (16,48)) # 点击导航栏的遗器,进入[人物]-[遗器]界面 time.sleep(1) - self.save_loadout(character_name) - self.calculated.relative_click((97,6) if IS_PC else (96,5)) # 退出[遗器]界面,返回[人物]界面 - time.sleep(2) + relics_hash = self.save_loadout() + self.calculated.switch_cmd() + print(_("配装信息:\n {}\n{}").format(self.get_loadout_brief(relics_hash), self.get_loadout_detail(relics_hash, 2))) + loadout_name = input(_(">>>>命名配装名称: ")) # 需作为字典key值,确保唯一性 (但不同的人物可以有同一配装名称) + while loadout_name in character_data: + loadout_name = input(_(">>>>命名冲突,请重命名: ")) + character_data[loadout_name] = relics_hash + self.loadout_data = modify_json_file(LOADOUT_FILE_NAME, character_name, character_data) + log.info(_("配装录入成功")) - def save_loadout(self, character_name: Optional[str]=None, max_retries=3): + def save_loadout(self, max_retries=3) -> list[str]: """ 说明: - 保存当前[人物]-[遗器]-[遗器详情]界面内的遗器配装 + 保存当前[人物]-[遗器]界面内的遗器配装 + 返回: + :return relics_hash: 遗器配装哈希值列表 """ - character_name = character_name if character_name else self.ocr_character_name() - character_data = self.loadout_data[character_name] - equip_pos_list = [(4,13),(9,13),(13,13),(18,13),(23,13),(27,13)] if IS_PC else [(5,14), (11,14), (17,14), (23,14), (28,14), (34,14)] + equip_pos_list = [(4,13),(9,13),(13,13),(18,13),(23,13),(27,13)] if IS_PC else [(5,14),(11,14),(17,14),(23,14),(28,14),(34,14)] relics_hash = [] + self.calculated.relative_click((38,26) if IS_PC else (36,21)) # 点击头部遗器,进入[人物]-[遗器]-[遗器详情]界面 + time.sleep(2) + self.calculated.relative_click((82,12) if IS_PC else (78,12)) # 点击遗器[对比],将遗器详情的背景由星空变为纯黑 + time.sleep(1) for equip_indx, equip_pos in enumerate(equip_pos_list): # 遗器部位循环 - log.info(_(f"选择部位:{self.equip_set_name[equip_indx]}")) + log.info(_(f"选择部位:{EQUIP_SET_NAME[equip_indx]}")) self.calculated.relative_click(equip_pos) time.sleep(1) tmp_data = self.try_ocr_relic(equip_indx, max_retries) @@ -234,15 +241,10 @@ def save_loadout(self, character_name: Optional[str]=None, max_retries=3): log.info(_("录入遗器数据")) self.add_relic_data(tmp_data, tmp_hash) relics_hash.append(tmp_hash) + self.calculated.relative_click((97,6) if IS_PC else (96,5)) # 退出[遗器详情]界面,返回[人物]-[遗器]界面 + time.sleep(0.5) log.info(_("配装识别完毕")) - self.calculated.switch_cmd() - loadout_name = input(_(">>>>命名配装名称: ")) # 需作为字典key值,确保唯一性 (但不同的人物可以有同一配装名称) - while loadout_name in character_data: - loadout_name = input(_(">>>>命名冲突,请重命名: ")) - character_data[loadout_name] = relics_hash - self.loadout_data = modify_json_file(LOADOUT_FILE_NAME, character_name, character_data) - log.info(_("配装录入成功")) - self.calculated.switch_window() + return relics_hash class Relic_filter: """ From 0cea48584196d218f781cd3a6fe06cbc84a3ebe8 Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Wed, 1 Nov 2023 17:00:47 +0800 Subject: [PATCH 17/31] =?UTF-8?q?pref:=20=E5=87=8F=E5=B0=91=E4=B8=8D?= =?UTF-8?q?=E5=BF=85=E8=A6=81=E7=9A=84=E5=AD=97=E5=85=B8=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/relic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/relic.py b/utils/relic.py index d055467e..e28fda8e 100644 --- a/utils/relic.py +++ b/utils/relic.py @@ -211,7 +211,7 @@ def save_loadout_for_char(self): while loadout_name in character_data: loadout_name = input(_(">>>>命名冲突,请重命名: ")) character_data[loadout_name] = relics_hash - self.loadout_data = modify_json_file(LOADOUT_FILE_NAME, character_name, character_data) + rewrite_json_file(LOADOUT_FILE_NAME, self.loadout_data) log.info(_("配装录入成功")) def save_loadout(self, max_retries=3) -> list[str]: From 87dfd6b9d519fcd3300eb4eaad1f314f7d1bfb08 Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Wed, 1 Nov 2023 17:07:56 +0800 Subject: [PATCH 18/31] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=98=9F?= =?UTF-8?q?=E4=BC=8D=E9=81=97=E5=99=A8=E9=85=8D=E8=A3=85=E7=9A=84=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E4=B8=8E=E8=AF=BB=E5=8F=96=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/relic.py | 165 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 157 insertions(+), 8 deletions(-) diff --git a/utils/relic.py b/utils/relic.py index e28fda8e..e1326701 100644 --- a/utils/relic.py +++ b/utils/relic.py @@ -87,21 +87,37 @@ def relic_entrance(self): 遗器模块入口 """ title = _("遗器模块:") - options = [_("保存当前人物的配装"), _("读取当前人物的配装"), _("识别当前遗器的数据"), _("返回主菜单")] + tab = "\n" + " " * 5 + options = [ + Choice(_("保存当前人物的配装"), value = 0, + description = tab + _("请使游戏保持在[人物]界面")), + Choice(_("保存当前队伍的配装"), value = 1, + description = tab + _("请使游戏保持在[人物]界面")), + Choice(_("读取当前人物的配装记录"), value = 2, + description = tab + _("请使游戏保持在[人物]界面")), + Choice(_("读取队伍的配装记录"), value = 3, + description = tab + _("请使游戏保持在[人物]界面")), + Choice(_("识别当前遗器数据"), value = 4, + description = tab + _("请使游戏保持在[人物]-[遗器]-[遗器详情]界面") + tab + _("推荐手动点击[对比]增加识别率")), + _("<返回主菜单>")] option = None # 保存上一次的选择 while True: self.calculated.switch_cmd() - option = select(title, options, default=option).ask() - if option == _("保存当前人物的配装"): + option = select(title, options, default=option, show_description=True).ask() + if option == 0: self.calculated.switch_window() self.save_loadout_for_char() - elif option == _("读取当前人物的配装"): + elif option == 1: + self.save_loadout_for_team() + elif option == 2: self.equip_loadout_for_char() - elif option == _("识别当前遗器的数据"): + elif option == 3: + self.equip_loadout_for_team() + elif option == 4: self.calculated.switch_window() data = self.try_ocr_relic() self.print_relic(data) - elif option == _("返回主菜单"): + elif option == _("<返回主菜单>"): break def equip_loadout_for_team(self): @@ -109,7 +125,34 @@ def equip_loadout_for_team(self): 说明: 装备当前[人物]界面本队伍的遗器配装 """ - ... + char_pos_list = [(26,6),(31,6),(37,6),(42,6)] if IS_PC else [(5,16),(5,27),(5,38),(5,49)] + # 选择队伍 + option = select( + _("请选择对当前队伍进行遗器装备的编队:"), + choices = self.get_team_choice_options() + [(_("<返回上一级>"))], + show_description = True, # 需questionary具备对show_description的相关支持 + ).ask() + if option == _("<返回上一级>"): + return + team_members = option # 得到 (char_name: loadout_name) 的键值对 + # 检查人物列表是否移动至开头 + self.calculated.switch_window() + self.calculated.relative_swipe(char_pos_list[0], char_pos_list[-1]) # 滑动人物列表 + time.sleep(0.5) + self.calculated.relative_click((12,40) if IS_PC else (16,48)) # 点击导航栏的遗器,进入[人物]-[遗器]界面 + time.sleep(1) + # 依次点击人物,进行配装 (编队人物无序) + for char_index in range(len(team_members)): + char_pos = char_pos_list[char_index] + self.calculated.relative_click(char_pos) # 点击人物 + time.sleep(2) + character_name = self.ocr_character_name() # 识别当前人物名称 + if character_name not in team_members: + log.error(_(f"编队错误:角色'{character_name}'不应在当前队伍中")) + return + relic_hash = self.loadout_data[character_name][team_members[character_name]] + self.equip_loadout(relic_hash) + log.info(_("队伍配装完毕")) def equip_loadout_for_char(self, character_name :Optional[str]=None): """ @@ -193,7 +236,91 @@ def save_loadout_for_team(self): 说明: 保存当前[人物]界面本队伍的遗器配装 """ - ... + char_pos_list = [(26,6),(31,6),(37,6),(42,6)] if IS_PC else [(5,16),(5,27),(5,38),(5,49)] + char_name_list = [] + relics_hash_list = [] + loadout_name_list = [] + # [1]选择队伍的人数 (能否通过页面识别?) + char_num = int(select(_("请选择队伍人数:"), ['4','3','2','1']).ask()) + # [2]选择是否为互斥队伍组别 + group_name = "compatible" # 默认为非互斥队组别 + ... # 互斥队组别【待扩展】 + if group_name not in self.team_data: + self.team_data[group_name] = {} + group_data = self.team_data[group_name] + # [3]选择组建方式 + options = { + _("全识别"): 0, + _("参考已有的配装数据"): 1 + } + option_ = select(_("请选择组建方式:"), options).ask() + state = options[option_] + # [4]检查人物列表是否移动至开头 + self.calculated.switch_window() + self.calculated.relative_swipe(char_pos_list[0], char_pos_list[-1]) # 滑动人物列表 + time.sleep(0.5) + self.calculated.relative_click((12,40) if IS_PC else (16,48)) # 点击导航栏的遗器,进入[人物]-[遗器]界面 + time.sleep(1) + # [5]依次点击人物,识别配装 + char_index = 0 + is_retrying = False + character_name = None + loadout_dict = self.HashList2dict() + while char_index < char_num: + char_pos = char_pos_list[char_index] + # [5.1]识别人物名称 + if not is_retrying: + self.calculated.switch_window() + self.calculated.relative_click(char_pos) # 点击人物 + time.sleep(2) + character_name = self.ocr_character_name() # 识别当前人物名称 + # [5.2]选择识别当前,还是录入已有 + option = None + if state == 1: + self.calculated.switch_cmd() + option = select( + _("请选择配装:"), + choices = [_("<识别当前配装>")] + self.get_loadout_choice_options(character_name) + [_("<退出>")], + show_description = True, # 需questionary具备对show_description的相关支持 + ).ask() + if option == _("<退出>"): # 退出本次编队 + return + elif option != _("<识别当前配装>"): + loadout_name, relics_hash = option # 获取已录入的配装数据 + if state == 0 or option == _("<识别当前配装>"): + self.calculated.switch_window() + relics_hash = self.save_loadout() + loadout_name = None # 等待最终统一命名 + print(_("配装信息:\n {}\n{}").format(self.get_loadout_brief(relics_hash), self.get_loadout_detail(relics_hash, 2))) + # [5.3]检查是否存在冲突遗器 + loadout_check = loadout_dict.add(relics_hash, character_name).find_duplicate_hash() + if loadout_check: # 列表非空,表示存在遗器冲突 + for equip_index, char_names, element in loadout_check: + self.calculated.switch_cmd() + log.error(_("编队遗器冲突:{}间的'{}'遗器冲突,遗器哈希值:{}").format(char_names, EQUIP_SET_NAME[equip_index], element)) + is_retrying = True # 将重复本次循环 + continue + log.info(_("配装校验成功")) + char_name_list.append(character_name) + relics_hash_list.append(relics_hash) + loadout_name_list.append(loadout_name) + is_retrying = False + char_index += 1 + # [6]自定义名称 + self.calculated.switch_cmd() + team_name = input(_(">>>>命名编队名称 (将同时作为各人物新建配装的名称): ")) + while team_name in group_data or \ + any([team_name in self.loadout_data[character_name] for character_name, loadout_name in zip(char_name_list, loadout_name_list) if loadout_name is None]): + team_name = input(_(">>>>命名冲突,请重命名: ")) + # [7]录入数据 + for i, (char_name, relics_hash, loadout_name) in enumerate(zip(char_name_list, relics_hash_list, loadout_name_list)): + if loadout_name is None: + loadout_name_list[i] = team_name + self.loadout_data[char_name][team_name] = relics_hash + rewrite_json_file(LOADOUT_FILE_NAME, self.loadout_data) + group_data[team_name]["team_members"] = {key: value for key, value in zip(char_name_list, loadout_name_list)} + rewrite_json_file(TEAM_FILE_NAME, self.team_data) + log.info(_("编队录入成功")) def save_loadout_for_char(self): """ @@ -707,6 +834,28 @@ def print_relic(self, data: Dict[str, Any]): token.append('-'*50) print("\n".join(token)) + def get_team_choice_options(self) -> List[Choice]: + """ + 说明: + 获取所有队伍配装记录的选项表 + 返回: + :return choice_options: 队伍配装记录的选项列表,Choice构造参数如下: + :return title: 队伍名称, + :return value: 队员信息字典(key-人物名称,value-配装名称), + :return description: 队员配装的简要信息 + """ + group_data = self.team_data["compatible"] # 获取非互斥队组别信息 + ... # 获取互斥队伍组别信息【待扩展】 + prefix = "\n" + " " * 5 + choice_options = [Choice( + title = str_just(team_name, 12), + value = team_data["team_members"], + description = "".join( + prefix + str_just(char_name, 10) + " " + self.get_loadout_brief(self.loadout_data[char_name][loadout_name]) + for char_name, loadout_name in team_data["team_members"].items()) + ) for team_name, team_data in group_data.items()] + return choice_options + def get_loadout_choice_options(self, character_name:str) -> List[Choice]: """ 说明: From c22362b86303a90c64c6e4dc132534f514846561 Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Wed, 1 Nov 2023 22:03:48 +0800 Subject: [PATCH 19/31] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=88=A4?= =?UTF-8?q?=E6=96=AD=E9=85=8D=E8=A3=85=E8=AE=B0=E5=BD=95=E6=98=AF=E5=90=A6?= =?UTF-8?q?=E5=B7=B2=E5=AD=98=E5=9C=A8=E7=9A=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/relic.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/utils/relic.py b/utils/relic.py index e1326701..ac9f3853 100644 --- a/utils/relic.py +++ b/utils/relic.py @@ -290,8 +290,10 @@ def save_loadout_for_team(self): if state == 0 or option == _("<识别当前配装>"): self.calculated.switch_window() relics_hash = self.save_loadout() - loadout_name = None # 等待最终统一命名 - print(_("配装信息:\n {}\n{}").format(self.get_loadout_brief(relics_hash), self.get_loadout_detail(relics_hash, 2))) + print(_("配装信息:\n {}\n{}").format(self.get_loadout_brief(relics_hash), self.get_loadout_detail(relics_hash, 2))) + loadout_name = self.find_loadout_name(character_name, relics_hash) + if loadout_name: + log.info(_(f"配装记录已存在,配装名称:{loadout_name}")) # [5.3]检查是否存在冲突遗器 loadout_check = loadout_dict.add(relics_hash, character_name).find_duplicate_hash() if loadout_check: # 列表非空,表示存在遗器冲突 @@ -334,6 +336,10 @@ def save_loadout_for_char(self): relics_hash = self.save_loadout() self.calculated.switch_cmd() print(_("配装信息:\n {}\n{}").format(self.get_loadout_brief(relics_hash), self.get_loadout_detail(relics_hash, 2))) + loadout_name = self.find_loadout_name(character_name, relics_hash) + if loadout_name: + log.info(_(f"配装记录已存在,配装名称:{loadout_name}")) + return loadout_name = input(_(">>>>命名配装名称: ")) # 需作为字典key值,确保唯一性 (但不同的人物可以有同一配装名称) while loadout_name in character_data: loadout_name = input(_(">>>>命名冲突,请重命名: ")) @@ -531,6 +537,16 @@ def compare_relics(self, old_data: Dict[str, Any], new_data: Dict[str, Any]) -> key != _("速度") and old_data["subs_stats"][key] > new_data["subs_stats"][key]: return False # 考虑手动提高速度数据精度的情况 return True + + def find_loadout_name(self, char_name: str, relics_hash: List[str]) -> Optional[str]: + """ + 说明: + 通过配装数据在记录中寻找配装名称 + """ + for loadout_name, hash_list in self.loadout_data[char_name].items(): + if hash_list == relics_hash: + return loadout_name + return None def check_team_data(self) -> bool: """ From d8ac10d1b0e0785d2dc0031da9963687aca7d5a8 Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Wed, 1 Nov 2023 22:56:50 +0800 Subject: [PATCH 20/31] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=95=A3?= =?UTF-8?q?=E4=BB=B6'=E8=B4=9D=E6=B4=9B=E4=BC=AF=E6=A0=BC=E7=9A=84?= =?UTF-8?q?=E9=93=81=E5=8D=AB=E9=98=B2=E7=BA=BF'=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E4=B8=BA=E9=93=81=E5=8D=AB=E5=A5=97=EF=BC=8C?= =?UTF-8?q?=E5=B0=86=E9=A2=84=E9=80=89=E8=AF=8D=E6=8C=89=E5=86=85=E5=A4=96?= =?UTF-8?q?=E5=9C=88=E5=88=92=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/relic.py | 5 ++++- utils/relic_constants.py | 9 +++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/utils/relic.py b/utils/relic.py index ac9f3853..f1809372 100644 --- a/utils/relic.py +++ b/utils/relic.py @@ -320,7 +320,7 @@ def save_loadout_for_team(self): loadout_name_list[i] = team_name self.loadout_data[char_name][team_name] = relics_hash rewrite_json_file(LOADOUT_FILE_NAME, self.loadout_data) - group_data[team_name]["team_members"] = {key: value for key, value in zip(char_name_list, loadout_name_list)} + group_data[team_name] = {"team_members": {key: value for key, value in zip(char_name_list, loadout_name_list)}} rewrite_json_file(TEAM_FILE_NAME, self.team_data) log.info(_("编队录入成功")) @@ -729,9 +729,12 @@ def ocr_relic(self, equip_set_index: Optional[int]=None) -> Dict[str, Any]: equip_set_name = EQUIP_SET_NAME[equip_set_index] # [2]套装识别 name_list = RELIC_SET_NAME[:, 0].tolist() + name_list = name_list[:RELIC_INNER_SET_INDEX] if equip_set_index < 4 else name_list[RELIC_INNER_SET_INDEX:] # 取外圈/内圈的切片 relic_set_index = self.calculated.ocr_pos_for_single_line(name_list, points=(77,15,92,19) if IS_PC else (71,17,88,21), img_pk=img_pc) if relic_set_index < 0: raise RelicOCRException(_("遗器部位OCR错误")) + if equip_set_index in [4, 5]: + relic_set_index += RELIC_INNER_SET_INDEX # 还原内圈遗器的真实索引 relic_set_name = RELIC_SET_NAME[relic_set_index, -1] # [3]稀有度识别 hue, __, __ = self.calculated.get_relative_pix_hsv((43,55) if IS_PC else (41,55)) # 识别指定位点色相 diff --git a/utils/relic_constants.py b/utils/relic_constants.py index 755ccfd1..6dab2993 100644 --- a/utils/relic_constants.py +++ b/utils/relic_constants.py @@ -13,6 +13,7 @@ # 注:因为数据有时要行取有时要列取,故采用数组存储 RELIC_SET_NAME = np.array([ +# 外圈 [_("过客"), _("过客"), _("治疗"), _("云无留迹的过客")], [_("枪手"), _("枪手"), _("快枪手"), _("野穗伴行的快枪手")], [_("圣骑"), _("圣骑"), _("圣骑"), _("净庭教宗的圣骑士")], @@ -27,10 +28,11 @@ [_("废"), _("废"), _("虚数"), _("盗匪荒漠的废土客")], [_("者"), _("长存"), _("莳者"), _("宝命长存的莳者")], [_("信使"), _("信使"), _("信使"), _("骇域漫游的信使")], +# 内圈 [_("黑塔"), _("太空"), _("空间站"), _("太空封印站")], [_("仙"), _("仙"), _("仙舟"), _("不老者的仙舟")], [_("公司"), _("公司"), _("命中"), _("泛银河商业公司")], - [_("贝洛"), _("贝洛"), _("防御"), _("筑城者的贝洛伯格")], + [_("贝洛"), _("贝洛"), _("防御"), _("筑城者的贝洛伯格")], # 注:有散件名为'贝洛伯格的铁卫防线' [_("螺丝"), _("差分"), _("差分"), _("星体差分机")], [_("萨尔"), _("停转"), _("停转"), _("停转的萨尔索图")], [_("利亚"), _("盗贼"), _("击破"), _("盗贼公国塔利亚")], @@ -38,7 +40,10 @@ [_("泰科"), _("繁星"), _("繁星"), _("繁星竞技场")], [_("伊须"), _("龙骨"), _("龙骨"), _("折断的龙骨")] ], dtype=np.str_) -"""遗器套装名称:0-套装散件名的共有词(ocr-必须),1-套装名的特异词(ocr-可选,为了增强鲁棒性),2-玩家惯用简称(print),3-套装全称(json),已按[1.4游戏]遗器筛选界面排序""" +"""遗器套装名称:0-套装散件名的共有词(ocr-必须),1-套装名的特异词(ocr-可选,为了增强鲁棒性),2-玩家惯用简称(print),3-套装全称(json),已按[1.4游戏]遗器筛选界面排序 (且前段为外圈,后段为内圈)""" + +RELIC_INNER_SET_INDEX = 14 +"""RELIC_SET_NAME参数的遗器内圈的起始点索引""" STATS_NAME = np.array([ [_("命值"), _("生"), _("生命值")], From 45b819971ec44027901e67d4b23f4c4b4e6c8833 Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Thu, 2 Nov 2023 13:54:12 +0800 Subject: [PATCH 21/31] =?UTF-8?q?fix:=20'=E9=80=80=E5=87=BA=E8=84=9A?= =?UTF-8?q?=E6=9C=AC'=E7=9A=84=E9=80=89=E9=A1=B9=E5=A4=84=E7=90=86?= =?UTF-8?q?=E6=9C=AA=E6=AD=A3=E5=B8=B8=E5=90=AF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Honkai_Star_Rail.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/Honkai_Star_Rail.py b/Honkai_Star_Rail.py index fcde46d7..f6546f05 100644 --- a/Honkai_Star_Rail.py +++ b/Honkai_Star_Rail.py @@ -357,14 +357,13 @@ def select(): sra.set_config(False) elif option == None: ... + elif option == _('退出脚本'): + if questionary.select(_("请问要退出脚本吗?"), [_("退出"), _("返回主菜单")]).ask() == _("返回主菜单"): + select() else: - if option: - is_loop = sra.main(option) - if is_loop: - select() - else: - if questionary.select(_("请问要退出脚本吗?"), [_("退出"), _("返回主菜单")]).ask() == _("返回主菜单"): - select() + is_loop = sra.main(option) + if is_loop: + select() serial_map = args.get("--map") if args.get("--map") != "default" else "1-1_1" # 地图编号 select() if not serial_map else sra.main(start=serial_map) sra.end() From 7e22a03a0ba06f297120505ff00528be678bd01e Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Thu, 2 Nov 2023 13:50:57 +0800 Subject: [PATCH 22/31] =?UTF-8?q?chore:=20=E4=BC=98=E5=8C=96=E9=81=97?= =?UTF-8?q?=E5=99=A8=E6=A8=A1=E5=9D=97=E7=9B=B8=E5=85=B3=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/relic.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/utils/relic.py b/utils/relic.py index f1809372..0ec32274 100644 --- a/utils/relic.py +++ b/utils/relic.py @@ -92,13 +92,13 @@ def relic_entrance(self): Choice(_("保存当前人物的配装"), value = 0, description = tab + _("请使游戏保持在[人物]界面")), Choice(_("保存当前队伍的配装"), value = 1, - description = tab + _("请使游戏保持在[人物]界面")), + description = tab + _("请使游戏保持在[人物]界面") + tab + _("并确保[人物列表]移动至开头")), Choice(_("读取当前人物的配装记录"), value = 2, description = tab + _("请使游戏保持在[人物]界面")), Choice(_("读取队伍的配装记录"), value = 3, - description = tab + _("请使游戏保持在[人物]界面")), + description = tab + _("请使游戏保持在[人物]界面") + tab + _("并确保[人物列表]移动至开头")), Choice(_("识别当前遗器数据"), value = 4, - description = tab + _("请使游戏保持在[人物]-[遗器]-[遗器详情]界面") + tab + _("推荐手动点击[对比]增加识别率")), + description = tab + _("请使游戏保持在[人物]-[遗器]-[遗器详情]界面") + tab + _("推荐手动点击[对比]提高识别度")), _("<返回主菜单>")] option = None # 保存上一次的选择 while True: @@ -125,7 +125,7 @@ def equip_loadout_for_team(self): 说明: 装备当前[人物]界面本队伍的遗器配装 """ - char_pos_list = [(26,6),(31,6),(37,6),(42,6)] if IS_PC else [(5,16),(5,27),(5,38),(5,49)] + char_pos_list = [(26,6),(31,6),(37,6),(42,6),...,(75,6)] if IS_PC else [(5,16),(5,27),(5,38),(5,49),...,(5,81)] # 选择队伍 option = select( _("请选择对当前队伍进行遗器装备的编队:"), @@ -138,7 +138,7 @@ def equip_loadout_for_team(self): # 检查人物列表是否移动至开头 self.calculated.switch_window() self.calculated.relative_swipe(char_pos_list[0], char_pos_list[-1]) # 滑动人物列表 - time.sleep(0.5) + time.sleep(1) self.calculated.relative_click((12,40) if IS_PC else (16,48)) # 点击导航栏的遗器,进入[人物]-[遗器]界面 time.sleep(1) # 依次点击人物,进行配装 (编队人物无序) @@ -236,7 +236,7 @@ def save_loadout_for_team(self): 说明: 保存当前[人物]界面本队伍的遗器配装 """ - char_pos_list = [(26,6),(31,6),(37,6),(42,6)] if IS_PC else [(5,16),(5,27),(5,38),(5,49)] + char_pos_list = [(26,6),(31,6),(37,6),(42,6),...,(75,6)] if IS_PC else [(5,16),(5,27),(5,38),(5,49),...,(5,81)] char_name_list = [] relics_hash_list = [] loadout_name_list = [] @@ -258,7 +258,7 @@ def save_loadout_for_team(self): # [4]检查人物列表是否移动至开头 self.calculated.switch_window() self.calculated.relative_swipe(char_pos_list[0], char_pos_list[-1]) # 滑动人物列表 - time.sleep(0.5) + time.sleep(1) self.calculated.relative_click((12,40) if IS_PC else (16,48)) # 点击导航栏的遗器,进入[人物]-[遗器]界面 time.sleep(1) # [5]依次点击人物,识别配装 From 883f810365f1755fd2aac37af14eefd55acc9352 Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Thu, 2 Nov 2023 14:22:49 +0800 Subject: [PATCH 23/31] =?UTF-8?q?chore:=20=E6=9B=B4=E6=AD=A3=E5=AF=B9?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E5=90=84=E7=95=8C=E9=9D=A2=E7=9A=84=E5=90=8D?= =?UTF-8?q?=E8=AF=8D=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/relic.py | 53 +++++++++++++++++++++++++------------------------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/utils/relic.py b/utils/relic.py index 0ec32274..ca0c9089 100644 --- a/utils/relic.py +++ b/utils/relic.py @@ -90,16 +90,17 @@ def relic_entrance(self): tab = "\n" + " " * 5 options = [ Choice(_("保存当前人物的配装"), value = 0, - description = tab + _("请使游戏保持在[人物]界面")), + description = tab + _("请使游戏保持在[角色]界面")), Choice(_("保存当前队伍的配装"), value = 1, - description = tab + _("请使游戏保持在[人物]界面") + tab + _("并确保[人物列表]移动至开头")), + description = tab + _("请使游戏保持在[角色]界面") + tab + _("并确保[角色头像列表]移动至开头")), Choice(_("读取当前人物的配装记录"), value = 2, - description = tab + _("请使游戏保持在[人物]界面")), + description = tab + _("请使游戏保持在[角色]界面")), Choice(_("读取队伍的配装记录"), value = 3, - description = tab + _("请使游戏保持在[人物]界面") + tab + _("并确保[人物列表]移动至开头")), + description = tab + _("请使游戏保持在[角色]界面") + tab + _("并确保[角色头像列表]移动至开头")), Choice(_("识别当前遗器数据"), value = 4, - description = tab + _("请使游戏保持在[人物]-[遗器]-[遗器详情]界面") + tab + _("推荐手动点击[对比]提高识别度")), - _("<返回主菜单>")] + description = tab + _("请使游戏保持在[角色]-[遗器]-[遗器替换]界面") + tab + _("推荐手动点击[对比]提高识别度")), + _("<返回主菜单>") + ] # 注:[角色]界面的前继可为[队伍]-[角色选择]-[详情]界面 option = None # 保存上一次的选择 while True: self.calculated.switch_cmd() @@ -123,7 +124,7 @@ def relic_entrance(self): def equip_loadout_for_team(self): """ 说明: - 装备当前[人物]界面本队伍的遗器配装 + 装备当前[角色]界面本队伍的遗器配装 """ char_pos_list = [(26,6),(31,6),(37,6),(42,6),...,(75,6)] if IS_PC else [(5,16),(5,27),(5,38),(5,49),...,(5,81)] # 选择队伍 @@ -139,7 +140,7 @@ def equip_loadout_for_team(self): self.calculated.switch_window() self.calculated.relative_swipe(char_pos_list[0], char_pos_list[-1]) # 滑动人物列表 time.sleep(1) - self.calculated.relative_click((12,40) if IS_PC else (16,48)) # 点击导航栏的遗器,进入[人物]-[遗器]界面 + self.calculated.relative_click((12,40) if IS_PC else (16,48)) # 点击导航栏的遗器,进入[角色]-[遗器]界面 time.sleep(1) # 依次点击人物,进行配装 (编队人物无序) for char_index in range(len(team_members)): @@ -157,7 +158,7 @@ def equip_loadout_for_team(self): def equip_loadout_for_char(self, character_name :Optional[str]=None): """ 说明: - 装备当前[人物]界面本人物的遗器配装 + 装备当前[角色]界面本人物的遗器配装 """ # 识别当前人物名称 character_name = self.ocr_character_name() if character_name is None else character_name @@ -176,14 +177,14 @@ def equip_loadout_for_char(self, character_name :Optional[str]=None): loadout_name, relic_hash = option self.calculated.switch_window() # 进行配装 - self.calculated.relative_click((12,40) if IS_PC else (16,48)) # 点击遗器,进入[人物]-[遗器]界面 + self.calculated.relative_click((12,40) if IS_PC else (16,48)) # 点击遗器,进入[角色]-[遗器]界面 time.sleep(0.5) self.equip_loadout(relic_hash) def equip_loadout(self, relics_hash:List[str]): """ 说明: - 装备当前[人物]-[遗器]页面内的指定遗器配装。 + 装备当前[角色]-[遗器]页面内的指定遗器配装。 遗器将强制替换 (1-替换己方已装备的遗器,2-替换对方已装备的遗器) 参数: :param relics_hash: 遗器配装哈希值列表 @@ -191,7 +192,7 @@ def equip_loadout(self, relics_hash:List[str]): equip_pos_list = [(4,13),(9,13),(13,13),(18,13),(23,13),(27,13)] if IS_PC else [(5,14),(11,14),(17,14),(23,14),(28,14),(34,14)] relic_filter = self.Relic_filter(self.calculated) # 遗器筛选器初始化 relic_set_name_dict = Array2dict(RELIC_SET_NAME) - self.calculated.relative_click((38,26) if IS_PC else (36,21)) # 点击头部遗器,进入[人物]-[遗器]-[遗器详情]界面 + self.calculated.relative_click((38,26) if IS_PC else (36,21)) # 点击头部遗器,进入[角色]-[遗器]-[遗器替换]界面 time.sleep(2) self.calculated.relative_click((82,12) if IS_PC else (78,12)) # 点击遗器[对比],将遗器详情的背景由星空变为纯黑 time.sleep(1) @@ -227,14 +228,14 @@ def equip_loadout(self, relics_hash:List[str]): time.sleep(0.5) elif button == 2: log.info(_("已装备")) - self.calculated.relative_click((97,6) if IS_PC else (96,5)) # 退出[遗器详情]界面,返回[人物]-[遗器]界面 + self.calculated.relative_click((97,6) if IS_PC else (96,5)) # 退出[遗器替换]界面,返回[角色]-[遗器]界面 time.sleep(0.5) log.info(_("配装装备完毕")) def save_loadout_for_team(self): """ 说明: - 保存当前[人物]界面本队伍的遗器配装 + 保存当前[角色]界面本队伍的遗器配装 """ char_pos_list = [(26,6),(31,6),(37,6),(42,6),...,(75,6)] if IS_PC else [(5,16),(5,27),(5,38),(5,49),...,(5,81)] char_name_list = [] @@ -259,7 +260,7 @@ def save_loadout_for_team(self): self.calculated.switch_window() self.calculated.relative_swipe(char_pos_list[0], char_pos_list[-1]) # 滑动人物列表 time.sleep(1) - self.calculated.relative_click((12,40) if IS_PC else (16,48)) # 点击导航栏的遗器,进入[人物]-[遗器]界面 + self.calculated.relative_click((12,40) if IS_PC else (16,48)) # 点击导航栏的遗器,进入[角色]-[遗器]界面 time.sleep(1) # [5]依次点击人物,识别配装 char_index = 0 @@ -327,11 +328,11 @@ def save_loadout_for_team(self): def save_loadout_for_char(self): """ 说明: - 保存当前[人物]界面本人物的遗器配装 + 保存当前[角色]界面本人物的遗器配装 """ character_name = self.ocr_character_name() # 识别当前人物名称 character_data = self.loadout_data[character_name] - self.calculated.relative_click((12,40) if IS_PC else (16,48)) # 点击导航栏的遗器,进入[人物]-[遗器]界面 + self.calculated.relative_click((12,40) if IS_PC else (16,48)) # 点击导航栏的遗器,进入[角色]-[遗器]界面 time.sleep(1) relics_hash = self.save_loadout() self.calculated.switch_cmd() @@ -350,13 +351,13 @@ def save_loadout_for_char(self): def save_loadout(self, max_retries=3) -> list[str]: """ 说明: - 保存当前[人物]-[遗器]界面内的遗器配装 + 保存当前[角色]-[遗器]界面内的遗器配装 返回: :return relics_hash: 遗器配装哈希值列表 """ equip_pos_list = [(4,13),(9,13),(13,13),(18,13),(23,13),(27,13)] if IS_PC else [(5,14),(11,14),(17,14),(23,14),(28,14),(34,14)] relics_hash = [] - self.calculated.relative_click((38,26) if IS_PC else (36,21)) # 点击头部遗器,进入[人物]-[遗器]-[遗器详情]界面 + self.calculated.relative_click((38,26) if IS_PC else (36,21)) # 点击头部遗器,进入[角色]-[遗器]-[遗器替换]界面 time.sleep(2) self.calculated.relative_click((82,12) if IS_PC else (78,12)) # 点击遗器[对比],将遗器详情的背景由星空变为纯黑 time.sleep(1) @@ -374,7 +375,7 @@ def save_loadout(self, max_retries=3) -> list[str]: log.info(_("录入遗器数据")) self.add_relic_data(tmp_data, tmp_hash) relics_hash.append(tmp_hash) - self.calculated.relative_click((97,6) if IS_PC else (96,5)) # 退出[遗器详情]界面,返回[人物]-[遗器]界面 + self.calculated.relative_click((97,6) if IS_PC else (96,5)) # 退出[遗器替换]界面,返回[角色]-[遗器]界面 time.sleep(0.5) log.info(_("配装识别完毕")) return relics_hash @@ -382,9 +383,9 @@ def save_loadout(self, max_retries=3) -> list[str]: class Relic_filter: """ 说明: - 遗器筛选器。封装了在[人物]-[遗器]-[遗器详情]-[遗器筛选]界面内的遗器筛选方法, + 遗器筛选器。封装了在[角色]-[遗器]-[遗器替换]-[遗器筛选]界面内的遗器筛选方法, 目前可以对遗器套装与稀有度进行筛选,并记录先前的筛选状态 - (注意在未退出[遗器详情]界面时切换遗器,会保留上一次的筛选状态) + (注意在未退出[遗器替换]界面时切换遗器,会保留上一次的筛选状态) """ rarity_pos_list = [(77,38),(89,38),(77,42),(89,42)] if IS_PC else [(71,45),(86,45),(71,52),(86,52)] """稀有度筛选项的点击位点 (分别为2,3,4,5星稀有度)""" @@ -400,7 +401,7 @@ def __init__(self, calculated: calculated): def do(self, relic_set_index: int, rairty: int): """ 说明: - 在当前[人物]-[遗器]-[遗器详情]内进行遗器筛选 + 在当前[角色]-[遗器]-[遗器替换]内进行遗器筛选 参数: :param relic_set_index: 遗器套装索引 :param rairty: 稀有度 @@ -436,7 +437,7 @@ def do(self, relic_set_index: int, rairty: int): def search_relic_set_for_filter(self, relic_set_index: int): """ 说明: - 在当前滑动[人物]-[遗器]-[遗器详情]-[遗器筛选]-[遗器套装筛选]界面内,搜索遗器套装名,并点击。 + 在当前滑动[角色]-[遗器]-[遗器替换]-[遗器筛选]-[遗器套装筛选]界面内,搜索遗器套装名,并点击。 综合OCR识别与方位计算 参数: :param equip_set_index: 遗器套装索引 @@ -460,7 +461,7 @@ def search_relic(self, equip_indx: int, key_hash: Optional[str]=None, key_data: ) -> Optional[tuple[int, int]]: """ 说明: - 在当前滑动[人物]-[遗器]-[遗器详情]界面内,搜索匹配的遗器。 + 在当前滑动[角色]-[遗器]-[遗器替换]界面内,搜索匹配的遗器。 key_hash非空: 激活精确匹配 (假设数据保存期间遗器未再次升级); key_data非空: 激活模糊匹配 (假设数据保存期间遗器再次升级,匹配成功后自动更新遗器数据); key_hash & key_data均空: 遍历当前页面内的遗器 @@ -711,7 +712,7 @@ def try_ocr_relic(self, equip_set_index:Optional[int]=None, max_retries=3) -> Di def ocr_relic(self, equip_set_index: Optional[int]=None) -> Dict[str, Any]: """ 说明: - OCR当前静态[人物]-[遗器]-[遗器详情]界面内的遗器数据,单次用时约0.5s。 + OCR当前静态[角色]-[遗器]-[遗器替换]界面内的遗器数据,单次用时约0.5s。 更改为ocr_for_single_line()后,相较ocr()已缩短一半用时,且提高了部分识别的准确性, 若更改为ocr_for_single_lines()后的性能变化【待测】(代码重构较大) 参数: From d9e25db5b475454158c0765e922b395047c8e42b Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Thu, 2 Nov 2023 18:33:03 +0800 Subject: [PATCH 24/31] =?UTF-8?q?fix:=20=E9=98=9F=E4=BC=8D=E9=81=97?= =?UTF-8?q?=E5=99=A8=E5=86=B2=E7=AA=81=E7=BB=93=E6=9E=9C=E6=9C=AA=E6=AD=A3?= =?UTF-8?q?=E7=A1=AE=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/relic.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/utils/relic.py b/utils/relic.py index ca0c9089..121a4e4a 100644 --- a/utils/relic.py +++ b/utils/relic.py @@ -270,7 +270,7 @@ def save_loadout_for_team(self): while char_index < char_num: char_pos = char_pos_list[char_index] # [5.1]识别人物名称 - if not is_retrying: + if not is_retrying: # 如果处于重试,则不再次识别人物名称 self.calculated.switch_window() self.calculated.relative_click(char_pos) # 点击人物 time.sleep(2) @@ -302,7 +302,9 @@ def save_loadout_for_team(self): self.calculated.switch_cmd() log.error(_("编队遗器冲突:{}间的'{}'遗器冲突,遗器哈希值:{}").format(char_names, EQUIP_SET_NAME[equip_index], element)) is_retrying = True # 将重复本次循环 - continue + if is_retrying: + log.error(_("请重新选择配装")) + continue log.info(_("配装校验成功")) char_name_list.append(character_name) relics_hash_list.append(relics_hash) From 590b72c04c1e7f3d3a1408cddc157f89175f425a Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Thu, 2 Nov 2023 18:38:02 +0800 Subject: [PATCH 25/31] =?UTF-8?q?chore:=20=E4=BF=AE=E6=94=B9=E9=81=97?= =?UTF-8?q?=E5=99=A8=E6=A8=A1=E5=9D=97=E9=83=A8=E5=88=86=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/relic.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/utils/relic.py b/utils/relic.py index 121a4e4a..ea4091be 100644 --- a/utils/relic.py +++ b/utils/relic.py @@ -69,8 +69,11 @@ def __init__(self, title=_("崩坏:星穹铁道")): self.relics_data: Dict[str, Dict[str, Any]] = read_json_file(RELIC_FILE_NAME, schema = RELIC_SCHEMA) self.loadout_data: Dict[str, Dict[str, List[str]]] = read_json_file(LOADOUT_FILE_NAME, schema = LOADOUT_SCHEMA) self.team_data: Dict[str, Dict[str, Any]] = read_json_file(TEAM_FILE_NAME, schema = TEAM_SCHEMA) - log.info(_("遗器数据载入完成")) + + log.info(_("遗器模块初始化完成")) log.info(_(f"共载入 {len(list(self.relics_data.keys()))} 件遗器数据")) + log.info(_(f"共载入 {sum(len(char_loadouts) for char_name, char_loadouts in self.loadout_data.items())} 套配装数据")) + log.info(_(f"共载入 {sum(len(group_data) for group_name, group_data in self.team_data.items())} 组队伍数据")) # 校验遗器哈希值 if not self.check_relic_data_hash(): @@ -89,11 +92,11 @@ def relic_entrance(self): title = _("遗器模块:") tab = "\n" + " " * 5 options = [ - Choice(_("保存当前人物的配装"), value = 0, + Choice(_("保存当前角色的配装"), value = 0, description = tab + _("请使游戏保持在[角色]界面")), Choice(_("保存当前队伍的配装"), value = 1, description = tab + _("请使游戏保持在[角色]界面") + tab + _("并确保[角色头像列表]移动至开头")), - Choice(_("读取当前人物的配装记录"), value = 2, + Choice(_("读取当前角色的配装记录"), value = 2, description = tab + _("请使游戏保持在[角色]界面")), Choice(_("读取队伍的配装记录"), value = 3, description = tab + _("请使游戏保持在[角色]界面") + tab + _("并确保[角色头像列表]移动至开头")), @@ -281,7 +284,7 @@ def save_loadout_for_team(self): self.calculated.switch_cmd() option = select( _("请选择配装:"), - choices = [_("<识别当前配装>")] + self.get_loadout_choice_options(character_name) + [_("<退出>")], + choices = self.get_loadout_choice_options(character_name) + [_("<识别当前配装>"), _("<退出>")], show_description = True, # 需questionary具备对show_description的相关支持 ).ask() if option == _("<退出>"): # 退出本次编队 @@ -300,7 +303,7 @@ def save_loadout_for_team(self): if loadout_check: # 列表非空,表示存在遗器冲突 for equip_index, char_names, element in loadout_check: self.calculated.switch_cmd() - log.error(_("编队遗器冲突:{}间的'{}'遗器冲突,遗器哈希值:{}").format(char_names, EQUIP_SET_NAME[equip_index], element)) + log.error(_("队伍遗器冲突:{}间的'{}'遗器冲突,遗器哈希值:{}").format(char_names, EQUIP_SET_NAME[equip_index], element)) is_retrying = True # 将重复本次循环 if is_retrying: log.error(_("请重新选择配装")) @@ -411,7 +414,7 @@ def do(self, relic_set_index: int, rairty: int): if self.pre_relic_set_index == relic_set_index and self.pre_rarity == rairty: # 筛选条件未改变 return # 若筛选条件之一发生改变,未改变的不会进行重复动作 - log.info(_(f"进行遗器筛选,筛选条件: set={relic_set_index}, rairty={rairty}")) + log.debug(_(f"进行遗器筛选,筛选条件: set={relic_set_index}, rairty={rairty}")) self.calculated.relative_click((3,92) if IS_PC else (4,92)) # 点击筛选图标进入[遗器筛选]界面 time.sleep(0.5) # 筛选遗器套装 @@ -562,9 +565,11 @@ def check_team_data(self) -> bool: loadout_dict = self.HashList2dict() [loadout_dict.add(self.loadout_data[char_name][loadout_name], char_name) for char_name, loadout_name in team_data["team_members"].items()] for equip_index, char_names, element in loadout_dict.find_duplicate_hash(): - log.error(_("编队遗器冲突:'{}'队伍的{}间的'{}'遗器冲突,遗器哈希值:{}").format(team_name, char_names, EQUIP_SET_NAME[equip_index], element)) + log.error(_("队伍遗器冲突:'{}'队伍的{}间的'{}'遗器冲突,遗器哈希值:{}").format(team_name, char_names, EQUIP_SET_NAME[equip_index], element)) ret = False if group_name != "compatible": ... # 互斥队伍组别【待扩展】 + if ret: + log.info(_("队伍配装校验成功")) return ret class HashList2dict: @@ -625,7 +630,7 @@ def check_relic_data_hash(self, updata=False) -> bool: self.updata_relic_data(old_hash, new_hash, equip_indx) cnt += 1 if not cnt: - log.info(_(f"遗器哈希值校验成功")) + log.info(_("遗器哈希值校验成功")) return True if updata: log.info(_(f"已更新 {cnt} 件遗器的哈希值")) From cc7a1541b61de4bcfde4d310adef9e8c5544a54a Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Thu, 2 Nov 2023 14:32:05 +0800 Subject: [PATCH 26/31] =?UTF-8?q?chore:=20=E5=8F=96=E6=B6=88Array2dict?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1=E5=88=9D=E5=A7=8B=E5=8C=96=E6=97=B6=E6=89=93?= =?UTF-8?q?=E5=8D=B0debug=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/calculated.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/calculated.py b/utils/calculated.py index 16c5ab52..399b5625 100644 --- a/utils/calculated.py +++ b/utils/calculated.py @@ -1009,7 +1009,7 @@ def __init__(self, arr: np.ndarray, key_index: int = -1, value_index: Optional[i self.data_dict = {row[key_index]: idx for idx, row in enumerate(arr)} else: self.data_dict = {row[key_index]: row[value_index] for row in arr} - log.debug(self.data_dict) + # log.debug(self.data_dict) def __getitem__(self, key: Any) -> Any: return self.data_dict[key] From 823cb1cbaf548ce596e52dcdfd143053277996b2 Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Thu, 2 Nov 2023 21:57:45 +0800 Subject: [PATCH 27/31] =?UTF-8?q?fix:=20'config.json'=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=9C=AA=E6=88=90=E5=8A=9F=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/config.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/utils/config.py b/utils/config.py index 105acfd7..c1a03cc2 100644 --- a/utils/config.py +++ b/utils/config.py @@ -92,9 +92,9 @@ def modify_json_file(filename:str, key:str, value:Any) -> dict: data: 修改后的json字典 """ # 先读,再写 - data, file_path = read_json_file(filename, path=True) + data = read_json_file(filename) data[key] = value - return rewrite_json_file(file_path, data) + return rewrite_json_file(filename, data) def rewrite_json_file(filename:str, data:dict) -> dict: @@ -108,6 +108,8 @@ def rewrite_json_file(filename:str, data:dict) -> dict: data: 修改后的json字典 """ file_path = normalize_file_path(filename) + if file_path is None: + file_path = filename # 原文件不存在,则新建 try: with open(file_path, "wb") as f: f.write(orjson.dumps(data, option=orjson.OPT_PASSTHROUGH_DATETIME | orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_INDENT_2)) From bf8c21de1392d903e9409426ff598486fa41a45e Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Thu, 2 Nov 2023 21:58:24 +0800 Subject: [PATCH 28/31] =?UTF-8?q?chore:=20=E5=88=A0=E9=99=A4=E6=9C=89?= =?UTF-8?q?=E5=85=B3=E9=81=97=E5=99=A8=E6=A8=A1=E5=9D=97=E7=9A=84=E9=9D=9E?= =?UTF-8?q?=E5=BF=85=E8=A6=81=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/config.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/utils/config.py b/utils/config.py index c1a03cc2..30ce5346 100644 --- a/utils/config.py +++ b/utils/config.py @@ -59,25 +59,10 @@ def read_json_file(filename: str, path=False, schema:dict=None) -> dict: else: return data else: - if filename in [RELIC_FILE_NAME, LOADOUT_FILE_NAME, TEAM_FILE_NAME]: - init_json_file(filename) - return read_json_file(filename, path) if path: return {}, filename else: return {} - - -def init_json_file(filename: str): - """ - 说明: - 初始化json文件为空字典 - 参数: - :param filename: 文件名称 - """ - with open(filename, "wb") as f: - log.info(_(f"{filename} 文件初始化")) - f.write(orjson.dumps({}, option = orjson.OPT_PASSTHROUGH_DATETIME | orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_INDENT_2)) def modify_json_file(filename:str, key:str, value:Any) -> dict: From 6c20b435335e9456a52083d91ff561ee850a5b19 Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Thu, 2 Nov 2023 22:38:06 +0800 Subject: [PATCH 29/31] =?UTF-8?q?chore:=20=E9=81=97=E5=99=A8=E5=86=85?= =?UTF-8?q?=E5=A4=96=E5=9C=88=E5=8F=82=E6=95=B0=E5=91=BD=E5=90=8D=E9=A2=A0?= =?UTF-8?q?=E5=80=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/relic.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/utils/relic.py b/utils/relic.py index ea4091be..09fd4c58 100644 --- a/utils/relic.py +++ b/utils/relic.py @@ -967,11 +967,11 @@ def get_loadout_brief(self, relics_hash: List[str]) -> str: outer_set_cnt = Counter(outer_set_list) inner_set_cnt = Counter(inner_set_list) # 生成信息 - inner = _("外:") + '+'.join([str(cnt) + name for name, cnt in outer_set_cnt.items()]) + " " - outer = _("内:") + '+'.join([str(cnt) + name for name, cnt in inner_set_cnt.items()]) + " " - # stats = " ".join([self.equip_set_abbr[idx]+":"+name for idx, name in enumerate(base_stats_list) if idx > 1]) + outer = _("外:") + '+'.join([str(cnt) + name for name, cnt in outer_set_cnt.items()]) + " " + inner = _("内:") + '+'.join([str(cnt) + name for name, cnt in inner_set_cnt.items()]) + " " + # stats = " ".join([EQUIP_SET_ADDR[idx]+":"+name for idx, name in enumerate(base_stats_list) if idx > 1]) stats = ".".join([name for idx, name in enumerate(base_stats_list) if idx > 1]) # 排除头部与手部 - msg = str_just(stats, 17) + " " + str_just(outer, 10) + " " + inner # 将长度最不定的外圈信息放至最后 + msg = str_just(stats, 17) + " " + str_just(inner, 10) + " " + outer # 将长度最不定的外圈信息放至最后 return msg def get_base_stats_detail(self, data: Tuple[str, float], rarity: int, level: int, stats_index: Optional[int]=None) -> Optional[float]: From 531b9c42e0e9c5bf05db4fb2ec661b9ae0ec9042 Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Fri, 3 Nov 2023 16:07:01 +0800 Subject: [PATCH 30/31] =?UTF-8?q?chore:=20=E8=AF=86=E5=88=AB=E9=98=9F?= =?UTF-8?q?=E4=BC=8D=E9=85=8D=E8=A3=85=E5=90=8E=E6=89=93=E5=8D=B0=E9=98=9F?= =?UTF-8?q?=E4=BC=8D=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/relic.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/utils/relic.py b/utils/relic.py index 09fd4c58..d5acb09f 100644 --- a/utils/relic.py +++ b/utils/relic.py @@ -314,6 +314,8 @@ def save_loadout_for_team(self): loadout_name_list.append(loadout_name) is_retrying = False char_index += 1 + print(_("队伍配装信息:{}").format("".join("\n " + str_just(char_name, 10) + " " + self.get_loadout_brief(relics_hash) + for char_name, relics_hash in zip(char_name_list, relics_hash_list)))) # [6]自定义名称 self.calculated.switch_cmd() team_name = input(_(">>>>命名编队名称 (将同时作为各人物新建配装的名称): ")) From ac013740b2d24f9466ad155175a778c8e32cb08f Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Fri, 3 Nov 2023 16:07:57 +0800 Subject: [PATCH 31/31] =?UTF-8?q?test:=20=E5=AE=8C=E6=88=90=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E6=B5=8B=E8=AF=95=EF=BC=8C=E5=B9=B6=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/relic.py | 61 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/utils/relic.py b/utils/relic.py index d5acb09f..82dd8c5d 100644 --- a/utils/relic.py +++ b/utils/relic.py @@ -23,28 +23,53 @@ class Relic: <<<遗器模块>>> 已完成功能: 1.识别遗器数据 (单次用时约0.5s) - a.[新增]支持所有稀有度遗器 (识别指定点位色相[黄,紫,蓝,绿]) - 2.保存人物配装 - 3.读取人物配装并装备 (遗器将强制替换,支持精确匹配与模糊匹配) - 4.[新增]兼容四星遗器: - a.兼容校验函数 (增加四星遗器副词条档位数据) - 5.[新增]模糊匹配成功后自动更新相关数据库 (同时会在新旧遗器间建立后继关系) - 6.[新增]在配装选择界面,打印配装的简要信息 + a.录入的遗器数据保存在'relics_set.json'文件 + b.可识别遗器的[部位、套装、稀有度、等级、主词条、副词条]属性 + c.支持所有稀有度遗器 (识别指定点位色相[黄、紫、蓝、绿]) + 2.遗器数据匹配 + a.精确匹配:通过计算与匹配遗器哈希值 + b.模糊匹配:判断新旧遗器是否存在升级关系,若匹配成功,则新遗器将自动替换配装中的旧遗器, + 并在遗器数据中建立后继关系,此功能可通过设置开关 + 3.遗器数据增强 + a.支持计算[四星、五星]遗器的副词条的[强化次数、档位总积分、修正数值(提高原数值的小数精度)] + 1).对于'速度'属性只能做保守估计,其他属性可做准确计算 + 2).【新增】可借助其他工具获得'速度'属性的精确值,并手动修改json文件中'速度'属性的小数位, + 修改后的数据可永久保留,将不影响遗器哈希值计算与模糊匹配,并用于后续的数值计算 + b.【新增】支持计算[四星、五星]遗器的主词条的[修正数值] + c.【新增】遗器数据打印时的小数精度可通过设置选择,范围为[0,1,2,3] + d.基于遗器数据增强的遗器数据校验功能 (可检测出大部分的遗器识别错误),可通过设置开关 + e.遗器数据增强可通过设置开关 + 4.保存角色配装 + a.录入的配装数据保存在'relics_loadout.json'文件 + b.【新增】可检查配装是否已经存在,存在的配装不重复录入 + 5.读取角色配装并装备 + a.基于遗器匹配,遗器将强制'替换',包含[替换己方已装备的遗器、替换对方已装备的遗器] + b.自动对遗器的[套装、稀有度]属性进行筛选,加快遗器搜索 + c.【新增】配装选择时,将会打印配装信息,包含[内外圈套装、遗器主词条名称、属性数值统计] + 6.【新增】保存队伍配装 + a.录入的队伍配装数据保存在'relics_team.json'文件 + b.录入方式包含[全识别、参考已有的配装数据] + c.可检查队伍是否存在冲突遗器 + 7.【新增】读取队伍配装并装备 + a.队伍选择时,将会打印队伍信息,包含[角色构成、各角色内外圈套装、各角色遗器主词条名称] + b.对当前队伍的角色顺序不做要求 + b.只支持对已有队伍进行配装,不支持选择相应角色构建队伍 + 待解决问题: - 1.[已解决]OCR准确率低: + 1.【已解决】OCR准确率低: 对于中文识别更换为项目早期的OCR模型;对于数字识别更换为仅包含英文数字的轻量模型 + 待开发功能: - 1.保存队伍配装 - 2.读取队伍配装并装备 - 3.遗器管理 - a.在模块入口成功识别当前遗器后,可选择进行数据录入、查询可能的遗器历史数据 (基于模糊匹配) - b.美化遗器打印 - 4.配装管理 - a.可选择对配装重命名 + 1.配装管理 [删、改] (需考虑队伍配装) + 2.对忘却之庭双队配装的保存做额外处理,并检查队伍间的遗器冲突 ... - 相关说明: - 1.[新增]本模块的所有识别位点均采用百分比相对坐标,以兼容不同平台支持不同分辨率 - 2.[新增]本模块首先会基于安卓模拟器进行测试,再基于PC端测试 + + 开发者说明: + 1.本模块的所有识别位点均采用百分比相对坐标,以兼容不同平台支持不同分辨率 + 2.本模块首先会基于安卓模拟器进行测试,再基于PC端测试 + 3.【新增】本模块的主体功能已全部完成,现转入日常维护与不定时支线功能开发 + 4.【新增】本模块暂不支持简体中文之外的语言 + 4.【新增】本模块暂未有开发GUI的计划 """ def __init__(self, title=_("崩坏:星穹铁道")):