MibooGram/plugins/genshin/model/converters/gcsim.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

406 lines
17 KiB
Python
Raw Normal View History

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 = float(value)
elif key == "particle_drop_count":
target.particle_drop_count = float(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,
)