From f770960557efd9c8a520ff1b966e02655ac93c74 Mon Sep 17 00:00:00 2001 From: trumully Date: Fri, 12 Apr 2024 18:28:06 +1200 Subject: [PATCH 1/9] initial commit --- src/artipy/analysis/__init__.py | 13 ++++++ src/artipy/analysis/analyse.py | 83 +++++++++++++++++++++++++++++++++ tests/test_analysis.py | 79 +++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 src/artipy/analysis/__init__.py create mode 100644 src/artipy/analysis/analyse.py create mode 100644 tests/test_analysis.py diff --git a/src/artipy/analysis/__init__.py b/src/artipy/analysis/__init__.py new file mode 100644 index 0000000..a22cd71 --- /dev/null +++ b/src/artipy/analysis/__init__.py @@ -0,0 +1,13 @@ +from .analyse import ( + calculate_artifact_crit_value, + calculate_artifact_maximum_roll_value, + calculate_artifact_roll_value, + calculate_substat_roll_value, +) + +__all__ = ( + "calculate_artifact_crit_value", + "calculate_artifact_maximum_roll_value", + "calculate_artifact_roll_value", + "calculate_substat_roll_value", +) diff --git a/src/artipy/analysis/analyse.py b/src/artipy/analysis/analyse.py new file mode 100644 index 0000000..78f78d5 --- /dev/null +++ b/src/artipy/analysis/analyse.py @@ -0,0 +1,83 @@ +from decimal import Decimal + +from artipy.artifacts import Artifact +from artipy.artifacts.upgrade_strategy import UPGRADE_STEP +from artipy.stats import StatType, SubStat +from artipy.stats.utils import possible_substat_values + + +def calculate_substat_roll_value(substat: SubStat) -> Decimal: + """Get the substat roll value. This is a percentage of the current value over the + highest potential value. + + :param substat: The substat to get the roll value for. + :type substat: SubStat + :return: The roll value of the substat. + :rtype: Decimal + """ + stat_value = substat.value + highest_value = max(possible_substat_values(substat.name, substat.rarity)) + + # Convert values to percentage if necessary + if substat.name.is_pct: + stat_value *= 100 + highest_value *= 100 + + return stat_value / highest_value + + +def calculate_artifact_roll_value(artifact: Artifact) -> Decimal: + """Get the current roll value of a given artifact. This is the sum of the roll + values for all substats. + + :param artifact: The artifact to get the roll value for. + :type artifact: Artifact + :return: The roll value of the artifact roll. + :rtype: Decimal + """ + return Decimal( + sum( + calculate_substat_roll_value(substat) for substat in artifact.get_substats() + ) + ) + + +def calculate_artifact_maximum_roll_value(artifact: Artifact) -> Decimal: + """Get the maximum roll value of a given artifact. This differs from the regular + roll value by assuming remaining rolls are the highest possible value (i.e 100% + roll value). + + :param artifact: The artifact to get the maximum roll value for. + :type artifact: Artifact + :return: The maximum roll value of the artifact roll. + :rtype: Decimal + """ + artifact_max_level = artifact.get_rarity() * 4 + remaining_rolls = (artifact_max_level - artifact.get_level()) // UPGRADE_STEP + return Decimal(calculate_artifact_roll_value(artifact) + remaining_rolls) + + +def calculate_artifact_crit_value(artifact: Artifact) -> Decimal: + """Get the crit value of a given artifact. This is the crit damage value plus + the two times the crit rate. + + :param artifact: The artifact to get the crit value for. + :type artifact: Artifact + :return: The crit value of the artifact. + :rtype: Decimal + """ + crit_dmg = sum( + [ + substat.value + for substat in artifact.get_substats() + if substat.name == StatType.CRIT_DMG + ] + ) + crit_rate = sum( + [ + substat.value + for substat in artifact.get_substats() + if substat.name == StatType.CRIT_RATE + ] + ) + return Decimal((crit_dmg + crit_rate * 2) * 100) diff --git a/tests/test_analysis.py b/tests/test_analysis.py new file mode 100644 index 0000000..75a76b1 --- /dev/null +++ b/tests/test_analysis.py @@ -0,0 +1,79 @@ +import math +from decimal import Decimal + +import pytest + +from artipy.analysis import ( + calculate_artifact_crit_value, + calculate_artifact_maximum_roll_value, + calculate_artifact_roll_value, + calculate_substat_roll_value, +) +from artipy.artifacts import Artifact, ArtifactBuilder +from artipy.stats import StatType, SubStat + + +@pytest.fixture +def substat() -> SubStat: + """This fixture creates a substat with the following properties: + roll value = ~1.9 (190%) + rolls = 2 + + :return: A substat object + :rtype: SubStat + """ + return SubStat(StatType.HP, 568, 5) + + +@pytest.fixture +def artifact() -> Artifact: + """This fixture creates an artifact with the following properties: + roll value = ~4.8 (480%) + max roll value = ~7.8 (780%) + crit value = ~7.8 + rolls = 5 + + :return: An artifact object + :rtype: Artifact + """ + return ( + ArtifactBuilder() + .with_mainstat(StatType.ATK_PERCENT, 0.228) + .with_substats( + [ + (StatType.ATK, 19), + (StatType.CRIT_RATE, 0.039), + (StatType.HP_PERCENT, 0.053), + (StatType.HP, 568), + ] + ) + .with_level(8) + .with_rarity(5) + .with_set("Gladiator's Finale") + .with_slot("sands") + .build() + ) + + +def test_calculate_substat_roll_value(substat) -> None: + """This test verifies the roll_value of a given substat""" + roll_value = calculate_substat_roll_value(substat) + assert math.isclose(roll_value, Decimal(1.9), rel_tol=1e-2) + + +def test_calcualte_artifact_roll_value(artifact) -> None: + """This test verifies the roll value of the artifact""" + roll_value = calculate_artifact_roll_value(artifact) + assert math.isclose(roll_value, Decimal(4.8), rel_tol=1e-2) + + +def test_calculate_artifact_maximum_roll_value(artifact) -> None: + """This test verifies the maximum roll value of the artifact""" + maximum_roll_value = calculate_artifact_maximum_roll_value(artifact) + assert math.isclose(maximum_roll_value, Decimal(7.8), rel_tol=1e-2) + + +def test_calculate_artifact_crit_value(artifact) -> None: + """This test verifies the crit value of the artifact""" + crit_value = calculate_artifact_crit_value(artifact) + assert math.isclose(crit_value, Decimal(7.8), rel_tol=1e-2) From d7316687bed70f2cb83dc5b321c10bddcea50871 Mon Sep 17 00:00:00 2001 From: trumully Date: Fri, 12 Apr 2024 23:03:07 +1200 Subject: [PATCH 2/9] chore: bump version to 0.3.0.alpha --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8313e78..35c739b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "artipy" -version = "0.2.3" +version = "0.3.0.alpha" description = "" authors = [ "Truman Mulholland " ] From 586a763a4c2809917ba2f3dc5eae38ec2847a85f Mon Sep 17 00:00:00 2001 From: trumully Date: Fri, 12 Apr 2024 23:38:52 +1200 Subject: [PATCH 3/9] feat: calculate_substat_rolls method --- src/artipy/analysis/__init__.py | 2 ++ src/artipy/analysis/analyse.py | 16 +++++++++++++++- src/artipy/stats/substat.py | 4 +--- src/artipy/stats/utils.py | 8 ++++---- tests/test_analysis.py | 10 ++++++++++ 5 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/artipy/analysis/__init__.py b/src/artipy/analysis/__init__.py index a22cd71..57ecff3 100644 --- a/src/artipy/analysis/__init__.py +++ b/src/artipy/analysis/__init__.py @@ -3,6 +3,7 @@ calculate_artifact_maximum_roll_value, calculate_artifact_roll_value, calculate_substat_roll_value, + calculate_substat_rolls, ) __all__ = ( @@ -10,4 +11,5 @@ "calculate_artifact_maximum_roll_value", "calculate_artifact_roll_value", "calculate_substat_roll_value", + "calculate_substat_rolls", ) diff --git a/src/artipy/analysis/analyse.py b/src/artipy/analysis/analyse.py index 78f78d5..93df53a 100644 --- a/src/artipy/analysis/analyse.py +++ b/src/artipy/analysis/analyse.py @@ -1,4 +1,5 @@ from decimal import Decimal +from math import ceil from artipy.artifacts import Artifact from artipy.artifacts.upgrade_strategy import UPGRADE_STEP @@ -18,7 +19,6 @@ def calculate_substat_roll_value(substat: SubStat) -> Decimal: stat_value = substat.value highest_value = max(possible_substat_values(substat.name, substat.rarity)) - # Convert values to percentage if necessary if substat.name.is_pct: stat_value *= 100 highest_value *= 100 @@ -26,6 +26,20 @@ def calculate_substat_roll_value(substat: SubStat) -> Decimal: return stat_value / highest_value +def calculate_substat_rolls(substat: SubStat) -> int: + """Calculate the number of rolls a substat has gone through. This is the difference + between the current value and the average value divided by the average value. + + :param substat: The substat to get the rolls for. + :type substat: SubStat + :return: The number of rolls the substat has gone through. + :rtype: int + """ + possible_rolls = possible_substat_values(substat.name, substat.rarity) + average_roll = Decimal(sum(possible_rolls) / len(possible_rolls)) + return ceil((substat.value - average_roll) / average_roll) + + def calculate_artifact_roll_value(artifact: Artifact) -> Decimal: """Get the current roll value of a given artifact. This is the sum of the roll values for all substats. diff --git a/src/artipy/stats/substat.py b/src/artipy/stats/substat.py index a0acc74..632091b 100644 --- a/src/artipy/stats/substat.py +++ b/src/artipy/stats/substat.py @@ -1,5 +1,5 @@ import random -from dataclasses import dataclass, field +from dataclasses import dataclass from decimal import Decimal from .stats import VALID_SUBSTATS, Stat, StatType @@ -11,7 +11,6 @@ class SubStat(Stat): """Substat dataclass for a Genshin Impact artifact.""" rarity: int = 5 - rolls: int = field(default=0, init=False) def roll(self) -> Decimal: """Roll a random value for the substat. This is used when initially creating @@ -21,7 +20,6 @@ def roll(self) -> Decimal: :rtype: Decimal """ values = possible_values(self.name, self.rarity) - self.rolls += 1 return random.choice(values) def upgrade(self) -> None: diff --git a/src/artipy/stats/utils.py b/src/artipy/stats/utils.py index c251e2b..073211e 100644 --- a/src/artipy/stats/utils.py +++ b/src/artipy/stats/utils.py @@ -38,12 +38,12 @@ def possible_mainstat_values(stat_type: StatType, rarity: int) -> tuple[Decimal, @lru_cache(maxsize=None) -def possible_substat_values(stat_type: StatType, rarity: int) -> tuple[Decimal, ...]: +def possible_substat_values(stat: StatType, rarity: int) -> tuple[Decimal, ...]: """Get the possible values for a substat based on the stat type and rarity. Map the values to Decimal. - :param stat_type: The stat to get the values for. - :type stat_type: StatType + :param stat: The stat to get the values for. + :type stat: StatType :param rarity: The rarity of the artifact. :type rarity: int :return: The possible values for the substat. @@ -52,7 +52,7 @@ def possible_substat_values(stat_type: StatType, rarity: int) -> tuple[Decimal, data = [ d for d in SUBSTAT_DATA - if d.depotId == int(f"{rarity}01") and d.propType == stat_type + if d.depotId == int(f"{rarity}01") and d.propType == stat ] sorted_data = sorted(data, key=attrgetter("propValue")) return map_to_decimal((d.propValue for d in sorted_data)) diff --git a/tests/test_analysis.py b/tests/test_analysis.py index 75a76b1..c0808b1 100644 --- a/tests/test_analysis.py +++ b/tests/test_analysis.py @@ -8,6 +8,7 @@ calculate_artifact_maximum_roll_value, calculate_artifact_roll_value, calculate_substat_roll_value, + calculate_substat_rolls, ) from artipy.artifacts import Artifact, ArtifactBuilder from artipy.stats import StatType, SubStat @@ -61,6 +62,15 @@ def test_calculate_substat_roll_value(substat) -> None: assert math.isclose(roll_value, Decimal(1.9), rel_tol=1e-2) +def test_calculate_substat_rolls(substat, artifact) -> None: + """This test verifies the number of rolls of a given substat""" + assert calculate_substat_rolls(substat) == 2 + + expected_rolls = (1, 1, 1, 2) + for substat, roll in zip(artifact.get_substats(), expected_rolls): + assert calculate_substat_rolls(substat) == roll + + def test_calcualte_artifact_roll_value(artifact) -> None: """This test verifies the roll value of the artifact""" roll_value = calculate_artifact_roll_value(artifact) From 89d8d2aed093c89864f25532b77e9057c2533937 Mon Sep 17 00:00:00 2001 From: trumully Date: Fri, 12 Apr 2024 23:40:42 +1200 Subject: [PATCH 4/9] chore: fix versioning in pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 35c739b..c758e98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "artipy" -version = "0.3.0.alpha" +version = "0.3.0-alpha" description = "" authors = [ "Truman Mulholland " ] From 9bf0acf5d67ec874b1c8f0472f69cc9c4174677b Mon Sep 17 00:00:00 2001 From: trumully Date: Sat, 13 Apr 2024 15:18:08 +1200 Subject: [PATCH 5/9] feat!: add plots to analysis module BREAKING CHANGES: pandas, plotly new dependencies --- poetry.lock | 194 ++++++++++++++++++++++++++++++- pyproject.toml | 2 + src/artipy/analysis/analyse.py | 95 ++++++++++++--- src/artipy/analysis/plots.py | 107 +++++++++++++++++ src/artipy/analysis/simulate.py | 52 +++++++++ src/artipy/artifacts/artifact.py | 27 +++-- src/artipy/artifacts/builder.py | 37 +++--- src/artipy/stats/__init__.py | 12 +- src/artipy/stats/mainstat.py | 4 +- src/artipy/stats/stats.py | 35 ++++++ src/artipy/stats/utils.py | 21 ++-- tests/test_artifacts.py | 5 - tests/test_stats.py | 2 +- 13 files changed, 529 insertions(+), 64 deletions(-) create mode 100644 src/artipy/analysis/plots.py create mode 100644 src/artipy/analysis/simulate.py diff --git a/poetry.lock b/poetry.lock index 99659a2..8b534df 100644 --- a/poetry.lock +++ b/poetry.lock @@ -486,6 +486,51 @@ files = [ [package.dependencies] setuptools = "*" +[[package]] +name = "numpy" +version = "1.26.4" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"}, + {file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"}, + {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4"}, + {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f"}, + {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a"}, + {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2"}, + {file = "numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07"}, + {file = "numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5"}, + {file = "numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71"}, + {file = "numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef"}, + {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e"}, + {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5"}, + {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a"}, + {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a"}, + {file = "numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20"}, + {file = "numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2"}, + {file = "numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218"}, + {file = "numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b"}, + {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b"}, + {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed"}, + {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a"}, + {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0"}, + {file = "numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110"}, + {file = "numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818"}, + {file = "numpy-1.26.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7349ab0fa0c429c82442a27a9673fc802ffdb7c7775fad780226cb234965e53c"}, + {file = "numpy-1.26.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:52b8b60467cd7dd1e9ed082188b4e6bb35aa5cdd01777621a1658910745b90be"}, + {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5241e0a80d808d70546c697135da2c613f30e28251ff8307eb72ba696945764"}, + {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f870204a840a60da0b12273ef34f7051e98c3b5961b61b0c2c1be6dfd64fbcd3"}, + {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:679b0076f67ecc0138fd2ede3a8fd196dddc2ad3254069bcb9faf9a79b1cebcd"}, + {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:47711010ad8555514b434df65f7d7b076bb8261df1ca9bb78f53d3b2db02e95c"}, + {file = "numpy-1.26.4-cp39-cp39-win32.whl", hash = "sha256:a354325ee03388678242a4d7ebcd08b5c727033fcff3b2f536aea978e15ee9e6"}, + {file = "numpy-1.26.4-cp39-cp39-win_amd64.whl", hash = "sha256:3373d5d70a5fe74a2c1bb6d2cfd9609ecf686d47a2d7b1d37a8f3b6bf6003aea"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:afedb719a9dcfc7eaf2287b839d8198e06dcd4cb5d276a3df279231138e83d30"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95a7476c59002f2f6c590b9b7b998306fba6a5aa646b1e22ddfeaf8f78c3a29c"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7e50d0a0cc3189f9cb0aeb3a6a6af18c16f59f004b866cd2be1c14b36134a4a0"}, + {file = "numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010"}, +] + [[package]] name = "packaging" version = "24.0" @@ -497,6 +542,77 @@ files = [ {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, ] +[[package]] +name = "pandas" +version = "2.2.2" +description = "Powerful data structures for data analysis, time series, and statistics" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pandas-2.2.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:90c6fca2acf139569e74e8781709dccb6fe25940488755716d1d354d6bc58bce"}, + {file = "pandas-2.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4abfe0be0d7221be4f12552995e58723c7422c80a659da13ca382697de830c08"}, + {file = "pandas-2.2.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8635c16bf3d99040fdf3ca3db669a7250ddf49c55dc4aa8fe0ae0fa8d6dcc1f0"}, + {file = "pandas-2.2.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:40ae1dffb3967a52203105a077415a86044a2bea011b5f321c6aa64b379a3f51"}, + {file = "pandas-2.2.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8e5a0b00e1e56a842f922e7fae8ae4077aee4af0acb5ae3622bd4b4c30aedf99"}, + {file = "pandas-2.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:ddf818e4e6c7c6f4f7c8a12709696d193976b591cc7dc50588d3d1a6b5dc8772"}, + {file = "pandas-2.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:696039430f7a562b74fa45f540aca068ea85fa34c244d0deee539cb6d70aa288"}, + {file = "pandas-2.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8e90497254aacacbc4ea6ae5e7a8cd75629d6ad2b30025a4a8b09aa4faf55151"}, + {file = "pandas-2.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58b84b91b0b9f4bafac2a0ac55002280c094dfc6402402332c0913a59654ab2b"}, + {file = "pandas-2.2.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2123dc9ad6a814bcdea0f099885276b31b24f7edf40f6cdbc0912672e22eee"}, + {file = "pandas-2.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:2925720037f06e89af896c70bca73459d7e6a4be96f9de79e2d440bd499fe0db"}, + {file = "pandas-2.2.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0cace394b6ea70c01ca1595f839cf193df35d1575986e484ad35c4aeae7266c1"}, + {file = "pandas-2.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:873d13d177501a28b2756375d59816c365e42ed8417b41665f346289adc68d24"}, + {file = "pandas-2.2.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:9dfde2a0ddef507a631dc9dc4af6a9489d5e2e740e226ad426a05cabfbd7c8ef"}, + {file = "pandas-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e9b79011ff7a0f4b1d6da6a61aa1aa604fb312d6647de5bad20013682d1429ce"}, + {file = "pandas-2.2.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cb51fe389360f3b5a4d57dbd2848a5f033350336ca3b340d1c53a1fad33bcad"}, + {file = "pandas-2.2.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eee3a87076c0756de40b05c5e9a6069c035ba43e8dd71c379e68cab2c20f16ad"}, + {file = "pandas-2.2.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3e374f59e440d4ab45ca2fffde54b81ac3834cf5ae2cdfa69c90bc03bde04d76"}, + {file = "pandas-2.2.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:43498c0bdb43d55cb162cdc8c06fac328ccb5d2eabe3cadeb3529ae6f0517c32"}, + {file = "pandas-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:d187d355ecec3629624fccb01d104da7d7f391db0311145817525281e2804d23"}, + {file = "pandas-2.2.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0ca6377b8fca51815f382bd0b697a0814c8bda55115678cbc94c30aacbb6eff2"}, + {file = "pandas-2.2.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:001910ad31abc7bf06f49dcc903755d2f7f3a9186c0c040b827e522e9cef0863"}, + {file = "pandas-2.2.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66b479b0bd07204e37583c191535505410daa8df638fd8e75ae1b383851fe921"}, + {file = "pandas-2.2.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a77e9d1c386196879aa5eb712e77461aaee433e54c68cf253053a73b7e49c33a"}, + {file = "pandas-2.2.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:92fd6b027924a7e178ac202cfbe25e53368db90d56872d20ffae94b96c7acc57"}, + {file = "pandas-2.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:640cef9aa381b60e296db324337a554aeeb883ead99dc8f6c18e81a93942f5f4"}, + {file = "pandas-2.2.2.tar.gz", hash = "sha256:9e79019aba43cb4fda9e4d983f8e88ca0373adbb697ae9c6c43093218de28b54"}, +] + +[package.dependencies] +numpy = [ + {version = ">=1.22.4", markers = "python_version < \"3.11\""}, + {version = ">=1.23.2", markers = "python_version == \"3.11\""}, + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, +] +python-dateutil = ">=2.8.2" +pytz = ">=2020.1" +tzdata = ">=2022.7" + +[package.extras] +all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)", "beautifulsoup4 (>=4.11.2)", "bottleneck (>=1.3.6)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=2022.12.0)", "fsspec (>=2022.11.0)", "gcsfs (>=2022.11.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.9.2)", "matplotlib (>=3.6.3)", "numba (>=0.56.4)", "numexpr (>=2.8.4)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "pandas-gbq (>=0.19.0)", "psycopg2 (>=2.9.6)", "pyarrow (>=10.0.1)", "pymysql (>=1.0.2)", "pyreadstat (>=1.2.0)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "qtpy (>=2.3.0)", "s3fs (>=2022.11.0)", "scipy (>=1.10.0)", "tables (>=3.8.0)", "tabulate (>=0.9.0)", "xarray (>=2022.12.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)", "zstandard (>=0.19.0)"] +aws = ["s3fs (>=2022.11.0)"] +clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.3.0)"] +compression = ["zstandard (>=0.19.0)"] +computation = ["scipy (>=1.10.0)", "xarray (>=2022.12.0)"] +consortium-standard = ["dataframe-api-compat (>=0.1.7)"] +excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)"] +feather = ["pyarrow (>=10.0.1)"] +fss = ["fsspec (>=2022.11.0)"] +gcp = ["gcsfs (>=2022.11.0)", "pandas-gbq (>=0.19.0)"] +hdf5 = ["tables (>=3.8.0)"] +html = ["beautifulsoup4 (>=4.11.2)", "html5lib (>=1.1)", "lxml (>=4.9.2)"] +mysql = ["SQLAlchemy (>=2.0.0)", "pymysql (>=1.0.2)"] +output-formatting = ["jinja2 (>=3.1.2)", "tabulate (>=0.9.0)"] +parquet = ["pyarrow (>=10.0.1)"] +performance = ["bottleneck (>=1.3.6)", "numba (>=0.56.4)", "numexpr (>=2.8.4)"] +plot = ["matplotlib (>=3.6.3)"] +postgresql = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "psycopg2 (>=2.9.6)"] +pyarrow = ["pyarrow (>=10.0.1)"] +spss = ["pyreadstat (>=1.2.0)"] +sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)"] +test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"] +xml = ["lxml (>=4.9.2)"] + [[package]] name = "platformdirs" version = "4.2.0" @@ -512,6 +628,21 @@ files = [ docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] +[[package]] +name = "plotly" +version = "5.20.0" +description = "An open-source, interactive data visualization library for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "plotly-5.20.0-py3-none-any.whl", hash = "sha256:837a9c8aa90f2c0a2f0d747b82544d014dc2a2bdde967b5bb1da25b53932d1a9"}, + {file = "plotly-5.20.0.tar.gz", hash = "sha256:bf901c805d22032cfa534b2ff7c5aa6b0659e037f19ec1e0cca7f585918b5c89"}, +] + +[package.dependencies] +packaging = "*" +tenacity = ">=6.2.0" + [[package]] name = "pluggy" version = "1.4.0" @@ -692,6 +823,20 @@ tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + [[package]] name = "python-gitlab" version = "4.4.0" @@ -742,6 +887,17 @@ docs = ["Sphinx (>=6.0,<7.0)", "furo (>=2023.3,<2024.0)", "sphinx-autobuild (==2 mypy = ["mypy (==1.9.0)", "types-requests (>=2.31.0,<2.32.0)"] test = ["coverage[toml] (>=7.0,<8.0)", "pytest (>=7.0,<8.0)", "pytest-clarity (>=1.0,<2.0)", "pytest-cov (>=5.0,<6.0)", "pytest-env (>=1.0,<2.0)", "pytest-lazy-fixture (>=0.6.3,<0.7.0)", "pytest-mock (>=3.0,<4.0)", "pytest-pretty (>=1.2,<2.0)", "pytest-xdist (>=3.0,<4.0)", "requests-mock (>=1.10,<2.0)", "responses (>=0.25.0,<0.26.0)", "types-pytest-lazy-fixture (>=0.6.3,<0.7.0)"] +[[package]] +name = "pytz" +version = "2024.1" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +files = [ + {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, + {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, +] + [[package]] name = "pyyaml" version = "6.0.1" @@ -882,6 +1038,17 @@ files = [ {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, ] +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + [[package]] name = "smmap" version = "5.0.1" @@ -893,6 +1060,20 @@ files = [ {file = "smmap-5.0.1.tar.gz", hash = "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62"}, ] +[[package]] +name = "tenacity" +version = "8.2.3" +description = "Retry code until it succeeds" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tenacity-8.2.3-py3-none-any.whl", hash = "sha256:ce510e327a630c9e1beaf17d42e6ffacc88185044ad85cf74c0a8887c6a0f88c"}, + {file = "tenacity-8.2.3.tar.gz", hash = "sha256:5398ef0d78e63f40007c1fb4c0bff96e1911394d2fa8d194f77619c05ff6cc8a"}, +] + +[package.extras] +doc = ["reno", "sphinx", "tornado (>=4.5)"] + [[package]] name = "toml" version = "0.10.2" @@ -937,6 +1118,17 @@ files = [ {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, ] +[[package]] +name = "tzdata" +version = "2024.1" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +files = [ + {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, + {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, +] + [[package]] name = "urllib3" version = "2.2.1" @@ -992,4 +1184,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = ">=3.9,<4.0.0" -content-hash = "2d19b968254c1380763acb2866dc71f414ac4aadedf501722566e5b7dcf8f294" +content-hash = "48d4c945cbebc436fa489d583dc9f3cad21b6635abd325a1fa7f73cd0d99f4a0" diff --git a/pyproject.toml b/pyproject.toml index c758e98..304551e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,8 @@ authors = [ "Truman Mulholland " ] [tool.poetry.dependencies] python = ">=3.9,<4.0.0" toml = "^0.10.2" +plotly = "^5.20.0" +pandas = "^2.2.2" [tool.poetry.group.dev.dependencies] pytest = "^8.1.1" diff --git a/src/artipy/analysis/analyse.py b/src/artipy/analysis/analyse.py index 93df53a..2035d42 100644 --- a/src/artipy/analysis/analyse.py +++ b/src/artipy/analysis/analyse.py @@ -1,11 +1,44 @@ +import itertools +import math from decimal import Decimal -from math import ceil +from enum import StrEnum, auto from artipy.artifacts import Artifact from artipy.artifacts.upgrade_strategy import UPGRADE_STEP from artipy.stats import StatType, SubStat from artipy.stats.utils import possible_substat_values +ROLL_MULTIPLIERS: dict[int, tuple[float, ...]] = { + 1: (0.8, 1.0), + 2: (0.7, 0.85, 1.0), + 3: (0.7, 0.8, 0.9, 1.0), + 4: (0.7, 0.8, 0.9, 1.0), + 5: (0.7, 0.8, 0.9, 1.0), +} + + +class RollMagnitude(StrEnum): + LOW = auto() + MEDIUM = auto() + HIGH = auto() + MAX = auto() + + @property + def magnitude(self) -> Decimal: + if self == RollMagnitude.LOW: + return Decimal("0.7") + elif self == RollMagnitude.MEDIUM: + return Decimal("0.8") + elif self == RollMagnitude.HIGH: + return Decimal("0.9") + return Decimal("1.0") + + @classmethod + def closest(cls, value: Decimal | float | int) -> "RollMagnitude": + return RollMagnitude( + min(cls, key=lambda x: abs(RollMagnitude(x).magnitude - Decimal(value))) + ) + def calculate_substat_roll_value(substat: SubStat) -> Decimal: """Get the substat roll value. This is a percentage of the current value over the @@ -37,7 +70,33 @@ def calculate_substat_rolls(substat: SubStat) -> int: """ possible_rolls = possible_substat_values(substat.name, substat.rarity) average_roll = Decimal(sum(possible_rolls) / len(possible_rolls)) - return ceil((substat.value - average_roll) / average_roll) + return math.ceil((substat.value - average_roll) / average_roll) + + +def calculate_substat_roll_magnitudes(substat: SubStat) -> tuple[RollMagnitude, ...]: + """Get the roll magnitudes for a substat. This is a tuple of the roll magnitudes + for each roll the substat has gone through. + + :param substat: The substat to get the roll magnitudes for. + :type substat: SubStat + :return: The roll magnitudes for the substat. + :rtype: tuple[RollMagnitude] + """ + + def get_magnitude( + values: tuple[Decimal, ...], value_to_index: Decimal + ) -> RollMagnitude: + index = values.index(value_to_index) + return RollMagnitude.closest(ROLL_MULTIPLIERS[substat.rarity][index]) + + possible_rolls = possible_substat_values(substat.name, substat.rarity) + rolls_actual = calculate_substat_rolls(substat) + + combinations = list( + itertools.combinations_with_replacement(possible_rolls, rolls_actual) + ) + combination = min(combinations, key=lambda x: abs(sum(x) - substat.value)) + return tuple(get_magnitude(possible_rolls, value) for value in combination) def calculate_artifact_roll_value(artifact: Artifact) -> Decimal: @@ -80,18 +139,24 @@ def calculate_artifact_crit_value(artifact: Artifact) -> Decimal: :return: The crit value of the artifact. :rtype: Decimal """ - crit_dmg = sum( - [ - substat.value - for substat in artifact.get_substats() - if substat.name == StatType.CRIT_DMG - ] + crit_dmg = ( + sum( + [ + substat.value + for substat in artifact.get_substats() + if substat.name == StatType.CRIT_DMG + ] + ) + * 100 ) - crit_rate = sum( - [ - substat.value - for substat in artifact.get_substats() - if substat.name == StatType.CRIT_RATE - ] + crit_rate = ( + sum( + [ + substat.value + for substat in artifact.get_substats() + if substat.name == StatType.CRIT_RATE + ] + ) + * 100 ) - return Decimal((crit_dmg + crit_rate * 2) * 100) + return Decimal(crit_dmg + crit_rate * 2) diff --git a/src/artipy/analysis/plots.py b/src/artipy/analysis/plots.py new file mode 100644 index 0000000..8e0d4f9 --- /dev/null +++ b/src/artipy/analysis/plots.py @@ -0,0 +1,107 @@ +from decimal import Decimal + +import pandas as pd +import plotly.express as px +import plotly.graph_objects as go +from plotly.subplots import make_subplots + +from artipy.artifacts import Artifact +from artipy.stats import STAT_NAMES + +from .analyse import ( + RollMagnitude, + calculate_artifact_crit_value, + calculate_artifact_roll_value, + calculate_substat_roll_magnitudes, + calculate_substat_rolls, +) +from .simulate import create_multiple_random_artifacts, upgrade_artifact_to_max + +ROUND_TO = Decimal("1E-2") + + +def plot_artifact_substat_rolls(artifact: Artifact) -> None: + substat_rolls = { + STAT_NAMES[substat.name]: calculate_substat_rolls(substat) + for substat in artifact.get_substats() + } + df = pd.DataFrame(substat_rolls.items(), columns=["stat", "rolls"]) + pie_figure = px.pie(df, values="rolls", names="stat") + + magnitudes_flat = [ + tuple(i.value for i in calculate_substat_roll_magnitudes(substat)) + for substat in artifact.get_substats() + ] + magnitudes_to_dict = { + STAT_NAMES[substat.name]: { + i.value: magnitudes_flat[idx].count(i.value) for i in RollMagnitude + } + for idx, substat in enumerate(artifact.get_substats()) + } + magnitudes_to_long_form = [ + {"stat_name": stat_name, "magnitude": magnitude, "count": count} + for stat_name, magnitudes in magnitudes_to_dict.items() + for magnitude, count in magnitudes.items() + ] + df_long = pd.DataFrame(magnitudes_to_long_form) + bar_traces = [] + for stat_name in df_long["stat_name"].unique(): + df_filtered = df_long[df_long["stat_name"] == stat_name] + bar_traces.append( + go.Bar( + x=df_filtered["magnitude"], + y=df_filtered["count"], + name=stat_name, + text=df_filtered["count"], + textposition="auto", + ) + ) + + fig = make_subplots( + rows=1, + cols=len(bar_traces) + 1, + specs=[[{"type": "pie"}] + [{"type": "bar"}] * len(bar_traces)], + column_widths=[0.4] + [0.6 / len(bar_traces)] * len(bar_traces), + subplot_titles=[ + f"Substat rolls on Artifact with {sum(substat_rolls.values())} total rolls", + *( + f"{stat} ({substat_rolls[STAT_NAMES[stat.name]]} rolls)" + for stat in artifact.get_substats() + ), + ], + ) + + fig.add_trace(pie_figure.data[0], row=1, col=1) + for i, trace in enumerate(bar_traces, start=2): + fig.add_trace(trace, row=1, col=i) + + fig.update_layout(showlegend=False) + + fig.show() + + +def plot_crit_value_distribution(iterations: int = 1_000) -> None: + for a in (artifacts := create_multiple_random_artifacts(iterations)): + upgrade_artifact_to_max(a) + + crit_values = [ + calculate_artifact_crit_value(a).quantize(ROUND_TO) + for a in artifacts + if calculate_artifact_crit_value(a) > 0 + ] + df = pd.DataFrame(crit_values, columns=["crit_value"]) + fig = px.histogram( + df, x="crit_value", title=f"Crit Rate Distribution of {iterations:,} Artifacts" + ) + fig.show() + + +def plot_roll_value_distribution(iterations: int = 1_000) -> None: + for a in (artifacts := create_multiple_random_artifacts(iterations)): + upgrade_artifact_to_max(a) + roll_values = [calculate_artifact_roll_value(a) for a in artifacts] + df = pd.DataFrame(roll_values, columns=["roll_value"]) + fig = px.histogram( + df, x="roll_value", title=f"Roll Value Distribution of {iterations:,} Artifacts" + ) + fig.show() diff --git a/src/artipy/analysis/simulate.py b/src/artipy/analysis/simulate.py new file mode 100644 index 0000000..dc60bed --- /dev/null +++ b/src/artipy/analysis/simulate.py @@ -0,0 +1,52 @@ +import random + +from artipy.artifacts import Artifact, ArtifactBuilder +from artipy.artifacts.utils import choose +from artipy.stats import VALID_MAINSTATS + + +def create_random_artifact( + slot: str = random.choice(("flower", "plume", "sands", "goblet")), +) -> Artifact: + """Create a random artifact. + + :return: The random artifact. + :rtype: Artifact + """ + + substat_count = 4 if random.random() < 0.2 else 3 + mainstats, mainstat_weights = zip(*VALID_MAINSTATS[slot].items()) + return ( + ArtifactBuilder() + .with_mainstat(choose(mainstats, mainstat_weights)) + .with_rarity(5) + .with_substats(amount=substat_count) + .with_slot(slot) + .build() + ) + + +def upgrade_artifact_to_max(artifact: Artifact) -> Artifact: + """Upgrade an artifact to its maximum level. + + :param artifact: The artifact to upgrade. + :type artifact: Artifact + :return: The upgraded artifact. + :rtype: Artifact + """ + while artifact.get_level() < artifact.get_rarity() * 4: + artifact.upgrade() + return artifact + + +def create_multiple_random_artifacts( + amount: int = 1, slot: str = random.choice(("flower", "plume", "sands", "goblet")) +) -> list[Artifact]: + """Create multiple random artifacts. + + :param amount: The amount of artifacts to create, defaults to 1 + :type amount: int, optional + :return: The list of random artifacts. + :rtype: list[Artifact] + """ + return [create_random_artifact(slot) for _ in range(amount)] diff --git a/src/artipy/artifacts/artifact.py b/src/artipy/artifacts/artifact.py index 103ccdb..af0fba3 100644 --- a/src/artipy/artifacts/artifact.py +++ b/src/artipy/artifacts/artifact.py @@ -1,9 +1,11 @@ from typing import Optional -from artipy.stats import MainStat, SubStat +from artipy.stats import MainStat, StatType, SubStat from .upgrade_strategy import AddStatStrategy, UpgradeStatStrategy, UpgradeStrategy +PLACEHOLDER_MAINSTAT = MainStat(StatType.HP, 0) + class Artifact: """Class representing an artifact in Genshin Impact.""" @@ -19,16 +21,19 @@ def __init__(self) -> None: self._slot: str = "" def set_mainstat(self, mainstat: MainStat) -> None: - mainstat.rarity = self.get_rarity() + if (rarity := self.get_rarity()) > 0: + mainstat.rarity = rarity self._mainstat = mainstat def set_substats(self, substats: list[SubStat]) -> None: - for substat in substats: - substat.rarity = self.get_rarity() + if (rarity := self.get_rarity()) > 0: + for substat in substats: + substat.rarity = rarity self._substats = substats def add_substat(self, substat: SubStat) -> None: - substat.rarity = self.get_rarity() + if (rarity := self.get_rarity()) > 0: + substat.rarity = rarity self._substats.append(substat) def set_level(self, level: int) -> None: @@ -38,7 +43,10 @@ def set_rarity(self, rarity: int) -> None: self._rarity = rarity stats: list[MainStat | SubStat] = [self.get_mainstat(), *self.get_substats()] for stat in stats: - stat.rarity = rarity + try: + stat.rarity = rarity + except AttributeError: + continue def set_artifact_set(self, set: str) -> None: self._set = set @@ -48,7 +56,7 @@ def set_artifact_slot(self, slot: str) -> None: def get_mainstat(self) -> MainStat: if self._mainstat is None: - raise ValueError("MainStat is not set.") + return PLACEHOLDER_MAINSTAT return self._mainstat def get_substats(self) -> list[SubStat]: @@ -76,6 +84,7 @@ def upgrade(self) -> None: def __str__(self) -> str: return ( - f"{self._slot} [+{self._level}]\n{'★' * self._rarity}\n" - f"{self._mainstat}\n{'\n'.join(str(s) for s in self._substats)}" + f"{self.get_artifact_slot()} [+{self.get_level()}]\n" + f"{'★' * self.get_rarity()}\n" + f"{self.get_mainstat()}\n{'\n'.join(str(s) for s in self.get_substats())}" ) diff --git a/src/artipy/artifacts/builder.py b/src/artipy/artifacts/builder.py index b336785..a7e2bd5 100644 --- a/src/artipy/artifacts/builder.py +++ b/src/artipy/artifacts/builder.py @@ -1,4 +1,4 @@ -from artipy import UPGRADE_STEP +from artipy import MAX_RARITY, UPGRADE_STEP from artipy.stats import MainStat, StatType, SubStat from .artifact import Artifact @@ -22,15 +22,13 @@ class ArtifactBuilder: def __init__(self) -> None: self._artifact: Artifact = Artifact() - def with_mainstat(self, stat: StatType, value: float | int) -> "ArtifactBuilder": + def with_mainstat( + self, stat: StatType, value: float | int = 0 + ) -> "ArtifactBuilder": """Set the mainstat of the artifact.""" - try: - self._artifact.get_mainstat() - except ValueError: - self._artifact.set_mainstat(MainStat(stat, value)) - return self - # Constraint: The mainstat can only be set once - raise ValueError("MainStat is already set.") + self._artifact.set_mainstat(MainStat(stat, value)) + self._artifact.get_mainstat().set_value_by_level(self._artifact.get_level()) + return self def with_substat(self, stat: StatType, value: float | int) -> "ArtifactBuilder": """Add a substat to the artifact.""" @@ -54,26 +52,22 @@ def with_substats( :type amount: int, optional :raises ValueError: If the amount is not within the valid range """ + rarity = self._artifact.get_rarity() if not substats: # Constraint: The number of substats cannot exceed artifact rarity - 1 - valid_range = range(1, self._artifact.get_rarity()) + valid_range = range(1, rarity) if amount not in valid_range: raise ValueError( f"Amount must be between {min(valid_range)} and {max(valid_range)}" ) - # Generate random substats - strategy = AddStatStrategy() for _ in range(amount): - new_stat = strategy.pick_stat(self._artifact) + new_stat = AddStatStrategy().pick_stat(self._artifact) self._artifact.add_substat(new_stat) + elif len(substats) > rarity - 1 and rarity > 0: + # Constraint: The number of substats cannot exceed artifact rarity + raise ValueError("Too many substats provided.") else: - if (rarity := self._artifact.get_rarity()) > 0: - # Constraint: The number of substats cannot exceed artifact rarity - if len(substats) > rarity - 1: - raise ValueError("Too many substats provided.") - - # Add the provided substats to the artifact self._artifact.set_substats([SubStat(*spec) for spec in substats]) return self @@ -96,6 +90,7 @@ def with_level(self, level: int) -> "ArtifactBuilder": f"Substat length mismatch with rarity '{rarity}' " f"(Expected {rarity} substats, got {substat_length})" ) + self._artifact.get_mainstat().set_value_by_level(level) self._artifact.set_level(level) return self @@ -111,6 +106,10 @@ def with_rarity(self, rarity: int) -> "ArtifactBuilder": f"Invalid rarity '{rarity}' for current level '{level}'. " f"(Expected {min(expected_range)}-{max(expected_range)})" ) + if rarity not in range(1, MAX_RARITY + 1): + raise ValueError(f"Invalid rarity '{rarity}' for artifact.") + if len(self._artifact.get_substats()) >= rarity: + raise ValueError("Substats are already full.") self._artifact.set_rarity(rarity) return self diff --git a/src/artipy/stats/__init__.py b/src/artipy/stats/__init__.py index 6d56be8..be22e86 100644 --- a/src/artipy/stats/__init__.py +++ b/src/artipy/stats/__init__.py @@ -1,6 +1,14 @@ from .mainstat import MainStat from .stat_data import StatData -from .stats import StatType +from .stats import STAT_NAMES, VALID_MAINSTATS, StatType from .substat import SubStat, create_substat -__all__ = ("MainStat", "SubStat", "StatData", "StatType", "create_substat") +__all__ = ( + "MainStat", + "SubStat", + "StatData", + "StatType", + "STAT_NAMES", + "VALID_MAINSTATS", + "create_substat", +) diff --git a/src/artipy/stats/mainstat.py b/src/artipy/stats/mainstat.py index 6b96b77..466f9b2 100644 --- a/src/artipy/stats/mainstat.py +++ b/src/artipy/stats/mainstat.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from .stats import Stat -from .utils import possible_mainstat_values as possible_values +from .utils import possible_mainstat_values @dataclass @@ -12,4 +12,4 @@ class MainStat(Stat): def set_value_by_level(self, level: int) -> None: """Set the value of the mainstat based on the level of the artifact.""" - self.value = possible_values(self.name, self.rarity)[level - 1] + self.value = possible_mainstat_values(self.name, self.rarity)[level] diff --git a/src/artipy/stats/stats.py b/src/artipy/stats/stats.py index c8eede3..a4843c5 100644 --- a/src/artipy/stats/stats.py +++ b/src/artipy/stats/stats.py @@ -69,6 +69,41 @@ def is_pct(self) -> bool: StatType.CRIT_DMG, ] +VALID_MAINSTATS: dict[str, dict[StatType, float]] = { + "flower": {StatType.HP: 100}, + "plume": {StatType.ATK: 100}, + "sands": { + StatType.HP_PERCENT: 26.68, + StatType.ATK_PERCENT: 26.66, + StatType.DEF_PERCENT: 26.66, + StatType.ENERGY_RECHARGE: 10.0, + StatType.ELEMENTAL_MASTERY: 10.0, + }, + "circlet": { + StatType.HP_PERCENT: 22.0, + StatType.ATK_PERCENT: 22.0, + StatType.DEF_PERCENT: 22.0, + StatType.CRIT_RATE: 10.0, + StatType.CRIT_DMG: 10.0, + StatType.HEALING_BONUS: 10.0, + StatType.ELEMENTAL_MASTERY: 4.0, + }, + "goblet": { + StatType.HP_PERCENT: 19.25, + StatType.ATK_PERCENT: 19.25, + StatType.DEF_PERCENT: 19.0, + StatType.PHYSICAL_DMG: 5.0, + StatType.ANEMO_DMG: 5.0, + StatType.CRYO_DMG: 5.0, + StatType.DENDRO_DMG: 5.0, + StatType.ELECTRO_DMG: 5.0, + StatType.GEO_DMG: 5.0, + StatType.HYDRO_DMG: 5.0, + StatType.PYRO_DMG: 5.0, + StatType.ELEMENTAL_MASTERY: 2.5, + }, +} + @dataclass class Stat: diff --git a/src/artipy/stats/utils.py b/src/artipy/stats/utils.py index 073211e..755ddab 100644 --- a/src/artipy/stats/utils.py +++ b/src/artipy/stats/utils.py @@ -1,7 +1,7 @@ from decimal import Decimal from functools import lru_cache from operator import attrgetter -from typing import Generator +from typing import Iterable from .stat_data import StatData from .stats import StatType @@ -10,7 +10,7 @@ SUBSTAT_DATA = StatData("substat_data.json") -def map_to_decimal(values: Generator[float, None, None]) -> tuple[Decimal, ...]: +def map_to_decimal(values: Iterable[float | int]) -> tuple[Decimal, ...]: """Helper function to map float values to Decimal.""" return tuple(map(Decimal, values)) @@ -27,14 +27,15 @@ def possible_mainstat_values(stat_type: StatType, rarity: int) -> tuple[Decimal, :return: The possible values for the mainstat. :rtype: tuple[Decimal, ...] """ - data = [] - for d in MAINSTAT_DATA: - try: - if d.rank == rarity: - data.append(d.addProps) - except AttributeError: - continue - return map_to_decimal((j.value for i in data for j in i if j.propType == stat_type)) + values = list(MAINSTAT_DATA)[1:] + data = [ + j.value + for i in values + if i.rank == rarity + for j in i.addProps + if j.propType == stat_type + ] + return map_to_decimal(data) @lru_cache(maxsize=None) diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py index 97b83f2..827d775 100644 --- a/tests/test_artifacts.py +++ b/tests/test_artifacts.py @@ -63,11 +63,6 @@ def test_artifact_str(artifact) -> None: ) -def test_builder_constraint_mainstat() -> None: - with pytest.raises(ValueError): - ArtifactBuilder().with_mainstat(StatType.HP, 0).with_mainstat(StatType.HP, 0) - - def test_builder_constraint_substats() -> None: # Case 1: Rarity is 5, so the number of substats can't exceed 4 with pytest.raises(ValueError): diff --git a/tests/test_stats.py b/tests/test_stats.py index 3b9ae9a..9e03c3e 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -41,7 +41,7 @@ def test_possible_mainstat_values() -> None: def test_mainstat_set_value_by_level(mainstat) -> None: mainstat.set_value_by_level(1) - assert mainstat.value == possible_mainstat_values(StatType.HP, 5)[0] + assert mainstat.value == possible_mainstat_values(StatType.HP, 5)[1] def test_mainstat_str(mainstat) -> None: From 5614309d0c9b372375e354a3037ff3cdb1b734c6 Mon Sep 17 00:00:00 2001 From: trumully Date: Sat, 13 Apr 2024 15:42:23 +1200 Subject: [PATCH 6/9] improvement: add colors to bin on plot_crit_value_distribution --- src/artipy/analysis/plots.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/artipy/analysis/plots.py b/src/artipy/analysis/plots.py index 8e0d4f9..0b6ce61 100644 --- a/src/artipy/analysis/plots.py +++ b/src/artipy/analysis/plots.py @@ -85,14 +85,21 @@ def plot_crit_value_distribution(iterations: int = 1_000) -> None: upgrade_artifact_to_max(a) crit_values = [ - calculate_artifact_crit_value(a).quantize(ROUND_TO) - for a in artifacts - if calculate_artifact_crit_value(a) > 0 + calculate_artifact_crit_value(a).quantize(ROUND_TO) for a in artifacts ] df = pd.DataFrame(crit_values, columns=["crit_value"]) + + bins = [0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0] + labels = [f"{bins[i]}-{bins[i+1]}" for i in range(len(bins) - 1)] + df["crit_value_range"] = pd.cut(df["crit_value"], bins=bins, labels=labels) + fig = px.histogram( - df, x="crit_value", title=f"Crit Rate Distribution of {iterations:,} Artifacts" + df, + x="crit_value", + color="crit_value_range", + title=f"Crit Rate Distribution of {iterations:,} Artifacts", ) + fig.show() From 5e6db3a52c86ed9e783e41d813d57d21f634fb90 Mon Sep 17 00:00:00 2001 From: trumully Date: Sat, 13 Apr 2024 15:51:32 +1200 Subject: [PATCH 7/9] feat(analysis.plots): add plot_multi_value_distribution --- src/artipy/analysis/plots.py | 38 ++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src/artipy/analysis/plots.py b/src/artipy/analysis/plots.py index 0b6ce61..5a87dd2 100644 --- a/src/artipy/analysis/plots.py +++ b/src/artipy/analysis/plots.py @@ -1,4 +1,5 @@ from decimal import Decimal +from typing import Callable import pandas as pd import plotly.express as px @@ -19,6 +20,12 @@ ROUND_TO = Decimal("1E-2") +ATTRIBUTES: dict[str, Callable] = { + "rolls": calculate_substat_rolls, + "roll_value": calculate_artifact_roll_value, + "crit_value": calculate_artifact_crit_value, +} + def plot_artifact_substat_rolls(artifact: Artifact) -> None: substat_rolls = { @@ -80,7 +87,7 @@ def plot_artifact_substat_rolls(artifact: Artifact) -> None: fig.show() -def plot_crit_value_distribution(iterations: int = 1_000) -> None: +def plot_crit_value_distribution(iterations: int = 1000) -> None: for a in (artifacts := create_multiple_random_artifacts(iterations)): upgrade_artifact_to_max(a) @@ -103,7 +110,7 @@ def plot_crit_value_distribution(iterations: int = 1_000) -> None: fig.show() -def plot_roll_value_distribution(iterations: int = 1_000) -> None: +def plot_roll_value_distribution(iterations: int = 1000) -> None: for a in (artifacts := create_multiple_random_artifacts(iterations)): upgrade_artifact_to_max(a) roll_values = [calculate_artifact_roll_value(a) for a in artifacts] @@ -112,3 +119,30 @@ def plot_roll_value_distribution(iterations: int = 1_000) -> None: df, x="roll_value", title=f"Roll Value Distribution of {iterations:,} Artifacts" ) fig.show() + + +def plot_multi_value_distribution( + iterations: int = 1000, *, attributes: tuple[str] +) -> None: + for attr in attributes: + if attr not in ATTRIBUTES: + raise ValueError( + f"Invalid attribute: {attr}\nValid attributes: {ATTRIBUTES}" + ) + + for a in (artifacts := create_multiple_random_artifacts(iterations)): + upgrade_artifact_to_max(a) + + concatted_df = pd.DataFrame() + for attr in attributes: + values = [ATTRIBUTES[attr](a) for a in artifacts if ATTRIBUTES[attr](a) > 0] + df = pd.DataFrame(values, columns=[attr]) + concatted_df = pd.concat([concatted_df, df]) + + fig = px.histogram( + concatted_df, + x=concatted_df.columns, + title=f"Value Distribution of {iterations:,} Artifacts", + ) + + fig.show() From 1446bcbccf4540f0b91e6d7d1217769c80f9b26d Mon Sep 17 00:00:00 2001 From: trumully Date: Sat, 13 Apr 2024 17:25:11 +1200 Subject: [PATCH 8/9] feat: add plot_expected_against_actual_mainstats method --- src/artipy/analysis/plots.py | 54 ++++++++++++++++++++++++++++++++- src/artipy/analysis/simulate.py | 12 +++++--- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/artipy/analysis/plots.py b/src/artipy/analysis/plots.py index 5a87dd2..c25bdb1 100644 --- a/src/artipy/analysis/plots.py +++ b/src/artipy/analysis/plots.py @@ -7,7 +7,7 @@ from plotly.subplots import make_subplots from artipy.artifacts import Artifact -from artipy.stats import STAT_NAMES +from artipy.stats import STAT_NAMES, VALID_MAINSTATS, StatType from .analyse import ( RollMagnitude, @@ -121,6 +121,58 @@ def plot_roll_value_distribution(iterations: int = 1000) -> None: fig.show() +def plot_expected_against_actual_mainstats(iterations: int = 1000) -> None: + for a in (artifacts := create_multiple_random_artifacts(iterations)): + upgrade_artifact_to_max(a) + + expected_mainstats = { + k: v for k, v in VALID_MAINSTATS.items() if k not in ("flower", "plume") + } + actual_mainstats: dict[str, list[StatType]] = {k: [] for k in expected_mainstats} + + for a in artifacts: + if (slot := a.get_artifact_slot()) in expected_mainstats: + actual_mainstats[slot].append(a.get_mainstat().name) + + actual_mainstats_pct: dict[str, dict[StatType, float]] = { + k: { + stat: (actual_mainstats[k].count(stat) / len(actual_mainstats[k])) * 100 + for stat in v + } + for k, v in actual_mainstats.items() + } + + fig = make_subplots( + rows=1, cols=len(expected_mainstats), subplot_titles=list(expected_mainstats) + ) + + for i, slot in enumerate(expected_mainstats, start=1): + col = (i - 1) % len(expected_mainstats) + 1 + fig.add_trace( + go.Bar( + x=list(expected_mainstats[slot]), + y=list(expected_mainstats[slot].values()), + name="Expected", + marker=dict(color="#FF6961"), + ), + row=1, + col=col, + ) + fig.add_trace( + go.Bar( + x=list(actual_mainstats_pct[slot]), + y=list(actual_mainstats_pct[slot].values()), + name="Actual", + marker=dict(color="#B4D8E7"), + ), + row=1, + col=col, + ) + + fig.update_layout(barmode="overlay", showlegend=False) + fig.show() + + def plot_multi_value_distribution( iterations: int = 1000, *, attributes: tuple[str] ) -> None: diff --git a/src/artipy/analysis/simulate.py b/src/artipy/analysis/simulate.py index dc60bed..c17f94b 100644 --- a/src/artipy/analysis/simulate.py +++ b/src/artipy/analysis/simulate.py @@ -6,7 +6,7 @@ def create_random_artifact( - slot: str = random.choice(("flower", "plume", "sands", "goblet")), + slot: str = random.choice(("flower", "plume", "sands", "goblet", "circlet")), ) -> Artifact: """Create a random artifact. @@ -39,9 +39,7 @@ def upgrade_artifact_to_max(artifact: Artifact) -> Artifact: return artifact -def create_multiple_random_artifacts( - amount: int = 1, slot: str = random.choice(("flower", "plume", "sands", "goblet")) -) -> list[Artifact]: +def create_multiple_random_artifacts(amount: int = 1) -> list[Artifact]: """Create multiple random artifacts. :param amount: The amount of artifacts to create, defaults to 1 @@ -49,4 +47,8 @@ def create_multiple_random_artifacts( :return: The list of random artifacts. :rtype: list[Artifact] """ - return [create_random_artifact(slot) for _ in range(amount)] + result = [] + for _ in range(amount): + slot = random.choice(("flower", "plume", "sands", "goblet", "circlet")) + result.append(create_random_artifact(slot)) + return result From 975b67fcff0bd45dbc6f48c01960ebb1a43624f8 Mon Sep 17 00:00:00 2001 From: trumully Date: Sat, 13 Apr 2024 17:27:57 +1200 Subject: [PATCH 9/9] chore: bump version to 0.3.0-beta.2 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 304551e..22c5ecf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "artipy" -version = "0.3.0-alpha" +version = "0.3.0-beta.2" description = "" authors = [ "Truman Mulholland " ]