Add gcsim commands

Signed-off-by: Lei Shi <me@leishi.io>
Co-authored-by: 洛水居室 <luoshuijs@outlook.com>
Co-authored-by: CHxCOOH <chxcooh@googlemail.com>
Co-authored-by: xtaodada <xtao@xtaolink.cn>
Co-authored-by: Nahida <53059854+NahidaBuer@users.noreply.github.com>
Co-authored-by: omg-xtao <100690902+omg-xtao@users.noreply.github.com>
This commit is contained in:
Lei Shi 2023-12-03 07:33:29 +01:00 committed by GitHub
parent d605efc892
commit 031198b08d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 2081 additions and 9 deletions

View File

@ -13,6 +13,8 @@ __all__ = [
"roleToName", "roleToName",
"weaponToName", "weaponToName",
"weaponToId", "weaponToId",
"elementToName",
"elementsToColor",
"not_real_roles", "not_real_roles",
"roleToTag", "roleToTag",
] ]
@ -744,6 +746,33 @@ weapons = {
"水仙十字之剑": ["水仙", "水仙十字剑"], "水仙十字之剑": ["水仙", "水仙十字剑"],
"静水流涌之辉": ["静水", "净水流涌之辉", "水神专武", "芙芙专武"], "静水流涌之辉": ["静水", "净水流涌之辉", "水神专武", "芙芙专武"],
} }
elements = {
"pyro": [""],
"hydro": [""],
"anemo": [""],
"cryo": [""],
"electro": [""],
"geo": [""],
"dendro": [""],
"physical": ["物理"],
}
elementsToColor = {
"anemo": "#65B89A",
"geo": "#F6A824",
"electro": "#9F79B5",
"dendro": "#97C12B",
"hydro": "#3FB6ED",
"pyro": "#E76429",
"cryo": "#8FCDDC",
"physical": "#15161B",
}
@functools.lru_cache()
def elementToName(elem: str) -> str | None:
"""将元素昵称转为正式名"""
elem = str.casefold(elem) # 忽略大小写
return elements[elem][0] if elem in elements else None
# noinspection PyPep8Naming # noinspection PyPep8Naming

View File

@ -15,6 +15,7 @@ class Remote:
MATERIAL = f"{BASE_URL}roles_material.json" MATERIAL = f"{BASE_URL}roles_material.json"
RULE = f"{RESOURCE_FightPropRule_URL}FightPropRule_genshin.json" RULE = f"{RESOURCE_FightPropRule_URL}FightPropRule_genshin.json"
DAMAGE = f"{RESOURCE_FightPropRule_URL}GenshinDamageRule.json" DAMAGE = f"{RESOURCE_FightPropRule_URL}GenshinDamageRule.json"
GCSIM = f"{RESOURCE_FightPropRule_URL}gcsim.json"
@staticmethod @staticmethod
async def get_remote_calendar() -> Dict[str, Dict]: async def get_remote_calendar() -> Dict[str, Dict]:
@ -80,3 +81,16 @@ class Remote:
except Exception as exc: # skipcq: PYL-W0703 except Exception as exc: # skipcq: PYL-W0703
logger.error("获取云端伤害计算规则失败: %s", exc_info=exc) logger.error("获取云端伤害计算规则失败: %s", exc_info=exc)
return {} return {}
@staticmethod
async def get_gcsim_scripts() -> Dict[str, str]:
"""获取云端 gcsim 脚本"""
try:
async with AsyncClient() as client:
req = await client.get(Remote.GCSIM)
if req.status_code == 200:
return req.json()
return {}
except Exception as exc: # skipcq: PYL-W0703
logger.error("获取云端 gcsim 脚本失败: %s", exc_info=exc)
return {}

28
modules/gcsim/cache.py Normal file
View File

@ -0,0 +1,28 @@
from typing import Optional
from core.dependence.redisdb import RedisDB
__all__ = [
"GCSimCache",
]
class GCSimCache:
qname: str = "gcsim:"
def __init__(self, redis: RedisDB, ttl: int = 24 * 60 * 60):
self.client = redis.client
self.ttl = ttl
def get_key(self, player_id: str, script_hash: int) -> str:
return f"{self.qname}:{player_id}:{script_hash}"
async def set_cache(self, player_id: str, script_hash: int, file_id: str) -> None:
key = self.get_key(player_id, script_hash)
await self.client.set(key, file_id, ex=self.ttl)
async def get_cache(self, player_id: str, script_hash: int) -> Optional[str]:
key = self.get_key(player_id, script_hash)
data = await self.client.get(key)
if data:
return data.decode()

66
modules/gcsim/file.py Normal file
View File

@ -0,0 +1,66 @@
import json
import asyncio
import os
from pathlib import Path
from typing import Union
import aiofiles
from utils.const import DATA_DIR
PLAYER_SCRIPTS_PATH = DATA_DIR / "gcsim"
PLAYER_SCRIPTS_PATH.mkdir(parents=True, exist_ok=True)
class PlayerGCSimScripts:
_lock = asyncio.Lock()
def __init__(self, player_scripts_path: Path = PLAYER_SCRIPTS_PATH):
self.player_scripts_path = player_scripts_path
def get_player_path(self, uid: Union[str, int]):
player_path = self.player_scripts_path.joinpath(str(uid))
player_path.mkdir(parents=True, exist_ok=True)
return player_path
def get_script_path(self, uid: Union[str, int], script_key: str):
scripts_path = self.get_player_path(str(uid)).joinpath("scripts")
scripts_path.mkdir(parents=True, exist_ok=True)
return scripts_path.joinpath(f"{script_key}.txt")
def get_result_path(self, uid: Union[str, int], script_key: str):
scripts_path = self.get_player_path(uid).joinpath("results")
scripts_path.mkdir(parents=True, exist_ok=True)
return scripts_path.joinpath(f"{script_key}.json")
def get_fits_path(self, uid: Union[str, int]):
return self.get_player_path(uid).joinpath("fits.json")
def get_fits(self, uid: Union[str, int]) -> list[dict]:
if self.get_fits_path(uid).exists():
return json.loads(self.get_fits_path(uid).read_text(encoding="utf-8"))
return []
def remove_fits(self, uid: Union[str, int]):
self.get_fits_path(uid).unlink(missing_ok=True)
def clear_fits(self):
if self.player_scripts_path.exists():
for root, _, files in os.walk(self.player_scripts_path):
for file in files:
if file == "fits.json":
os.remove(os.path.join(root, file))
async def write_script(
self,
uid: Union[str, int],
script_key: str,
script: str,
):
async with self._lock, aiofiles.open(self.get_script_path(uid, script_key), "w", encoding="utf-8") as f:
await f.write(script)
async def write_fits(self, uid: Union[str, int], fits: list[dict]):
async with self._lock, aiofiles.open(self.get_fits_path(uid), "w", encoding="utf-8") as f:
await f.write(json.dumps(fits, ensure_ascii=False, indent=4))

View File

@ -60,6 +60,7 @@ class SetCommandPlugin(Plugin):
BotCommand("stats", "玩家统计查询"), BotCommand("stats", "玩家统计查询"),
BotCommand("player_card", "查询角色卡片"), BotCommand("player_card", "查询角色卡片"),
BotCommand("avatar_board", "角色排名"), BotCommand("avatar_board", "角色排名"),
BotCommand("gcsim", "组队伤害计算"),
# Cookie 查询类 # Cookie 查询类
BotCommand("dailynote", "查询实时便笺"), BotCommand("dailynote", "查询实时便笺"),
BotCommand("ledger", "查询当月旅行札记"), BotCommand("ledger", "查询当月旅行札记"),
@ -80,6 +81,7 @@ class SetCommandPlugin(Plugin):
BotCommand("sign_all", "全部账号重新签到"), BotCommand("sign_all", "全部账号重新签到"),
BotCommand("send_log", "发送日志"), BotCommand("send_log", "发送日志"),
BotCommand("update", "更新"), BotCommand("update", "更新"),
BotCommand("set_command", "重设命令"),
] ]
await context.bot.set_my_commands(commands=group_command) # 留空default 为 botCommandScopeDefault, 所有聊天可见 await context.bot.set_my_commands(commands=group_command) # 留空default 为 botCommandScopeDefault, 所有聊天可见
await context.bot.set_my_commands(commands=user_command + group_command, scope=BotCommandScopeAllPrivateChats()) await context.bot.set_my_commands(commands=user_command + group_command, scope=BotCommandScopeAllPrivateChats())

View File

@ -0,0 +1,298 @@
import copy
from typing import Optional, TYPE_CHECKING, List, Union, Dict
from enkanetwork import EnkaNetworkResponse
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, Message
from telegram.ext import filters
from telegram.helpers import create_deep_linked_url
from core.config import config
from core.dependence.assets import AssetsService
from core.dependence.redisdb import RedisDB
from core.plugin import Plugin, handler
from core.services.players import PlayersService
from gram_core.services.template.services import TemplateService
from modules.gcsim.file import PlayerGCSimScripts
from modules.playercards.file import PlayerCardsFile
from plugins.genshin.gcsim.renderer import GCSimResultRenderer
from plugins.genshin.gcsim.runner import GCSimRunner, GCSimFit
from plugins.genshin.model.base import CharacterInfo
from plugins.genshin.model.converters.enka import EnkaConverter
from utils.log import logger
if TYPE_CHECKING:
from telegram.ext import ContextTypes
__all__ = ("GCSimPlugin",)
async def _no_account_return(message: Message, context: "ContextTypes.DEFAULT_TYPE"):
buttons = [
[
InlineKeyboardButton(
"点我绑定账号",
url=create_deep_linked_url(context.bot.username, "set_uid"),
)
]
]
await message.reply_text("未查询到您所绑定的账号信息,请先绑定账号", reply_markup=InlineKeyboardMarkup(buttons))
async def _no_character_return(user_id: int, uid: int, message: Message):
photo = open("resources/img/kitsune.png", "rb")
buttons = [
[
InlineKeyboardButton(
"更新面板",
callback_data=f"update_player_card|{user_id}|{uid}",
)
]
]
await message.reply_photo(
photo=photo,
caption="角色列表未找到,请尝试点击下方按钮从 Enka.Network 更新角色列表",
reply_markup=InlineKeyboardMarkup(buttons),
)
class GCSimPlugin(Plugin):
def __init__(
self,
assets_service: AssetsService,
player_service: PlayersService,
template_service: TemplateService,
redis: RedisDB = None,
):
self.player_service = player_service
self.player_cards_file = PlayerCardsFile()
self.player_gcsim_scripts = PlayerGCSimScripts()
self.gcsim_runner = GCSimRunner(redis)
self.gcsim_renderer = GCSimResultRenderer(assets_service, template_service)
self.scripts_per_page = 8
async def initialize(self):
await self.gcsim_runner.initialize()
def _gen_buttons(
self, user_id: int, uid: int, fits: List[GCSimFit], page: int = 1
) -> List[List[InlineKeyboardButton]]:
buttons = []
for fit in fits[(page - 1) * self.scripts_per_page : page * self.scripts_per_page]:
button = InlineKeyboardButton(
f"{fit.script_key} ({','.join(map(str, fit.characters))})",
callback_data=f"enqueue_gcsim|{user_id}|{uid}|{fit.script_key}",
)
if not buttons or len(buttons[-1]) >= 1:
buttons.append([])
buttons[-1].append(button)
buttons.append(
[
InlineKeyboardButton("上一页", callback_data=f"gcsim_page|{user_id}|{uid}|{page - 1}")
if page > 1
else InlineKeyboardButton("更新配队", callback_data=f"gcsim_refresh|{user_id}|{uid}"),
InlineKeyboardButton(
f"{page}/{int(len(fits) / self.scripts_per_page) + 1}",
callback_data=f"gcsim_unclickable|{user_id}|{uid}|unclickable",
),
InlineKeyboardButton("下一页", callback_data=f"gcsim_page|{user_id}|{uid}|{page + 1}")
if page < int(len(fits) / self.scripts_per_page) + 1
else InlineKeyboardButton(
"更新配队",
callback_data=f"gcsim_refresh|{user_id}|{uid}",
),
]
)
return buttons
async def _get_uid(self, user_id: int, args: List[str], reply: Optional["Message"]) -> Optional[int]:
"""通过消息获取 uid优先级args > reply > self"""
uid, user_id_ = None, user_id
if args:
for i in args:
if i is not None and i.isdigit() and len(i) == 9:
uid = int(i)
if reply:
try:
user_id_ = reply.from_user.id
except AttributeError:
pass
if not uid:
player_info = await self.player_service.get_player(user_id_)
if player_info is not None:
uid = player_info.player_id
if (not uid) and (user_id_ != user_id):
player_info = await self.player_service.get_player(user_id)
if player_info is not None:
uid = player_info.player_id
return uid
@staticmethod
def _fix_skill_level(data: Dict) -> Dict:
for i in data["avatarInfoList"]:
if "proudSkillExtraLevelMap" in i:
del i["proudSkillExtraLevelMap"]
return data
async def _load_characters(self, uid: Union[int, str]) -> List[CharacterInfo]:
original_data = await self.player_cards_file.load_history_info(uid)
if original_data is None:
return []
if original_data.get("avatarInfoList") is None:
original_data["avatarInfoList"] = []
if len(original_data["avatarInfoList"]) == 0:
return []
enka_response: EnkaNetworkResponse = EnkaNetworkResponse.parse_obj(
self._fix_skill_level(copy.deepcopy(original_data))
)
character_infos = []
for avatar_info in enka_response.characters:
try:
character_infos.append(EnkaConverter.to_character_info(avatar_info))
except ValueError as e:
logger.error("无法解析 Enka.Network 角色信息: %s\n%s", e, avatar_info.json())
return character_infos
@handler.command(command="gcsim", block=False)
async def gcsim(self, update: Update, context: "ContextTypes.DEFAULT_TYPE"):
user = update.effective_user
message = update.effective_message
args = self.get_args(context)
if not self.gcsim_runner.initialized:
await message.reply_text("GCSim 未初始化,请稍候再试或重启派蒙")
return
if context.user_data.get("overlapping", False):
reply = await message.reply_text("旅行者已经有脚本正在运行,请让派蒙稍微休息一下")
if filters.ChatType.GROUPS.filter(message):
self.add_delete_message_job(reply)
self.add_delete_message_job(message)
return
logger.info("用户 %s[%s] 发出 gcsim 命令", user.full_name, user.id)
uid = await self._get_uid(user.id, args, message.reply_to_message)
if uid is None:
return await _no_account_return(message, context)
character_infos = await self._load_characters(uid)
if not character_infos:
return await _no_character_return(user.id, uid, message)
fits = await self.gcsim_runner.get_fits(uid)
if not fits:
fits = await self.gcsim_runner.calculate_fits(uid, character_infos)
if not fits:
await message.reply_text("好像没有找到适合旅行者的配队呢,要不更新下面板吧")
return
buttons = self._gen_buttons(user.id, uid, fits)
await message.reply_text(
"请选择 GCSim 脚本",
reply_markup=InlineKeyboardMarkup(buttons),
)
@handler.callback_query(pattern=r"^gcsim_refresh\|", block=False)
async def gcsim_refresh(self, update: "Update", _: "ContextTypes.DEFAULT_TYPE") -> None:
callback_query = update.callback_query
user = callback_query.from_user
message = callback_query.message
user_id, uid = map(int, callback_query.data.split("|")[1:])
if user.id != user_id:
await callback_query.answer(text="这不是你的按钮!\n" + config.notice.user_mismatch, show_alert=True)
return
character_infos = await self._load_characters(uid)
if not character_infos:
return await _no_character_return(user.id, uid, message)
await self.gcsim_runner.remove_fits(uid)
fits = await self.gcsim_runner.calculate_fits(uid, character_infos)
if not fits:
await callback_query.edit_message_text("好像没有找到适合旅行者的配队呢,要不更新下面板吧")
return
buttons = self._gen_buttons(user.id, uid, fits)
await callback_query.edit_message_reply_markup(reply_markup=InlineKeyboardMarkup(buttons))
@handler.callback_query(pattern=r"^gcsim_page\|", block=False)
async def gcsim_page(self, update: "Update", _: "ContextTypes.DEFAULT_TYPE") -> None:
callback_query = update.callback_query
user = callback_query.from_user
message = callback_query.message
user_id, uid, page = map(int, callback_query.data.split("|")[1:])
if user.id != user_id:
await callback_query.answer(text="这不是你的按钮!\n" + config.notice.user_mismatch, show_alert=True)
return
fits = await self.gcsim_runner.get_fits(uid)
if not fits:
await callback_query.answer(text="其他数据好像被派蒙吃掉了,要不重新试试吧", show_alert=True)
await message.delete()
return
buttons = self._gen_buttons(user_id, uid, fits, page)
await callback_query.edit_message_reply_markup(reply_markup=InlineKeyboardMarkup(buttons))
@handler.callback_query(pattern=r"^gcsim_unclickable\|", block=False)
async def gcsim_unclickable(self, update: "Update", _: "ContextTypes.DEFAULT_TYPE") -> None:
callback_query = update.callback_query
_, _, _, reason = callback_query.data.split("|")
await callback_query.answer(
text="已经是第一页了!\n"
if reason == "first_page"
else "已经是最后一页了!\n"
if reason == "last_page"
else "这个按钮不可用\n" + config.notice.user_mismatch,
show_alert=True,
)
@handler.callback_query(pattern=r"^enqueue_gcsim\|", block=False)
async def enqueue_gcsim(self, update: "Update", _: "ContextTypes.DEFAULT_TYPE") -> None:
callback_query = update.callback_query
user = callback_query.from_user
message = callback_query.message
user_id, uid, script_key = callback_query.data.split("|")[1:]
msg_to_reply = message
if message.reply_to_message:
msg_to_reply = message.reply_to_message
logger.info("用户 %s[%s] GCSim运行请求 || %s", user.full_name, user.id, callback_query.data)
if str(user.id) != user_id:
await callback_query.answer(text="这不是你的按钮!\n" + config.notice.user_mismatch, show_alert=True)
return
logger.info("用户 %s[%s] enqueue_gcsim 运行请求 || %s", user.full_name, user.id, callback_query.data)
character_infos = await self._load_characters(uid)
if not character_infos:
return await _no_character_return(user.id, uid, message)
await callback_query.edit_message_text("GCSim 运行中...", reply_markup=InlineKeyboardMarkup([]))
result = await self.gcsim_runner.run(user_id, uid, script_key, character_infos)
if result.error:
await callback_query.edit_message_text(result.error)
else:
await callback_query.edit_message_text(f"GCSim {result.script_key} 运行完成")
if result.file_id:
await msg_to_reply.reply_photo(result.file_id, caption=f"GCSim {script_key} 运行结果")
self.add_delete_message_job(message, delay=1)
return
result_path = self.player_gcsim_scripts.get_result_path(uid, script_key)
if not result_path.exists():
await callback_query.answer(text="运行结果似乎在提瓦特之外,派蒙找不到了", show_alert=True)
return
if result.script is None:
await callback_query.answer(text="脚本似乎在提瓦特之外,派蒙找不到了", show_alert=True)
return
result_ = await self.gcsim_renderer.prepare_result(result_path, result.script, character_infos)
if not result_:
await callback_query.answer(text="在准备运行结果时派蒙出问题了", show_alert=True)
return
render_result = await self.gcsim_renderer.render(script_key, result_)
reply = await render_result.reply_photo(
msg_to_reply,
filename=f"gcsim_{uid}_{script_key}.png",
caption=f"GCSim {script_key} 运行结果",
)
self.add_delete_message_job(message, delay=1)
if reply and reply.photo:
await self.gcsim_runner.cache.set_cache(uid, hash(str(result.script)), reply.photo[0].file_id)

View File

@ -0,0 +1,93 @@
import json
from pathlib import Path
from typing import Optional, List
from core.dependence.assets import AssetsService
from gram_core.services.template.models import RenderResult
from gram_core.services.template.services import TemplateService
from metadata.shortname import idToName, elementToName, elementsToColor
from plugins.genshin.model import GCSim, GCSimCharacterInfo, CharacterInfo
from plugins.genshin.model.converters.gcsim import GCSimConverter
class GCSimResultRenderer:
def __init__(self, assets_service: AssetsService, template_service: TemplateService):
self.assets_service = assets_service
self.template_service = template_service
async def prepare_result(
self, result_path: Path, script: GCSim, character_infos: List[CharacterInfo]
) -> Optional[dict]:
result = json.loads(result_path.read_text(encoding="utf-8"))
characters = {ch.character for ch in character_infos}
result["extra"] = {}
for idx, character_details in enumerate(result["character_details"]):
asset_id, _ = GCSimConverter.to_character(character_details["name"])
gcsim_character: GCSimCharacterInfo = next(
filter(lambda gc, cn=character_details["name"]: gc.character == cn, script.characters), None
)
if not gcsim_character:
return None
if character_details["name"] not in result["extra"]:
result["extra"][character_details["name"]] = {}
if GCSimConverter.to_character(gcsim_character.character)[1] in characters:
result["extra"][character_details["name"]]["owned"] = True
else:
result["extra"][character_details["name"]]["owned"] = False
result["extra"][character_details["name"]]["icon"] = (
await self.assets_service.avatar(asset_id).icon()
).as_uri()
result["extra"][character_details["name"]]["rarity"] = self.assets_service.avatar(asset_id).enka.rarity
result["extra"][character_details["name"]]["constellation"] = gcsim_character.constellation
if "character_dps" not in result["extra"]:
result["extra"]["character_dps"] = []
result["extra"]["character_dps"].append(
{"value": result["statistics"]["character_dps"][idx]["mean"], "name": idToName(asset_id)}
)
result["extra"]["element_dps"] = [
{"value": data["mean"], "name": elementToName(elem), "itemStyle": {"color": elementsToColor[elem]}}
for elem, data in result["statistics"]["element_dps"].items()
]
result["extra"]["damage"] = {
"xAxis": [i * 0.5 for i in range(len(result["statistics"]["damage_buckets"]["buckets"]))],
"series": [
{
"data": [bucket["mean"] for bucket in result["statistics"]["damage_buckets"]["buckets"]],
"type": "line",
"stack": "x",
"areaStyle": {},
"name": "平均伤害",
},
{
"data": [bucket["min"] for bucket in result["statistics"]["damage_buckets"]["buckets"]],
"type": "line",
"stack": "x",
"name": "最小伤害",
},
{
"data": [bucket["max"] for bucket in result["statistics"]["damage_buckets"]["buckets"]],
"type": "line",
"stack": "x",
"name": "最大伤害",
},
{
"data": [bucket["sd"] for bucket in result["statistics"]["damage_buckets"]["buckets"]],
"type": "line",
"stack": "x",
"name": "标准差",
},
],
}
return result
async def render(self, script_key: str, data: dict) -> RenderResult:
return await self.template_service.render(
"genshin/gcsim/result.jinja2",
{"script_key": script_key, **data},
full_page=True,
query_selector="body > div",
ttl=7 * 24 * 60 * 60,
)

View File

@ -0,0 +1,229 @@
import asyncio
import multiprocessing
import platform
import time
from dataclasses import dataclass
from pathlib import Path
from queue import Queue
from typing import Optional, Dict, List, Union, TYPE_CHECKING, Tuple
import gcsim_pypi
from pydantic import BaseModel
from metadata.shortname import idToName
from modules.apihelper.client.components.remote import Remote
from modules.gcsim.cache import GCSimCache
from modules.gcsim.file import PlayerGCSimScripts
from plugins.genshin.model.base import CharacterInfo, Character
from plugins.genshin.model.converters.gcsim import GCSimConverter
from plugins.genshin.model.gcsim import GCSim, GCSimCharacter
from utils.const import DATA_DIR
from utils.log import logger
if TYPE_CHECKING:
from core.dependence.redisdb import RedisDB
GCSIM_SCRIPTS_PATH = DATA_DIR / "gcsim" / "scripts"
GCSIM_SCRIPTS_PATH.mkdir(parents=True, exist_ok=True)
class FitCharacter(BaseModel):
id: int
name: str
gcsim: GCSimCharacter
character: Character
def __str__(self):
return self.name
class GCSimFit(BaseModel):
script_key: str
fit_count: int
characters: List[FitCharacter]
total_levels: int
total_weapon_levels: int
@dataclass
class GCSimResult:
error: Optional[str]
user_id: str
uid: str
script_key: str
script: Optional[GCSim] = None
file_id: Optional[str] = None
def _get_gcsim_bin_name() -> str:
if platform.system() == "Windows":
return "gcsim.exe"
bin_name = "gcsim"
if platform.system() == "Darwin":
bin_name += ".darwin"
if platform.machine() == "arm64":
bin_name += ".arm64"
return bin_name
def _get_limit_command() -> str:
if platform.system() == "Linux":
return "ulimit -m 1000000 && ulimit -v 1000000 && timeout 120 "
return ""
class GCSimRunner:
def __init__(self, client: "RedisDB"):
self.initialized = False
self.bin_path = None
self.player_gcsim_scripts = PlayerGCSimScripts()
self.gcsim_version: Optional[str] = None
self.scripts: Dict[str, GCSim] = {}
max_concurrent_gcsim = multiprocessing.cpu_count()
self.sema = asyncio.BoundedSemaphore(max_concurrent_gcsim)
self.queue: Queue[None] = Queue()
self.cache = GCSimCache(client)
@staticmethod
def check_gcsim_script(name: str, script: str) -> Optional[GCSim]:
try:
return GCSimConverter.from_gcsim_script(script)
except ValueError as e:
logger.error("无法解析 GCSim 脚本 %s: %s", name, e)
return None
async def refresh(self):
self.player_gcsim_scripts.clear_fits()
self.scripts.clear()
new_scripts = await Remote.get_gcsim_scripts()
for name, text in new_scripts.items():
if script := self.check_gcsim_script(name, text):
self.scripts[name] = script
for path in GCSIM_SCRIPTS_PATH.iterdir():
if path.is_file():
with open(path, "r", encoding="utf-8") as f:
try:
if script := self.check_gcsim_script(path.name, f.read()):
self.scripts[path.stem] = script
except UnicodeError as e:
logger.error("无法读取 GCSim 脚本 %s: %s", path.name, e)
async def initialize(self):
gcsim_pypi_path = Path(gcsim_pypi.__file__).parent
self.bin_path = gcsim_pypi_path.joinpath("bin").joinpath(_get_gcsim_bin_name())
process = await asyncio.create_subprocess_exec(
self.bin_path, "-version", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode == 0:
self.gcsim_version = stdout.decode().splitlines()[0]
logger.debug("GCSim version: %s", self.gcsim_version)
else:
logger.error("GCSim 运行时出错: %s", stderr.decode())
now = time.time()
await self.refresh()
logger.debug("加载 %d GCSim 脚本耗时 %.2f", len(self.scripts), time.time() - now)
self.initialized = True
async def _execute_gcsim(
self, user_id: str, uid: str, script_key: str, added_time: float, character_infos: List[CharacterInfo]
) -> GCSimResult:
script = self.scripts.get(script_key)
if script is None:
return GCSimResult(error="未找到脚本", user_id=user_id, uid=uid, script_key=script_key)
try:
merged_script = GCSimConverter.merge_character_infos(script, character_infos)
except ValueError:
return GCSimResult(error="无法合并角色信息", user_id=user_id, uid=uid, script_key=script_key)
if file_id := await self.cache.get_cache(uid, hash(str(merged_script))):
return GCSimResult(error=None, user_id=user_id, uid=uid, script_key=script_key, file_id=file_id)
await self.player_gcsim_scripts.write_script(uid, script_key, str(merged_script))
limit = _get_limit_command()
command = [
self.bin_path,
"-c",
self.player_gcsim_scripts.get_script_path(uid, script_key).absolute().as_posix(),
"-out",
self.player_gcsim_scripts.get_result_path(uid, script_key).absolute().as_posix(),
]
process = await asyncio.create_subprocess_shell(
limit + " ".join([str(i) for i in command]),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
logger.debug("GCSim 脚本 (%s|%s|%s) 用时 %.2fs", user_id, uid, script_key, time.time() - added_time)
error = None
if stderr:
error = stderr.decode()[:500]
if "out of memory" in error:
error = "超出内存限制"
if process.returncode == 124:
error = "超出运行时间限制"
if error:
logger.error("GCSim 脚本 (%s|%s|%s) 错误: %s", user_id, uid, script_key, error)
return GCSimResult(error=error, user_id=user_id, uid=uid, script_key=script_key, script=merged_script)
if stdout:
logger.info("GCSim 脚本 (%s|%s|%s) 运行完成", user_id, uid, script_key)
logger.debug("GCSim 脚本 (%s|%s|%s) 输出: %s", user_id, uid, script_key, stdout.decode())
return GCSimResult(error=None, user_id=user_id, uid=uid, script_key=script_key, script=merged_script)
return GCSimResult(
error="No output",
user_id=user_id,
uid=uid,
script_key=script_key,
script=merged_script,
)
async def run(
self,
user_id: str,
uid: str,
script_key: str,
character_infos: List[CharacterInfo],
) -> GCSimResult:
start_time = time.time()
async with self.sema:
result = await self._execute_gcsim(user_id, uid, script_key, start_time, character_infos)
return result
async def calculate_fits(self, uid: Union[int, str], character_infos: List[CharacterInfo]) -> List[GCSimFit]:
fits = []
for key, script in self.scripts.items():
# 空和莹会被认为是两个角色
fit_characters: List[Tuple[CharacterInfo, GCSimCharacter]] = []
for ch in character_infos:
gcsim_character = GCSimConverter.from_character(ch.character)
if gcsim_character in [c.character for c in script.characters]:
fit_characters.append((ch, gcsim_character))
if fit_characters:
fits.append(
GCSimFit(
script_key=key,
characters=[
FitCharacter(id=ch[0].id, name=idToName(ch[0].id), gcsim=ch[1], character=ch[0].character)
for ch in fit_characters
],
fit_count=len(fit_characters),
total_levels=sum(ch.level for ch in script.characters),
total_weapon_levels=sum(ch.weapon_info.level for ch in script.characters),
)
)
fits = sorted(
fits,
key=lambda x: (x.fit_count, x.total_levels, x.total_weapon_levels),
reverse=True,
)
await self.player_gcsim_scripts.write_fits(uid, [fit.dict() for fit in fits])
return fits
async def get_fits(self, uid: Union[int, str]) -> List[GCSimFit]:
return [GCSimFit(**fit) for fit in self.player_gcsim_scripts.get_fits(uid)]
async def remove_fits(self, uid: Union[int, str]) -> None:
self.player_gcsim_scripts.remove_fits(uid)

View File

@ -0,0 +1,29 @@
from plugins.genshin.model.base import *
from plugins.genshin.model.gcsim import *
__all__ = [
"Digit",
"DigitType",
"Character",
"CharacterInfo",
"CharacterStats",
"Weapon",
"WeaponInfo",
"WeaponType",
"Set",
"Artifact",
"ArtifactAttribute",
"ArtifactAttributeType",
"ArtifactPosition",
"GCSimCharacter",
"GCSimSet",
"GCSimSetInfo",
"GCSimWeapon",
"GCSimWeaponInfo",
"GCSimCharacterStats",
"GCSimCharacterInfo",
"GCSimTarget",
"GCSimEnergySettings",
"GCSim",
]

View File

@ -0,0 +1,198 @@
from decimal import Decimal
from enum import Enum
from typing import Optional, List, NewType
from pydantic import BaseModel, Field, validator
# TODO: 考虑自动生成Enum
Character = NewType("Character", str)
Weapon = NewType("Weapon", str)
Set = NewType("Set", str)
class DigitType(Enum):
NUMERIC = "numeric"
PERCENT = "percent"
class Digit(BaseModel):
type: DigitType
value: Decimal
class WeaponType(Enum):
BOW = "bow"
CLAYMORE = "claymore"
CATALYST = "catalyst"
POLEARM = "polearm"
SWORD = "sword"
class ArtifactPosition(Enum):
FLOWER = "flower"
PLUME = "plume"
SANDS = "sands"
GOBLET = "goblet"
CIRCLET = "circlet"
class ArtifactAttributeType(Enum):
HP = "hp"
ATK = "atk"
DEF = "def"
HP_PERCENT = "hp_percent"
ATK_PERCENT = "atk_percent"
DEF_PERCENT = "def_percent"
ELEMENTAL_MASTERY = "elemental_mastery"
ENERGY_RECHARGE = "energy_recharge"
CRIT_RATE = "crit_rate"
CRIT_DMG = "crit_dmg"
HEALING_BONUS = "healing_bonus"
PYRO_DMG_BONUS = "pyro_dmg_bonus"
HYDRO_DMG_BONUS = "hydro_dmg_bonus"
DENDRO_DMG_BONUS = "dendro_dmg_bonus"
ELECTRO_DMG_BONUS = "electro_dmg_bonus"
ANEMO_DMG_BONUS = "anemo_dmg_bonus"
CRYO_DMG_BONUS = "cryo_dmg_bonus"
GEO_DMG_BONUS = "geo_dmg_bonus"
PHYSICAL_DMG_BONUS = "physical_dmg_bonus"
class ArtifactAttribute(BaseModel):
type: ArtifactAttributeType
digit: Digit
class WeaponInfo(BaseModel):
id: int = 0
weapon: Weapon = ""
type: WeaponType
level: int = 0
max_level: int = 0
refinement: int = 0
ascension: int = 0
@validator("max_level")
def validate_max_level(cls, v, values):
if v == 0:
return values["level"]
if v < values["level"]:
raise ValueError("max_level must be greater than or equal to level")
return v
@validator("refinement")
def validate_refinement(cls, v):
if v < 0 or v > 5:
raise ValueError("refinement must be between 1 and 5")
return v
class Artifact(BaseModel):
id: int = 0
set: Set = ""
position: ArtifactPosition
level: int = 0
rarity: int = 0
main_attribute: ArtifactAttribute
sub_attributes: List[ArtifactAttribute] = []
@validator("level")
def validate_level(cls, v):
if v < 0 or v > 20:
raise ValueError("level must be between 0 and 20")
return v
@validator("rarity")
def validate_rarity(cls, v):
if v < 0 or v > 5:
raise ValueError("rarity must be between 0 and 5")
return v
@validator("sub_attributes")
def validate_sub_attributes(cls, v):
if len(v) > 4:
raise ValueError("sub_attributes must not be greater than 4")
return v
class CharacterStats(BaseModel):
BASE_HP: Digit = Digit(type=DigitType.NUMERIC, value=Decimal(0))
HP: Digit = Field(Digit(type=DigitType.NUMERIC, value=Decimal(0)), alias="FIGHT_PROP_HP")
HP_PERCENT: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_HP_PERCENT")
BASE_ATTACK: Digit = Field(Digit(type=DigitType.NUMERIC, value=Decimal(0)), alias="FIGHT_PROP_BASE_ATTACK")
ATTACK: Digit = Field(Digit(type=DigitType.NUMERIC, value=Decimal(0)), alias="FIGHT_PROP_ATTACK")
ATTACK_PERCENT: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_ATTACK_PERCENT")
BASE_DEFENSE: Digit = Field(Digit(type=DigitType.NUMERIC, value=Decimal(0)), alias="FIGHT_PROP_BASE_DEFENSE")
DEFENSE: Digit = Field(Digit(type=DigitType.NUMERIC, value=Decimal(0)), alias="FIGHT_PROP_DEFENSE")
DEFENSE_PERCENT: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_DEFENSE_PERCENT")
ELEMENTAL_MASTERY: Digit = Field(
Digit(type=DigitType.NUMERIC, value=Decimal(0)), alias="FIGHT_PROP_ELEMENT_MASTERY"
)
CRIT_RATE: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_CRITICAL")
CRIT_DMG: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_CRITICAL_HURT")
HEALING_BONUS: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_HEAL_ADD")
INCOMING_HEALING_BONUS: Digit = Field(
Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_HEALED_ADD"
)
ENERGY_RECHARGE: Digit = Field(
Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_CHARGE_EFFICIENCY"
)
CD_REDUCTION: Digit = Field(
Digit(type=DigitType.NUMERIC, value=Decimal(0)), alias="FIGHT_PROP_SKILL_CD_MINUS_RATIO"
)
SHIELD_STRENGTH: Digit = Field(
Digit(type=DigitType.NUMERIC, value=Decimal(0)), alias="FIGHT_PROP_SHIELD_COST_MINUS_RATIO"
)
PYRO_DMG_BONUS: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_FIRE_ADD_HURT")
PYRO_RES: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_FIRE_SUB_HURT")
HYDRO_DMG_BONUS: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_WATER_ADD_HURT")
HYDRO_RES: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_WATER_SUB_HURT")
DENDRO_DMG_BONUS: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_GRASS_ADD_HURT")
DENDRO_RES: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_GRASS_SUB_HURT")
ELECTRO_DMG_BONUS: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_ELEC_ADD_HURT")
ELECTRO_RES: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_ELEC_SUB_HURT")
ANEMO_DMG_BONUS: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_WIND_ADD_HURT")
ANEMO_RES: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_WIND_SUB_HURT")
CRYO_DMG_BONUS: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_ICE_ADD_HURT")
CRYO_RES: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_ICE_SUB_HURT")
GEO_DMG_BONUS: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_ROCK_ADD_HURT")
GEO_RES: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_ROCK_SUB_HURT")
PHYSICAL_DMG_BONUS: Digit = Field(
Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_PHYSICAL_SUB_HURT"
)
PHYSICAL_RES: Digit = Field(Digit(type=DigitType.PERCENT, value=Decimal(0)), alias="FIGHT_PROP_PHYSICAL_ADD_HURT")
CURRENT_HP: Digit = Field(Digit(type=DigitType.NUMERIC, value=Decimal(0)), alias="FIGHT_PROP_CUR_HP")
MAX_HP: Digit = Field(Digit(type=DigitType.NUMERIC, value=Decimal(0)), alias="FIGHT_PROP_MAX_HP")
CURRENT_ATTACK: Digit = Field(Digit(type=DigitType.NUMERIC, value=Decimal(0)), alias="FIGHT_PROP_CUR_ATTACK")
CURRENT_DEFENSE: Digit = Field(Digit(type=DigitType.NUMERIC, value=Decimal(0)), alias="FIGHT_PROP_CUR_DEFENSE")
class CharacterInfo(BaseModel):
id: int = 0
character: Character = ""
weapon_info: Optional[WeaponInfo] = None
artifacts: List[Artifact] = []
level: int = 0
max_level: int = 0
constellation: int = 0
ascension: int = 0
skills: List[int] = []
rarity: int = 0
stats: CharacterStats = CharacterStats()
@validator("max_level")
def validate_max_level(cls, v, values):
if v == 0:
return values["level"]
if v < values["level"]:
raise ValueError("max_level must be greater than or equal to level")
return v
@validator("skills")
def validate_skills(cls, v):
if len(v) > 3:
raise ValueError("skills must not be greater than 3")
return v

View File

@ -0,0 +1,197 @@
from decimal import Decimal
from enkanetwork import (
CharacterInfo as EnkaCharacterInfo,
CharacterStats as EnkaCharacterStats,
StatsPercentage,
Equipments,
EquipmentsType,
EquipType,
EquipmentsStats,
DigitType as EnkaDigitType,
)
from plugins.genshin.model import (
CharacterInfo,
Digit,
DigitType,
CharacterStats,
WeaponInfo,
WeaponType,
Artifact,
ArtifactPosition,
ArtifactAttribute,
ArtifactAttributeType,
)
from plugins.genshin.model.metadata import Metadata
metadata = Metadata()
class EnkaConverter:
@classmethod
def to_weapon_type(cls, type_str: str) -> WeaponType:
if type_str == "WEAPON_BOW":
return WeaponType.BOW
if type_str == "WEAPON_CATALYST":
return WeaponType.CATALYST
if type_str == "WEAPON_CLAYMORE":
return WeaponType.CLAYMORE
if type_str == "WEAPON_POLE":
return WeaponType.POLEARM
if type_str == "WEAPON_SWORD_ONE_HAND":
return WeaponType.SWORD
if type_str == "单手剑":
return WeaponType.SWORD
raise ValueError(f"Unknown weapon type: {type_str}")
@classmethod
def to_weapon_info(cls, equipment: Equipments) -> WeaponInfo:
if equipment.type != EquipmentsType.WEAPON:
raise ValueError(f"Not weapon equipment type: {equipment.type}")
weapon_data = metadata.weapon_metadata.get(str(equipment.id))
if not weapon_data:
raise ValueError(f"Unknown weapon id: {equipment.id}")
return WeaponInfo(
id=equipment.id,
weapon=weapon_data["route"],
type=cls.to_weapon_type(weapon_data["type"]),
level=equipment.level,
max_level=equipment.max_level,
refinement=equipment.refinement,
ascension=equipment.ascension,
)
@classmethod
def to_artifact_attribute_type(cls, prop_id: str) -> ArtifactAttributeType: # skipcq: PY-R1000
if prop_id == "FIGHT_PROP_HP":
return ArtifactAttributeType.HP
if prop_id == "FIGHT_PROP_ATTACK":
return ArtifactAttributeType.ATK
if prop_id == "FIGHT_PROP_DEFENSE":
return ArtifactAttributeType.DEF
if prop_id == "FIGHT_PROP_HP_PERCENT":
return ArtifactAttributeType.HP_PERCENT
if prop_id == "FIGHT_PROP_ATTACK_PERCENT":
return ArtifactAttributeType.ATK_PERCENT
if prop_id == "FIGHT_PROP_DEFENSE_PERCENT":
return ArtifactAttributeType.DEF_PERCENT
if prop_id == "FIGHT_PROP_ELEMENT_MASTERY":
return ArtifactAttributeType.ELEMENTAL_MASTERY
if prop_id == "FIGHT_PROP_CHARGE_EFFICIENCY":
return ArtifactAttributeType.ENERGY_RECHARGE
if prop_id == "FIGHT_PROP_CRITICAL":
return ArtifactAttributeType.CRIT_RATE
if prop_id == "FIGHT_PROP_CRITICAL_HURT":
return ArtifactAttributeType.CRIT_DMG
if prop_id == "FIGHT_PROP_HEAL_ADD":
return ArtifactAttributeType.HEALING_BONUS
if prop_id == "FIGHT_PROP_FIRE_ADD_HURT":
return ArtifactAttributeType.PYRO_DMG_BONUS
if prop_id == "FIGHT_PROP_WATER_ADD_HURT":
return ArtifactAttributeType.HYDRO_DMG_BONUS
if prop_id == "FIGHT_PROP_ELEC_ADD_HURT":
return ArtifactAttributeType.ELECTRO_DMG_BONUS
if prop_id == "FIGHT_PROP_ICE_ADD_HURT":
return ArtifactAttributeType.CRYO_DMG_BONUS
if prop_id == "FIGHT_PROP_WIND_ADD_HURT":
return ArtifactAttributeType.ANEMO_DMG_BONUS
if prop_id == "FIGHT_PROP_ROCK_ADD_HURT":
return ArtifactAttributeType.GEO_DMG_BONUS
if prop_id == "FIGHT_PROP_GRASS_ADD_HURT":
return ArtifactAttributeType.DENDRO_DMG_BONUS
if prop_id == "FIGHT_PROP_PHYSICAL_ADD_HURT":
return ArtifactAttributeType.PHYSICAL_DMG_BONUS
raise ValueError(f"Unknown artifact attribute type: {prop_id}")
@classmethod
def to_artifact_attribute(cls, equip_stat: EquipmentsStats) -> ArtifactAttribute:
return ArtifactAttribute(
type=cls.to_artifact_attribute_type(equip_stat.prop_id),
digit=Digit(
value=Decimal(equip_stat.value),
type=DigitType.PERCENT if equip_stat.type == EnkaDigitType.PERCENT else DigitType.NUMERIC,
),
)
@classmethod
def to_artifact_position(cls, equip_type: EquipType) -> ArtifactPosition:
if equip_type == EquipType.Flower:
return ArtifactPosition.FLOWER
if equip_type == EquipType.Feather:
return ArtifactPosition.PLUME
if equip_type == EquipType.Sands:
return ArtifactPosition.SANDS
if equip_type == EquipType.Goblet:
return ArtifactPosition.GOBLET
if equip_type == EquipType.Circlet:
return ArtifactPosition.CIRCLET
raise ValueError(f"Unknown artifact position: {equip_type}")
@classmethod
def to_artifact(cls, equipment: Equipments) -> Artifact:
if equipment.type != EquipmentsType.ARTIFACT:
raise ValueError(f"Not artifact equipment type: {equipment.type}")
artifact_data = next(
(
data
for data in metadata.artifacts_metadata.values()
if data["name"] == equipment.detail.artifact_name_set
),
None,
)
if not artifact_data:
raise ValueError(f"Unknown artifact: {equipment}")
return Artifact(
id=artifact_data["id"],
set=artifact_data["route"],
position=cls.to_artifact_position(equipment.detail.artifact_type),
level=equipment.level,
rarity=equipment.detail.rarity,
main_attribute=cls.to_artifact_attribute(equipment.detail.mainstats),
sub_attributes=[cls.to_artifact_attribute(stat) for stat in equipment.detail.substats],
)
@classmethod
def to_character_stats(cls, character_stats: EnkaCharacterStats) -> CharacterStats:
return CharacterStats(
**{
stat: Digit(
value=Decimal(value.value),
type=DigitType.PERCENT if isinstance(value, StatsPercentage) else DigitType.NUMERIC,
)
for stat, value in character_stats._iter() # pylint: disable=W0212
}
)
@classmethod
def to_character(cls, character_info: EnkaCharacterInfo) -> str:
character_id = str(character_info.id)
if character_id in ("10000005", "10000007"):
character_id += f"-{character_info.element.name.lower()}"
character_data = metadata.characters_metadata.get(character_id)
if not character_data:
raise ValueError(f"Unknown character: {character_info.name}\n{character_info}")
return character_data["route"]
@classmethod
def to_character_info(cls, character_info: EnkaCharacterInfo) -> CharacterInfo:
weapon_equip = next((equip for equip in character_info.equipments if equip.type == EquipmentsType.WEAPON), None)
artifacts_equip = [equip for equip in character_info.equipments if equip.type == EquipmentsType.ARTIFACT]
return CharacterInfo(
id=character_info.id,
character=cls.to_character(character_info),
rarity=character_info.rarity,
weapon_info=cls.to_weapon_info(weapon_equip) if weapon_equip else None,
artifacts=[cls.to_artifact(equip) for equip in artifacts_equip],
level=character_info.level,
max_level=character_info.max_level,
ascension=character_info.ascension,
constellation=character_info.constellations_unlocked,
skills=[skill.level for skill in character_info.skills],
stats=cls.to_character_stats(character_info.stats),
)

View File

@ -0,0 +1,405 @@
import re
from collections import Counter
from decimal import Decimal
from functools import lru_cache
from typing import List, Optional, Tuple, Dict
from gcsim_pypi.aliases import CHARACTER_ALIASES, WEAPON_ALIASES, ARTIFACT_ALIASES
from pydantic import ValidationError
from plugins.genshin.model import (
Set,
Weapon,
DigitType,
WeaponInfo,
Artifact,
ArtifactAttributeType,
Character,
CharacterInfo,
GCSim,
GCSimTarget,
GCSimWeapon,
GCSimWeaponInfo,
GCSimSet,
GCSimSetInfo,
GCSimCharacter,
GCSimEnergySettings,
GCSimCharacterInfo,
GCSimCharacterStats,
)
from plugins.genshin.model.metadata import Metadata
from utils.log import logger
metadata = Metadata()
def remove_non_words(text: str) -> str:
return text.replace("'", "").replace('"', "").replace("-", "").replace(" ", "")
def from_character_gcsim_character(character: Character) -> GCSimCharacter:
if character == "Raiden Shogun":
return GCSimCharacter("raiden")
if character == "Yae Miko":
return GCSimCharacter("yaemiko")
if character == "Hu Tao":
return GCSimCharacter("hutao")
if character == "Yun Jin":
return GCSimCharacter("yunjin")
if character == "Kuki Shinobu":
return GCSimCharacter("kuki")
if "Traveler" in character:
s = character.split(" ")
traveler_name = "aether" if s[-1] == "Boy" else "lumine"
return GCSimCharacter(f"{traveler_name}{s[0].lower()}")
return GCSimCharacter(character.split(" ")[-1].lower())
GCSIM_CHARACTER_TO_CHARACTER: Dict[GCSimCharacter, Tuple[int, Character]] = {}
for char in metadata.characters_metadata.values():
GCSIM_CHARACTER_TO_CHARACTER[from_character_gcsim_character(char["route"])] = (char["id"], char["route"])
for alias, char in CHARACTER_ALIASES.items():
if alias not in GCSIM_CHARACTER_TO_CHARACTER:
if char in GCSIM_CHARACTER_TO_CHARACTER:
GCSIM_CHARACTER_TO_CHARACTER[alias] = GCSIM_CHARACTER_TO_CHARACTER[char]
elif alias.startswith("traveler") or alias.startswith("aether") or alias.startswith("lumine"):
continue
else:
logger.warning("Character alias %s not found in GCSIM", alias)
GCSIM_WEAPON_TO_WEAPON: Dict[GCSimWeapon, Tuple[int, Weapon]] = {}
for _weapon in metadata.weapon_metadata.values():
GCSIM_WEAPON_TO_WEAPON[remove_non_words(_weapon["route"].lower())] = (_weapon["id"], _weapon["route"])
for alias, _weapon in WEAPON_ALIASES.items():
if alias not in GCSIM_WEAPON_TO_WEAPON:
if _weapon in GCSIM_WEAPON_TO_WEAPON:
GCSIM_WEAPON_TO_WEAPON[alias] = GCSIM_WEAPON_TO_WEAPON[_weapon]
else:
logger.warning("Weapon alias %s not found in GCSIM", alias)
GCSIM_ARTIFACT_TO_ARTIFACT: Dict[GCSimSet, Tuple[int, Set]] = {}
for _artifact in metadata.artifacts_metadata.values():
GCSIM_ARTIFACT_TO_ARTIFACT[remove_non_words(_artifact["route"].lower())] = (_artifact["id"], _artifact["route"])
for alias, _artifact in ARTIFACT_ALIASES.items():
if alias not in GCSIM_ARTIFACT_TO_ARTIFACT:
if _artifact in GCSIM_ARTIFACT_TO_ARTIFACT:
GCSIM_ARTIFACT_TO_ARTIFACT[alias] = GCSIM_ARTIFACT_TO_ARTIFACT[_artifact]
else:
logger.warning("Artifact alias %s not found in GCSIM", alias)
class GCSimConverter:
literal_keys_numeric_values_regex = re.compile(
r"([\w_%]+)=(\d+ *, *\d+ *, *\d+|[\d*\.*\d+]+ *, *[\d*\.*\d+]+|\d+/\d+|\d*\.*\d+|\d+)"
)
@classmethod
def to_character(cls, character: GCSimCharacter) -> Tuple[int, Character]:
return GCSIM_CHARACTER_TO_CHARACTER[character]
@classmethod
def from_character(cls, character: Character) -> GCSimCharacter:
return from_character_gcsim_character(character)
@classmethod
def to_weapon(cls, weapon: GCSimWeapon) -> Tuple[int, Weapon]:
return GCSIM_WEAPON_TO_WEAPON[weapon]
@classmethod
def from_weapon(cls, weapon: Weapon) -> GCSimWeapon:
return GCSimWeapon(remove_non_words(weapon).lower())
@classmethod
def from_weapon_info(cls, weapon_info: Optional[WeaponInfo]) -> GCSimWeaponInfo:
if weapon_info is None:
return GCSimWeaponInfo(weapon=GCSimWeapon("dullblade"), refinement=1, level=1, max_level=20)
return GCSimWeaponInfo(
weapon=cls.from_weapon(weapon_info.weapon),
refinement=weapon_info.refinement,
level=weapon_info.level,
max_level=weapon_info.max_level,
)
@classmethod
def to_set(cls, set_name: GCSimSet) -> Tuple[int, Set]:
return GCSIM_ARTIFACT_TO_ARTIFACT[set_name]
@classmethod
def from_set(cls, set_name: Set) -> GCSimSet:
return GCSimSet(remove_non_words(set_name).lower())
@classmethod
def from_artifacts(cls, artifacts: List[Artifact]) -> List[GCSimSetInfo]:
c = Counter()
for art in artifacts:
c[cls.from_set(art.set)] += 1
return [GCSimSetInfo(set=set_name, count=count) for set_name, count in c.items()]
@classmethod
@lru_cache
def from_attribute_type(cls, attribute_type: ArtifactAttributeType) -> str: # skipcq: PY-R1000
if attribute_type == ArtifactAttributeType.HP:
return "HP"
if attribute_type == ArtifactAttributeType.HP_PERCENT:
return "HP_PERCENT"
if attribute_type == ArtifactAttributeType.ATK:
return "ATK"
if attribute_type == ArtifactAttributeType.ATK_PERCENT:
return "ATK_PERCENT"
if attribute_type == ArtifactAttributeType.DEF:
return "DEF"
if attribute_type == ArtifactAttributeType.DEF_PERCENT:
return "DEF_PERCENT"
if attribute_type == ArtifactAttributeType.ELEMENTAL_MASTERY:
return "EM"
if attribute_type == ArtifactAttributeType.ENERGY_RECHARGE:
return "ER"
if attribute_type == ArtifactAttributeType.CRIT_RATE:
return "CR"
if attribute_type == ArtifactAttributeType.CRIT_DMG:
return "CD"
if attribute_type == ArtifactAttributeType.HEALING_BONUS:
return "HEAL"
if attribute_type == ArtifactAttributeType.PYRO_DMG_BONUS:
return "PYRO_PERCENT"
if attribute_type == ArtifactAttributeType.HYDRO_DMG_BONUS:
return "HYDRO_PERCENT"
if attribute_type == ArtifactAttributeType.DENDRO_DMG_BONUS:
return "DENDRO_PERCENT"
if attribute_type == ArtifactAttributeType.ELECTRO_DMG_BONUS:
return "ELECTRO_PERCENT"
if attribute_type == ArtifactAttributeType.ANEMO_DMG_BONUS:
return "ANEMO_PERCENT"
if attribute_type == ArtifactAttributeType.CRYO_DMG_BONUS:
return "CRYO_PERCENT"
if attribute_type == ArtifactAttributeType.GEO_DMG_BONUS:
return "GEO_PERCENT"
if attribute_type == ArtifactAttributeType.PHYSICAL_DMG_BONUS:
return "PHYS_PERCENT"
raise ValueError(f"Unknown attribute type: {attribute_type}")
@classmethod
def from_artifacts_stats(cls, artifacts: List[Artifact]) -> GCSimCharacterStats:
gcsim_stats = GCSimCharacterStats()
for art in artifacts:
main_attr_name = cls.from_attribute_type(art.main_attribute.type)
setattr(
gcsim_stats,
main_attr_name,
getattr(gcsim_stats, main_attr_name)
+ (
Decimal(art.main_attribute.digit.value) / Decimal(100)
if art.main_attribute.digit.type == DigitType.PERCENT
else Decimal(art.main_attribute.digit.value)
),
)
for sub_attr in art.sub_attributes:
attr_name = cls.from_attribute_type(sub_attr.type)
setattr(
gcsim_stats,
attr_name,
getattr(gcsim_stats, attr_name)
+ (
Decimal(sub_attr.digit.value) / Decimal(100)
if sub_attr.digit.type == DigitType.PERCENT
else Decimal(sub_attr.digit.value)
),
)
return gcsim_stats
@classmethod
def from_character_info(cls, character: CharacterInfo) -> GCSimCharacterInfo:
return GCSimCharacterInfo(
character=cls.from_character(character.character),
level=character.level,
max_level=character.max_level,
constellation=character.constellation,
talent=character.skills,
weapon_info=cls.from_weapon_info(character.weapon_info),
set_info=cls.from_artifacts(character.artifacts),
# NOTE: Only stats from arifacts are needed
stats=cls.from_artifacts_stats(character.artifacts),
)
@classmethod
def merge_character_infos(cls, gcsim: GCSim, character_infos: List[CharacterInfo]) -> GCSim:
gcsim_characters = {ch.character: ch for ch in gcsim.characters}
for character_info in character_infos:
try:
gcsim_character = cls.from_character_info(character_info)
if gcsim_character.character in gcsim_characters:
gcsim_characters[gcsim_character.character] = gcsim_character
except ValidationError as e:
errors = e.errors()
if errors and errors[0].get("msg").startswith("Not supported"):
# Something is not supported, skip
continue
logger.warning("Failed to convert character info: %s", character_info)
gcsim.characters = list(gcsim_characters.values())
return gcsim
@classmethod
def prepend_scripts(cls, gcsim: GCSim, scripts: List[str]) -> GCSim:
gcsim.scripts = scripts + gcsim.scripts
return gcsim
@classmethod
def append_scripts(cls, gcsim: GCSim, scripts: List[str]) -> GCSim:
gcsim.scripts = gcsim.scripts + scripts
return gcsim
@classmethod
def from_gcsim_energy(cls, line: str) -> GCSimEnergySettings:
energy_settings = GCSimEnergySettings()
matches = cls.literal_keys_numeric_values_regex.findall(line)
for key, value in matches:
if key == "interval":
energy_settings.intervals = list(map(int, value.split(",")))
elif key == "amount":
energy_settings.amount = int(value)
else:
logger.warning("Unknown energy setting: %s=%s", key, value)
return energy_settings
@classmethod
def from_gcsim_target(cls, line: str) -> GCSimTarget:
target = GCSimTarget()
matches = cls.literal_keys_numeric_values_regex.findall(line)
for key, value in matches:
if key == "lvl":
target.level = int(value)
elif key == "hp":
target.hp = int(value)
elif key == "amount":
target.amount = int(value)
elif key == "resist":
target.resist = float(value)
elif key == "pos":
target.position = tuple(p for p in value.split(","))
elif key == "interval":
target.interval = list(map(int, value.split(",")))
elif key == "radius":
target.radius = float(value)
elif key == "particle_threshold":
target.particle_threshold = int(value)
elif key == "particle_drop_count":
target.particle_drop_count = int(value)
elif key in ("pyro", "hydro", "dendro", "electro", "anemo", "cryo", "geo", "physical"):
target.others[key] = float(value)
else:
logger.warning("Unknown target setting: %s=%s", key, value)
return target
@classmethod
def from_gcsim_char_line(cls, line: str, character: GCSimCharacterInfo) -> GCSimCharacterInfo:
matches = cls.literal_keys_numeric_values_regex.findall(line)
for key, value in matches:
if key == "lvl":
character.level, character.max_level = map(int, value.split("/"))
elif key == "cons":
character.constellation = int(value)
elif key == "talent":
character.talent = list(map(int, value.split(",")))
elif key == "start_hp":
character.start_hp = int(value)
elif key == "breakthrough":
character.params.append(f"{key}={value}")
else:
logger.warning("Unknown character setting: %s=%s", key, value)
return character
@classmethod
def from_gcsim_weapon_line(cls, line: str, weapon_info: GCSimWeaponInfo) -> GCSimWeaponInfo:
weapon_name = re.search(r"weapon= *\"(.*)\"", line).group(1)
if weapon_name not in WEAPON_ALIASES:
raise ValueError(f"Unknown weapon: {weapon_name}")
weapon_info.weapon = WEAPON_ALIASES[weapon_name]
for key, value in cls.literal_keys_numeric_values_regex.findall(line):
if key == "refine":
weapon_info.refinement = int(value)
elif key == "lvl":
weapon_info.level, weapon_info.max_level = map(int, value.split("/"))
elif key.startswith("stack"):
weapon_info.params.append(f"stacks={value}")
elif key in ("pickup_delay", "breakthrough"):
weapon_info.params.append(f"{key}={value}")
else:
logger.warning("Unknown weapon setting: %s=%s", key, value)
return weapon_info
@classmethod
def from_gcsim_set_line(cls, line: str) -> GCSimSetInfo:
gcsim_set = re.search(r"set= *\"(.*)\"", line).group(1)
if gcsim_set not in ARTIFACT_ALIASES:
raise ValueError(f"Unknown set: {gcsim_set}")
gcsim_set = ARTIFACT_ALIASES[gcsim_set]
set_info = GCSimSetInfo(set=gcsim_set)
for key, value in cls.literal_keys_numeric_values_regex.findall(line):
if key == "count":
set_info.count = int(value)
elif key.startswith("stack"):
set_info.params.append(f"stacks={value}")
else:
logger.warning("Unknown set info: %s=%s", key, value)
return set_info
@classmethod
def from_gcsim_stats_line(cls, line: str, stats: GCSimCharacterStats) -> GCSimCharacterStats:
matches = re.findall(r"(\w+%?)=(\d*\.*\d+)", line)
for stat, value in matches:
attr = stat.replace("%", "_percent").upper()
setattr(stats, attr, getattr(stats, attr) + Decimal(value))
return stats
@classmethod
def from_gcsim_script(cls, script: str) -> GCSim: # skipcq: PY-R1000
options = ""
characters = {}
character_aliases = {}
active_character = None
targets = []
energy_settings = GCSimEnergySettings()
script_lines = []
for line in script.strip().split("\n"):
line = line.split("#")[0].strip()
if not line or line.startswith("#"):
continue
if line.startswith("options"):
options = line.strip(";")
elif line.startswith("target"):
targets.append(cls.from_gcsim_target(line))
elif line.startswith("energy"):
energy_settings = cls.from_gcsim_energy(line)
elif line.startswith("active"):
active_character = line.strip(";").split(" ")[1]
elif m := re.match(r"(\w+) +(char|add weapon|add set|add stats)\W", line):
if m.group(1) not in CHARACTER_ALIASES:
raise ValueError(f"Unknown character: {m.group(1)}")
c = CHARACTER_ALIASES[m.group(1)]
if c not in characters:
characters[c] = GCSimCharacterInfo(character=c)
if m.group(1) != c:
character_aliases[m.group(1)] = c
if m.group(2) == "char":
characters[c] = cls.from_gcsim_char_line(line, characters[c])
elif m.group(2) == "add weapon":
characters[c].weapon_info = cls.from_gcsim_weapon_line(line, characters[c].weapon_info)
elif m.group(2) == "add set":
characters[c].set_info.append(cls.from_gcsim_set_line(line))
elif m.group(2) == "add stats":
characters[c].stats = cls.from_gcsim_stats_line(line, characters[c].stats)
else:
for key, value in character_aliases.items():
line = line.replace(f"{key} ", f"{value} ")
line = line.replace(f".{key}.", f".{value}.")
script_lines.append(line)
return GCSim(
options=options,
characters=list(characters.values()),
targets=targets,
energy_settings=energy_settings,
active_character=active_character,
script_lines=script_lines,
)

View File

@ -0,0 +1,233 @@
from decimal import Decimal
from typing import Any, NewType, List, Optional, Tuple, Dict
from gcsim_pypi.aliases import ARTIFACT_ALIASES, CHARACTER_ALIASES, WEAPON_ALIASES
from gcsim_pypi.availability import AVAILABLE_ARTIFACTS, AVAILABLE_CHARACTERS, AVAILABLE_WEAPONS
from pydantic import BaseModel, validator
GCSimCharacter = NewType("GCSimCharacter", str)
GCSimWeapon = NewType("GCSimWeapon", str)
GCSimSet = NewType("GCSimSet", str)
class GCSimWeaponInfo(BaseModel):
weapon: GCSimWeapon = "dullblade"
refinement: int = 1
level: int = 1
max_level: int = 20
params: List[str] = []
@validator("weapon")
def validate_weapon(cls, v):
if v not in AVAILABLE_WEAPONS or v not in WEAPON_ALIASES:
raise ValueError(f"Not supported weapon: {v}")
return WEAPON_ALIASES[v]
class GCSimSetInfo(BaseModel):
set: GCSimSet
count: int = 2
params: List[str] = []
@validator("set")
def validate_set(cls, v):
if v not in AVAILABLE_ARTIFACTS or v not in ARTIFACT_ALIASES:
raise ValueError(f"Not supported set: {v}")
return ARTIFACT_ALIASES[v]
class GCSimCharacterStats(BaseModel):
HP: Decimal = Decimal(0)
HP_PERCENT: Decimal = Decimal(0)
ATK: Decimal = Decimal(0)
ATK_PERCENT: Decimal = Decimal(0)
DEF: Decimal = Decimal(0)
DEF_PERCENT: Decimal = Decimal(0)
EM: Decimal = Decimal(0)
ER: Decimal = Decimal(0)
CR: Decimal = Decimal(0)
CD: Decimal = Decimal(0)
HEAL: Decimal = Decimal(0)
PYRO_PERCENT: Decimal = Decimal(0)
HYDRO_PERCENT: Decimal = Decimal(0)
DENDRO_PERCENT: Decimal = Decimal(0)
ELECTRO_PERCENT: Decimal = Decimal(0)
ANEMO_PERCENT: Decimal = Decimal(0)
CRYO_PERCENT: Decimal = Decimal(0)
GEO_PERCENT: Decimal = Decimal(0)
PHYS_PERCENT: Decimal = Decimal(0)
class GCSimCharacterInfo(BaseModel):
character: GCSimCharacter
level: int = 1
max_level: int = 20
constellation: int = 0
talent: List[int] = [1, 1, 1]
start_hp: Optional[int] = None
weapon_info: GCSimWeaponInfo = GCSimWeaponInfo()
set_info: List[GCSimSetInfo] = []
stats: GCSimCharacterStats = GCSimCharacterStats()
params: List[str] = []
@validator("character")
def validate_character(cls, v):
if v not in AVAILABLE_CHARACTERS or v not in CHARACTER_ALIASES:
raise ValueError(f"Not supported character: {v}")
return CHARACTER_ALIASES[v]
@property
def char(self) -> str:
return self.character
@property
def char_line(self) -> str:
return (
" ".join(
filter(
lambda w: w,
[
f"{self.char}",
"char",
f"lvl={self.level}/{self.max_level}",
f"cons={self.constellation}",
f"start_hp={self.start_hp}" if self.start_hp is not None else "",
f"talent={','.join(str(t) for t in self.talent)}",
f"+params=[{','.join(self.params)}] " if self.params else "",
],
)
)
+ ";"
)
@property
def weapon_line(self) -> str:
return (
" ".join(
filter(
lambda w: w,
[
f"{self.char}",
f'add weapon="{self.weapon_info.weapon}"',
f"refine={self.weapon_info.refinement}",
f"lvl={self.weapon_info.level}/{self.weapon_info.max_level}",
f"+params=[{','.join(self.weapon_info.params)}] " if self.weapon_info.params else "",
],
)
)
+ ";"
)
@property
def set_line(self) -> str:
return "\n".join(
" ".join(
filter(
lambda w: w,
[
f"{self.char}",
f'add set="{set_info.set}"',
f"count={4 if set_info.count >= 4 else 2}",
f"+params=[{','.join(set_info.params)}] " if set_info.params else "",
],
)
)
+ ";"
for set_info in self.set_info
# NOTE: 祭*系列似乎并不支持
if set_info.count > 1
)
@property
def stats_line(self) -> str:
if all(value == 0 for _, value in self.stats):
return ""
return (
f"{self.char} add stats "
+ " ".join(
[
f"{stat.replace('_PERCENT', '%').lower()}={value:.4f}"
if stat.endswith("_PERCENT") or stat in {"CR", "CD", "ER"}
else f"{stat.lower()}={value:.2f}"
for stat, value in iter(self.stats)
if value > 0
]
)
+ ";"
)
def __str__(self) -> str:
return "\n".join([self.char_line, self.weapon_line, self.set_line, self.stats_line])
class GCSimTarget(BaseModel):
level: int = 100
resist: float = 0.1
position: Tuple[str, str] = ("0", "0")
interval: List[int] = []
radius: Optional[float] = None
hp: Optional[int] = None
amount: Optional[int] = None
particle_threshold: Optional[int] = None
particle_drop_count: Optional[int] = None
others: Dict[str, Any] = {}
def __str__(self) -> str:
return (
" ".join(
filter(
lambda w: w,
[
f"target lvl={self.level} resist={self.resist} ",
f"pos={','.join(self.position)}",
f"radius={self.radius}" if self.radius is not None else "",
f"hp={self.hp}" if self.hp is not None else "",
f"amount={self.amount}" if self.amount is not None else "",
f"interval={','.join(str(i) for i in self.interval)}" if self.interval else "",
f"particle_threshold={self.particle_threshold}" if self.particle_threshold is not None else "",
f"particle_drop_count={self.particle_drop_count}"
if self.particle_drop_count is not None
else "",
" ".join([f"{k}={v}" for k, v in self.others.items()]),
],
)
)
+ ";"
)
class GCSimEnergySettings(BaseModel):
intervals: List[int] = [480, 720]
amount: int = 1
def __str__(self) -> str:
return f"energy every interval={','.join(str(i) for i in self.intervals)} amount={self.amount};"
class GCSim(BaseModel):
options: Optional[str] = None
characters: List[GCSimCharacterInfo] = []
targets: List[GCSimTarget] = [GCSimTarget()]
energy_settings: Optional[GCSimEnergySettings] = None
# TODO: Do we even want this?
hurt_settings: Optional[str] = None
active_character: Optional[GCSimCharacter] = None
script_lines: List[str] = []
def __str__(self) -> str:
line = ""
if self.options:
line += f"{self.options};\n"
line += "\n".join([str(c) for c in self.characters])
line += "\n"
line += "\n".join([str(t) for t in self.targets])
line += "\n"
if self.energy_settings:
line += f"{self.energy_settings}\n"
if self.active_character:
line += f"active {self.active_character};\n"
else:
line += f"active {self.characters[0].char};\n"
line += "\n".join(self.script_lines)
line += "\n"
return line

View File

@ -0,0 +1,31 @@
from typing import Dict, Any
from utils.const import PROJECT_ROOT
try:
import ujson as jsonlib
except ImportError:
import json as jsonlib
METADATA_PATH = PROJECT_ROOT.joinpath("metadata").joinpath("data")
class Metadata:
_instance: "Metadata" = None
weapon_metadata: Dict[str, Any] = {}
artifacts_metadata: Dict[str, Any] = {}
characters_metadata: Dict[str, Any] = {}
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance.reload_assets()
return cls._instance
def reload_assets(self) -> None:
self.__load_assets_data()
def __load_assets_data(self) -> None:
self.weapon_metadata = jsonlib.loads(METADATA_PATH.joinpath("weapon.json").read_text(encoding="utf-8"))
self.artifacts_metadata = jsonlib.loads(METADATA_PATH.joinpath("reliquary.json").read_text(encoding="utf-8"))
self.characters_metadata = jsonlib.loads(METADATA_PATH.joinpath("avatar.json").read_text(encoding="utf-8"))

View File

@ -32,6 +32,7 @@ from core.services.players import PlayersService
from core.services.template.services import TemplateService from core.services.template.services import TemplateService
from metadata.shortname import roleToName, idToName from metadata.shortname import roleToName, idToName
from modules.apihelper.client.components.remote import Remote from modules.apihelper.client.components.remote import Remote
from modules.gcsim.file import PlayerGCSimScripts
from modules.playercards.file import PlayerCardsFile from modules.playercards.file import PlayerCardsFile
from modules.playercards.helpers import ArtifactStatsTheory from modules.playercards.helpers import ArtifactStatsTheory
from utils.enkanetwork import RedisCache, EnkaNetworkAPI from utils.enkanetwork import RedisCache, EnkaNetworkAPI
@ -78,6 +79,7 @@ class PlayerCards(Plugin):
self.client = EnkaNetworkAPI(lang="chs", user_agent=config.enka_network_api_agent, cache=False) self.client = EnkaNetworkAPI(lang="chs", user_agent=config.enka_network_api_agent, cache=False)
self.cache = RedisCache(redis.client, key="plugin:player_cards:enka_network", ex=60) self.cache = RedisCache(redis.client, key="plugin:player_cards:enka_network", ex=60)
self.player_cards_file = PlayerCardsFile() self.player_cards_file = PlayerCardsFile()
self.player_gcsim_scripts = PlayerGCSimScripts()
self.assets_service = assets_service self.assets_service = assets_service
self.template_service = template_service self.template_service = template_service
self.kitsune: Optional[str] = None self.kitsune: Optional[str] = None
@ -210,15 +212,7 @@ class PlayerCards(Plugin):
self.kitsune = reply_message.photo[-1].file_id self.kitsune = reply_message.photo[-1].file_id
return return
enka_response = EnkaNetworkResponse.parse_obj(copy.deepcopy(original_data)) enka_response = EnkaNetworkResponse.parse_obj(copy.deepcopy(original_data))
if character_name is not None: if character_name is None:
logger.info(
"用户 %s[%s] 角色卡片查询命令请求 || character_name[%s] uid[%s]",
user.full_name,
user.id,
character_name,
uid,
)
else:
logger.info("用户 %s[%s] 角色卡片查询命令请求", user.full_name, user.id) logger.info("用户 %s[%s] 角色卡片查询命令请求", user.full_name, user.id)
ttl = await self.cache.ttl(uid) ttl = await self.cache.ttl(uid)
if enka_response.characters is None or len(enka_response.characters) == 0: if enka_response.characters is None or len(enka_response.characters) == 0:
@ -244,6 +238,14 @@ class PlayerCards(Plugin):
if reply_message.photo: if reply_message.photo:
self.kitsune = reply_message.photo[-1].file_id self.kitsune = reply_message.photo[-1].file_id
return return
logger.info(
"用户 %s[%s] 角色卡片查询命令请求 || character_name[%s] uid[%s]",
user.full_name,
user.id,
character_name,
uid,
)
for characters in enka_response.characters: for characters in enka_response.characters:
if characters.name == character_name: if characters.name == character_name:
break break
@ -300,6 +302,7 @@ class PlayerCards(Plugin):
await callback_query.answer("请先将角色加入到角色展柜并允许查看角色详情后再使用此功能,如果已经添加了角色,请等待角色数据更新后重试", show_alert=True) await callback_query.answer("请先将角色加入到角色展柜并允许查看角色详情后再使用此功能,如果已经添加了角色,请等待角色数据更新后重试", show_alert=True)
await message.delete() await message.delete()
return return
self.player_gcsim_scripts.remove_fits(uid)
await callback_query.answer(text="正在从 Enka.Network 获取角色列表 请不要重复点击按钮") await callback_query.answer(text="正在从 Enka.Network 获取角色列表 请不要重复点击按钮")
buttons = self.gen_button(data, user.id, uid, update_button=False) buttons = self.gen_button(data, user.id, uid, update_button=False)
render_data = await self.parse_holder_data(data) render_data = await self.parse_holder_data(data)

View File

@ -44,6 +44,7 @@ pillow = "^10.0.1"
playwright = "^1.28.0" playwright = "^1.28.0"
aiosqlite = { extras = ["sqlite"], version = "^0.19.0" } aiosqlite = { extras = ["sqlite"], version = "^0.19.0" }
simnet = { git = "https://github.com/PaiGramTeam/SIMNet" } simnet = { git = "https://github.com/PaiGramTeam/SIMNet" }
gcsim-pypi = "^2.8.2.10"
[tool.poetry.extras] [tool.poetry.extras]
pyro = ["Pyrogram", "TgCrypto"] pyro = ["Pyrogram", "TgCrypto"]

View File

@ -60,6 +60,13 @@
</div> </div>
<div class="command-description">角色卡片</div> <div class="command-description">角色卡片</div>
</div> </div>
<div class="command">
<div class="command-name">
/gcsim
<i class="fa fa-user-circle-o ml-2"></i>
</div>
<div class="command-description">gcsim 组队伤害计算</div>
</div>
<div class="command"> <div class="command">
<div class="command-name"> <div class="command-name">
/avatar_board /avatar_board

View File

@ -0,0 +1,27 @@
<div class="flex w-full flex-row justify-between space-x-6 px-8">
{% for character in character_details %}
<div class="flex w-1/{{ character_details | length }}">
<div class="h-full w-full rounded-xl relative">
{% if extra[character.name].constellation > 0 %}
{% set bg = ['blue','blue', 'green','green', 'red', 'red'][extra[character.name].constellation - 1] %}
<div
class="absolute top-0 right-0 rounded-tr-lg rounded-bl-lg px-1 bg-{{ bg }}-400 ">
{{ extra[character.name].constellation }} 命
</div>
{% endif %}
{% if not extra[character.name].owned %}
<div class="absolute top-0 left-0 rounded-tl-lg rounded-br-lg px-1 bg-pink-400 text-white">
试用
</div>
{% endif %}
<div class="aspect-square w-full rounded-t-xl"
style="background-image: url('../../background/rarity/half/{{ extra[character.name].rarity }}.png')">
<img src="{{ extra[character.name].icon }}" alt=""/>
</div>
<div class="flex h-6 items-center justify-center rounded-b-xl bg-gray-200">
<span class="text-md"> Lv {{ character.level }}</span>
</div>
</div>
</div>
{% endfor %}
</div>

View File

@ -0,0 +1,28 @@
{% for p in ["eps", "hps", "shp"] %}
<div class="flex flex-col h-full w-1/3 items-start justify-center rounded-xl bg-slate-600 p-4">
<div class="text-base text-gray-400">
{% if p == "eps" %}
每秒能量 (EPS)
{% elif p == "hps" %}
每秒治疗 (HPS)
{% elif p == "shp" %}
每秒盾量 (SHP)
{% endif %}
</div>
<div class="mb-1 text-3xl font-bold text-white">{{ statistics[p].mean | int }}</div>
<div class="flex w-full justify-between text-sm">
<div>
<div class="text-gray-400">min</div>
<div class="font-semibold text-gray-200">{{ statistics[p].min | int }}</div>
</div>
<div>
<div class="text-gray-400">max</div>
<div class="font-semibold text-gray-200">{{ statistics[p].max | int }}</div>
</div>
<div>
<div class="text-gray-400">std</div>
<div class="font-semibold text-gray-200">{{ statistics[p].sd | int }}</div>
</div>
</div>
</div>
{% endfor %}

View File

@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<title>Title</title>
<script src="../../js/tailwindcss-3.1.8.js"></script>
<script src="../../js/echarts.min.js"></script>
<link type="text/css" href="../../styles/public.css" rel="stylesheet"/>
</head>
<body>
<div class="mx-auto flex w-[48rem] flex-col space-y-2 bg-gray-400 px-2 py-2">
<!-- START TEAM/DPS summary row -->
<div class="flex h-auto flex-col items-start justify-center space-y-4 rounded-lg bg-slate-600 py-6">
<!-- START character row -->
{% include "genshin/gcsim/characters.jinja2" %}
<!-- END character row -->
<!-- START summary DPS row -->
{% include "genshin/gcsim/summary.jinja2" %}
<!-- END summary DPS row -->
</div>
<!-- END TEAM/DPS summary row -->
<!-- START DPS distribution row -->
<div class="flex flex-row items-start justify-center space-x-2 rounded-lg">
<div class="flex h-56 w-1/2 items-center justify-center rounded-xl bg-slate-600" id="dps_by_character">
</div>
<div class="flex h-56 w-1/2 items-center justify-center rounded-xl bg-slate-600" id="dps_by_element">
</div>
<script type="text/javascript">
var data = JSON.parse('{{ extra | tojson }}')
// Initialize the echarts instance based on the prepared dom
var dpsByCharacter = echarts.init(document.getElementById('dps_by_character'));
var dpsByElement = echarts.init(document.getElementById('dps_by_element'));
dpsByCharacter.setOption({
series: [
{
type: 'pie',
data: data["character_dps"],
label: {
color: 'white'
}
}
],
animation: false
});
dpsByElement.setOption({
series: [
{
type: 'pie',
data: data["element_dps"],
label: {
color: 'white'
}
}
],
animation: false
});
</script>
</div>
<!-- END DPS distribution row -->
<!-- START Energy/Heal/Shield row -->
<div class="flex flex-row items-start justify-center space-x-2 rounded-lg">
{% include "genshin/gcsim/other_summary.jinja2" %}
</div>
<!-- END Energy/Heal/Shield row -->
<!-- START DPS line chart row -->
<div class="flex h-80 w-full flex-row items-start justify-center rounded-lg bg-slate-600" id="damage_overtime">
</div>
<script type="text/javascript">
var data = JSON.parse('{{ extra | tojson }}')
// Initialize the echarts instance based on the prepared dom
var damageOvertime = echarts.init(document.getElementById('damage_overtime'));
damageOvertime.setOption({
xAxis: {
data: data["damage"]["xAxis"],
axisLabel: {
color: 'white',
}
},
yAxis: {
axisLabel: {
color: 'white'
}
},
legend: {
textStyle: {
color: 'white'
}
},
series: data["damage"]["series"],
animation: false
});
</script>
<!-- END DPS line chart row -->
</div>
</body>
</html>

View File

@ -0,0 +1,8 @@
<div class="bg-grey-500 flex h-auto w-full flex-col items-center justify-center">
<div class="flex flex-col items-center justify-center">
<span class="text-xl text-yellow-400">{{ script_key }}</span>
</div>
<div class="flex flex-col items-center justify-center">
<span class="mt-2 text-7xl font-bold text-yellow-400">{{ statistics.dps.mean | int }}</span>
</div>
</div>

45
resources/js/echarts.min.js vendored Normal file

File diff suppressed because one or more lines are too long