refactor!: calculate -> calculators

This commit is contained in:
2024-04-04 18:10:53 +08:00
parent c705fea473
commit f359322b6c
14 changed files with 402 additions and 294 deletions

View File

@ -1,8 +0,0 @@
from .b30 import calculate_b30, get_b30_calculated_list
from .score import (
calculate_constants_from_play_rating,
calculate_play_rating,
calculate_score_modifier,
calculate_score_range,
calculate_shiny_pure,
)

View File

@ -1,24 +0,0 @@
from decimal import Decimal
from typing import Dict, List
from ..models.scores import ScoreCalculated
def get_b30_calculated_list(
calculated_list: List[ScoreCalculated],
) -> List[ScoreCalculated]:
best_scores: Dict[str, ScoreCalculated] = {}
for calculated in calculated_list:
key = f"{calculated.song_id}_{calculated.rating_class}"
stored = best_scores.get(key)
if stored and stored.score < calculated.score or not stored:
best_scores[key] = calculated
ret_list = list(best_scores.values())
ret_list = sorted(ret_list, key=lambda c: c.potential, reverse=True)[:30]
return ret_list
def calculate_b30(calculated_list: List[ScoreCalculated]) -> Decimal:
ptt_list = [Decimal(c.potential) for c in get_b30_calculated_list(calculated_list)]
sum_ptt_list = sum(ptt_list)
return (sum_ptt_list / len(ptt_list)) if sum_ptt_list else Decimal("0.0")

View File

@ -1,66 +0,0 @@
from dataclasses import dataclass
from decimal import Decimal
from math import floor
from typing import Tuple, Union
def calculate_score_range(notes: int, pure: int, far: int):
single_note_score = 10000000 / Decimal(notes)
actual_score = floor(
single_note_score * pure + single_note_score * Decimal(0.5) * far
)
return (actual_score, actual_score + pure)
def calculate_score_modifier(score: int) -> Decimal:
if score >= 10000000:
return Decimal(2)
if score >= 9800000:
return Decimal(1) + (Decimal(score - 9800000) / 200000)
return Decimal(score - 9500000) / 300000
def calculate_play_rating(constant: int, score: int) -> Decimal:
score_modifier = calculate_score_modifier(score)
return max(Decimal(0), Decimal(constant) / 10 + score_modifier)
def calculate_shiny_pure(notes: int, score: int, pure: int, far: int) -> int:
single_note_score = 10000000 / Decimal(notes)
actual_score = single_note_score * pure + single_note_score * Decimal(0.5) * far
return score - floor(actual_score)
@dataclass
class ConstantsFromPlayRatingResult:
# pylint: disable=invalid-name
EXPlus: Tuple[Decimal, Decimal]
EX: Tuple[Decimal, Decimal]
AA: Tuple[Decimal, Decimal]
A: Tuple[Decimal, Decimal]
B: Tuple[Decimal, Decimal]
C: Tuple[Decimal, Decimal]
def calculate_constants_from_play_rating(play_rating: Union[Decimal, str, float, int]):
# pylint: disable=no-value-for-parameter
play_rating = Decimal(play_rating)
ranges = []
for upper_score, lower_score in [
(10000000, 9900000),
(9899999, 9800000),
(9799999, 9500000),
(9499999, 9200000),
(9199999, 8900000),
(8899999, 8600000),
]:
upper_score_modifier = calculate_score_modifier(upper_score)
lower_score_modifier = calculate_score_modifier(lower_score)
ranges.append(
(play_rating - upper_score_modifier, play_rating - lower_score_modifier)
)
return ConstantsFromPlayRatingResult(*ranges)

View File

@ -1,174 +0,0 @@
from decimal import Decimal
from typing import Literal, Optional, Union
class PlayResult:
def __init__(
self,
*,
play_rating: Union[Decimal, str, float, int],
partner_step: Union[Decimal, str, float, int],
):
self.__play_rating = play_rating
self.__partner_step = partner_step
@property
def play_rating(self):
return Decimal(self.__play_rating)
@property
def partner_step(self):
return Decimal(self.__partner_step)
class PartnerBonus:
def __init__(
self,
*,
step_bonus: Union[Decimal, str, float, int] = Decimal("0.0"),
final_multiplier: Union[Decimal, str, float, int] = Decimal("1.0"),
):
self.__step_bonus = step_bonus
self.__final_multiplier = final_multiplier
@property
def step_bonus(self):
return Decimal(self.__step_bonus)
@property
def final_multiplier(self):
return Decimal(self.__final_multiplier)
AwakenedIlithPartnerBonus = PartnerBonus(step_bonus="6.0")
AwakenedEtoPartnerBonus = PartnerBonus(step_bonus="7.0")
AwakenedLunaPartnerBonus = PartnerBonus(step_bonus="7.0")
class AwakenedAyuPartnerBonus(PartnerBonus):
def __init__(self, step_bonus: Union[Decimal, str, float, int]):
super().__init__(step_bonus=step_bonus)
AmaneBelowExPartnerBonus = PartnerBonus(final_multiplier="0.5")
class MithraTerceraPartnerBonus(PartnerBonus):
def __init__(self, step_bonus: int):
super().__init__(step_bonus=step_bonus)
MayaPartnerBonus = PartnerBonus(final_multiplier="2.0")
class StepBooster:
def final_value(self) -> Decimal:
raise NotImplementedError()
class LegacyMapStepBooster(StepBooster):
def __init__(
self,
stamina: Literal[2, 4, 6],
fragments: Literal[100, 250, 500, None],
):
self.stamina = stamina
self.fragments = fragments
@property
def stamina(self):
return self.__stamina
@stamina.setter
def stamina(self, value: Literal[2, 4, 6]):
if value not in [2, 4, 6]:
raise ValueError("stamina can only be one of [2, 4, 6]")
self.__stamina = value
@property
def fragments(self):
return self.__fragments
@fragments.setter
def fragments(self, value: Literal[100, 250, 500, None]):
if value not in [100, 250, 500, None]:
raise ValueError("fragments can only be one of [100, 250, 500, None]")
self.__fragments = value
def final_value(self) -> Decimal:
stamina_multiplier = Decimal(self.stamina)
fragments_multiplier = Decimal(1)
if self.fragments == 100:
fragments_multiplier = Decimal("1.1")
elif self.fragments == 250:
fragments_multiplier = Decimal("1.25")
elif self.fragments == 500:
fragments_multiplier = Decimal("1.5")
return stamina_multiplier * fragments_multiplier
class MemoriesStepBooster(StepBooster):
def final_value(self) -> Decimal:
return Decimal("4.0")
def calculate_step_original(
play_result: PlayResult,
*,
partner_bonus: Optional[PartnerBonus] = None,
step_booster: Optional[StepBooster] = None,
) -> Decimal:
ptt = play_result.play_rating
step = play_result.partner_step
if partner_bonus:
partner_bonus_step = partner_bonus.step_bonus
partner_bonus_multiplier = partner_bonus.final_multiplier
else:
partner_bonus_step = Decimal("0")
partner_bonus_multiplier = Decimal("1.0")
result = (Decimal("2.45") * ptt.sqrt() + Decimal("2.5")) * (step / 50)
result += partner_bonus_step
result *= partner_bonus_multiplier
if step_booster:
result *= step_booster.final_value()
return result
def calculate_step(
play_result: PlayResult,
*,
partner_bonus: Optional[PartnerBonus] = None,
step_booster: Optional[StepBooster] = None,
) -> Decimal:
result_original = calculate_step_original(
play_result, partner_bonus=partner_bonus, step_booster=step_booster
)
return round(result_original, 1)
def calculate_play_rating_from_step(
step: Union[Decimal, str, int, float],
partner_step_value: Union[Decimal, str, int, float],
*,
partner_bonus: Optional[PartnerBonus] = None,
step_booster: Optional[StepBooster] = None,
):
step = Decimal(step)
partner_step_value = Decimal(partner_step_value)
# get original play result
if partner_bonus and partner_bonus.final_multiplier:
step /= partner_bonus.final_multiplier
if step_booster:
step /= step_booster.final_value()
if partner_bonus and partner_bonus.step_bonus:
step -= partner_bonus.step_bonus
play_rating_sqrt = (Decimal(50) * step - Decimal("2.5") * partner_step_value) / (
Decimal("2.45") * partner_step_value
)
return play_rating_sqrt**2 if play_rating_sqrt >= 0 else -(play_rating_sqrt**2)

View File

@ -0,0 +1,2 @@
from . import world
from .play_result import PlayResultCalculators, calculate_constants_from_play_rating

View File

@ -0,0 +1,105 @@
from decimal import Decimal
from math import floor
from typing import Tuple, TypedDict, Union
from arcaea_offline.constants.play_result import ScoreLowerLimits
class PlayResultCalculators:
@staticmethod
def score_possible_range(notes: int, pure: int, far: int) -> tuple[int, int]:
"""
Returns the possible range of score based on the given values.
The first integer of returned tuple is the lower limit of the score,
and the second integer is the upper limit.
For example, ...
"""
single_note_score = 10000000 / Decimal(notes)
actual_score = floor(
single_note_score * pure + single_note_score * Decimal(0.5) * far
)
return (actual_score, actual_score + pure)
@staticmethod
def shiny_pure(notes: int, score: int, pure: int, far: int) -> int:
single_note_score = 10000000 / Decimal(notes)
actual_score = single_note_score * pure + single_note_score * Decimal(0.5) * far
return score - floor(actual_score)
@staticmethod
def score_modifier(score: int) -> Decimal:
"""
Returns the score modifier of the given score
https://arcaea.fandom.com/wiki/Potential#Score_Modifier
:param score: The score of the play result, e.g. 9900000
:return: The modifier of the given score, e.g. Decimal("1.5")
"""
if not isinstance(score, int):
raise TypeError("score must be an integer")
if score < 0:
raise ValueError("score cannot be negative")
if score >= 10000000:
return Decimal(2)
if score >= 9800000:
return Decimal(1) + (Decimal(score - 9800000) / 200000)
return Decimal(score - 9500000) / 300000
@classmethod
def play_rating(cls, score: int, constant: int) -> Decimal:
"""
Returns the play rating of the given score
https://arcaea.fandom.com/wiki/Potential#Play_Rating
:param constant: The (constant * 10) of the played chart, e.g. 120 for Testify[BYD]
:param score: The score of the play result, e.g. 10002221
:return: The play rating of the given values, e.g. Decimal("14.0")
"""
if not isinstance(score, int):
raise TypeError("score must be an integer")
if not isinstance(constant, int):
raise TypeError("constant must be an integer")
if score < 0:
raise ValueError("score cannot be negative")
if constant < 0:
raise ValueError("constant cannot be negative")
score_modifier = cls.score_modifier(score)
return max(Decimal(0), Decimal(constant) / 10 + score_modifier)
class ConstantsFromPlayRatingResult(TypedDict):
EX_PLUS: Tuple[Decimal, Decimal]
EX: Tuple[Decimal, Decimal]
AA: Tuple[Decimal, Decimal]
A: Tuple[Decimal, Decimal]
B: Tuple[Decimal, Decimal]
C: Tuple[Decimal, Decimal]
@classmethod
def constants_from_play_rating(
cls, play_rating: Union[Decimal, str, float, int]
) -> ConstantsFromPlayRatingResult:
play_rating = Decimal(play_rating)
def _result(score_upper: int, score_lower: int) -> Tuple[Decimal, Decimal]:
upper_score_modifier = cls.score_modifier(score_upper)
lower_score_modifier = cls.score_modifier(score_lower)
return (
play_rating - upper_score_modifier,
play_rating - lower_score_modifier,
)
return {
"EX_PLUS": _result(10000000, ScoreLowerLimits.EX_PLUS),
"EX": _result(ScoreLowerLimits.EX_PLUS - 1, ScoreLowerLimits.EX),
"AA": _result(ScoreLowerLimits.EX - 1, ScoreLowerLimits.AA),
"A": _result(ScoreLowerLimits.AA - 1, ScoreLowerLimits.A),
"B": _result(ScoreLowerLimits.A - 1, ScoreLowerLimits.B),
"C": _result(ScoreLowerLimits.B - 1, ScoreLowerLimits.C),
}

View File

@ -0,0 +1,11 @@
from ._common import MemoriesStepBooster, PartnerBonus, WorldPlayResult
from .legacy import LegacyMapStepBooster
from .main import WorldMainMapCalculators
from .partners import (
AmaneBelowExPartnerBonus,
AwakenedEtoPartnerBonus,
AwakenedIlithPartnerBonus,
AwakenedLunaPartnerBonus,
MayaPartnerBonus,
MithraTerceraPartnerBonus,
)

View File

@ -0,0 +1,50 @@
from decimal import Decimal
from typing import Union
class WorldPlayResult:
def __init__(
self,
*,
play_rating: Union[Decimal, str, float, int],
partner_step: Union[Decimal, str, float, int],
):
self.__play_rating = play_rating
self.__partner_step = partner_step
@property
def play_rating(self):
return Decimal(self.__play_rating)
@property
def partner_step(self):
return Decimal(self.__partner_step)
class PartnerBonus:
def __init__(
self,
*,
step_bonus: Union[Decimal, str, float, int] = Decimal("0.0"),
final_multiplier: Union[Decimal, str, float, int] = Decimal("1.0"),
):
self.__step_bonus = step_bonus
self.__final_multiplier = final_multiplier
@property
def step_bonus(self):
return Decimal(self.__step_bonus)
@property
def final_multiplier(self):
return Decimal(self.__final_multiplier)
class StepBooster:
def final_value(self) -> Decimal:
raise NotImplementedError()
class MemoriesStepBooster(StepBooster):
def final_value(self) -> Decimal:
return Decimal("4.0")

View File

@ -0,0 +1,45 @@
from decimal import Decimal
from typing import Literal
from ._common import StepBooster
class LegacyMapStepBooster(StepBooster):
def __init__(
self,
stamina: Literal[2, 4, 6],
fragments: Literal[100, 250, 500, None],
):
self.stamina = stamina
self.fragments = fragments
@property
def stamina(self):
return self.__stamina
@stamina.setter
def stamina(self, value: Literal[2, 4, 6]):
if value not in [2, 4, 6]:
raise ValueError("stamina can only be one of [2, 4, 6]")
self.__stamina = value
@property
def fragments(self):
return self.__fragments
@fragments.setter
def fragments(self, value: Literal[100, 250, 500, None]):
if value not in [100, 250, 500, None]:
raise ValueError("fragments can only be one of [100, 250, 500, None]")
self.__fragments = value
def final_value(self) -> Decimal:
stamina_multiplier = Decimal(self.stamina)
fragments_multiplier = Decimal(1)
if self.fragments == 100:
fragments_multiplier = Decimal("1.1")
elif self.fragments == 250:
fragments_multiplier = Decimal("1.25")
elif self.fragments == 500:
fragments_multiplier = Decimal("1.5")
return stamina_multiplier * fragments_multiplier

View File

@ -0,0 +1,57 @@
from decimal import Decimal
from typing import Optional, Union
from ._common import PartnerBonus, StepBooster, WorldPlayResult
class WorldMainMapCalculators:
@staticmethod
def step(
play_result: WorldPlayResult,
*,
partner_bonus: Optional[PartnerBonus] = None,
step_booster: Optional[StepBooster] = None,
) -> Decimal:
ptt = play_result.play_rating
step = play_result.partner_step
if partner_bonus:
partner_bonus_step = partner_bonus.step_bonus
partner_bonus_multiplier = partner_bonus.final_multiplier
else:
partner_bonus_step = Decimal("0")
partner_bonus_multiplier = Decimal("1.0")
result = (Decimal("2.45") * ptt.sqrt() + Decimal("2.5")) * (step / 50)
result += partner_bonus_step
result *= partner_bonus_multiplier
if step_booster:
result *= step_booster.final_value()
return result
@staticmethod
def play_rating_from_step(
step: Union[Decimal, str, int, float],
partner_step_value: Union[Decimal, str, int, float],
*,
partner_bonus: Optional[PartnerBonus] = None,
step_booster: Optional[StepBooster] = None,
):
step = Decimal(step)
partner_step_value = Decimal(partner_step_value)
# get original play result
if partner_bonus and partner_bonus.final_multiplier:
step /= partner_bonus.final_multiplier
if step_booster:
step /= step_booster.final_value()
if partner_bonus and partner_bonus.step_bonus:
step -= partner_bonus.step_bonus
play_rating_sqrt = (
Decimal(50) * step - Decimal("2.5") * partner_step_value
) / (Decimal("2.45") * partner_step_value)
return (
play_rating_sqrt**2 if play_rating_sqrt >= 0 else -(play_rating_sqrt**2)
)

View File

@ -0,0 +1,16 @@
from ._common import PartnerBonus
AwakenedIlithPartnerBonus = PartnerBonus(step_bonus="6.0")
AwakenedEtoPartnerBonus = PartnerBonus(step_bonus="7.0")
AwakenedLunaPartnerBonus = PartnerBonus(step_bonus="7.0")
AmaneBelowExPartnerBonus = PartnerBonus(final_multiplier="0.5")
class MithraTerceraPartnerBonus(PartnerBonus):
def __init__(self, step_bonus: int):
super().__init__(step_bonus=step_bonus)
MayaPartnerBonus = PartnerBonus(final_multiplier="2.0")

View File

@ -1,22 +0,0 @@
from decimal import Decimal
from arcaea_offline.calculate.world_step import (
AwakenedAyuPartnerBonus,
LegacyMapStepBooster,
PlayResult,
calculate_step_original,
)
def test_world_step():
# the result was copied from https://arcaea.fandom.com/wiki/World_Mode_Mechanics#Calculation
# CC BY-SA 3.0
booster = LegacyMapStepBooster(6, 250)
partner_bonus = AwakenedAyuPartnerBonus("+3.6")
play_result = PlayResult(play_rating=Decimal("11.299"), partner_step=92)
result = calculate_step_original(
play_result, partner_bonus=partner_bonus, step_booster=booster
)
assert result.quantize(Decimal("0.000")) == Decimal("175.149")

View File

@ -0,0 +1,42 @@
from decimal import Decimal
import pytest
from arcaea_offline.calculators.play_result import PlayResultCalculators
class TestPlayResultCalculators:
def test_score_modifier(self):
# Results from https://arcaea.fandom.com/wiki/Potential#Score_Modifier
assert PlayResultCalculators.score_modifier(10000000) == Decimal("2.0")
assert PlayResultCalculators.score_modifier(9900000) == Decimal("1.5")
assert PlayResultCalculators.score_modifier(9800000) == Decimal("1.0")
assert PlayResultCalculators.score_modifier(9500000) == Decimal("0.0")
assert PlayResultCalculators.score_modifier(9200000) == Decimal("-1.0")
assert PlayResultCalculators.score_modifier(8900000) == Decimal("-2.0")
assert PlayResultCalculators.score_modifier(8600000) == Decimal("-3.0")
assert PlayResultCalculators.score_modifier(0).quantize(
Decimal("-0.00")
) == Decimal("-31.67")
pytest.raises(ValueError, PlayResultCalculators.score_modifier, -1)
pytest.raises(TypeError, PlayResultCalculators.score_modifier, "9800000")
pytest.raises(TypeError, PlayResultCalculators.score_modifier, None)
pytest.raises(TypeError, PlayResultCalculators.score_modifier, [])
def test_play_rating(self):
assert PlayResultCalculators.play_rating(10002221, 120) == Decimal("14.0")
assert PlayResultCalculators.play_rating(5500000, 120) == Decimal("0.0")
pytest.raises(TypeError, PlayResultCalculators.play_rating, "10002221", 120)
pytest.raises(TypeError, PlayResultCalculators.play_rating, 10002221, "120")
pytest.raises(TypeError, PlayResultCalculators.play_rating, "10002221", "120")
pytest.raises(TypeError, PlayResultCalculators.play_rating, 10002221, None)
pytest.raises(ValueError, PlayResultCalculators.play_rating, -1, 120)
pytest.raises(ValueError, PlayResultCalculators.play_rating, 10002221, -1)

View File

@ -0,0 +1,74 @@
from decimal import ROUND_FLOOR, Decimal
from arcaea_offline.calculators.play_result import PlayResultCalculators
from arcaea_offline.calculators.world import (
LegacyMapStepBooster,
PartnerBonus,
WorldMainMapCalculators,
WorldPlayResult,
)
class TestWorldMainMapCalculators:
def test_step_fandom(self):
# Final result from https://arcaea.fandom.com/wiki/World_Mode_Mechanics#Calculation
# CC BY-SA 3.0
booster = LegacyMapStepBooster(6, 250)
partner_bonus = PartnerBonus(step_bonus="+3.6")
play_result = WorldPlayResult(play_rating=Decimal("11.299"), partner_step=92)
result = WorldMainMapCalculators.step(
play_result, partner_bonus=partner_bonus, step_booster=booster
)
assert result.quantize(Decimal("0.000")) == Decimal("175.149")
def test_step(self):
# Results from actual play results, Arcaea v5.5.8c
def _quantize(decimal: Decimal) -> Decimal:
return decimal.quantize(Decimal("0.0"), rounding=ROUND_FLOOR)
# goldenslaughter FTR [9.7], 9906968
# 10.7 > 34.2 < 160
assert _quantize(
WorldMainMapCalculators.step(
WorldPlayResult(
play_rating=PlayResultCalculators.play_rating(9906968, 97),
partner_step=160,
)
)
) == Decimal("34.2")
# Luna Rossa FTR [9.7], 9984569
# 10.8 > 34.7 < 160
assert _quantize(
WorldMainMapCalculators.step(
WorldPlayResult(
play_rating=PlayResultCalculators.play_rating(9984569, 97),
partner_step=160,
)
)
) == Decimal("34.7")
# ultradiaxon-N3 FTR [10.5], 9349575
# 10.2 > 32.7 < 160
assert _quantize(
WorldMainMapCalculators.step(
WorldPlayResult(
play_rating=PlayResultCalculators.play_rating(9349575, 105),
partner_step=160,
)
)
) == Decimal("32.7")
# san skia FTR [8.3], 10001036
# 10.3 > 64.2 < 310
assert _quantize(
WorldMainMapCalculators.step(
WorldPlayResult(
play_rating=PlayResultCalculators.play_rating(10001036, 83),
partner_step=310,
)
)
) == Decimal("64.2")