Skip to content

Commit

Permalink
feat: x-api-version header support for scores (#120)
Browse files Browse the repository at this point in the history
* feat: add support for api version header

* fix: add aliases for lazer models

* style: mypy fixes
  • Loading branch information
NiceAesth authored Feb 25, 2023
1 parent 5d47184 commit 5702798
Show file tree
Hide file tree
Showing 8 changed files with 251 additions and 26 deletions.
2 changes: 2 additions & 0 deletions aiosu/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from .mods import Mods


if TYPE_CHECKING:
from typing import Any

Expand All @@ -30,6 +31,7 @@ class Config:
allow_population_by_field_name = True
json_loads = orjson.loads
json_dumps = orjson_dumps

json_encoders = {
Mods: str,
}
Expand Down
7 changes: 7 additions & 0 deletions aiosu/models/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ class HTMLBody(BaseModel):
bbcode: Optional[str]


class PinAttributes(BaseModel):
is_pinned: bool
score_id: int
score_type: str


class CurrentUserAttributes(BaseModel):
can_destroy: Optional[bool]
can_reopen: Optional[bool]
Expand All @@ -81,6 +87,7 @@ class CurrentUserAttributes(BaseModel):
last_read_id: Optional[int]
can_new_comment: Optional[bool]
can_new_comment_reason: Optional[str]
pin: Optional[PinAttributes]


class CursorModel(BaseModel):
Expand Down
188 changes: 173 additions & 15 deletions aiosu/models/lazer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,46 +3,204 @@
"""
from __future__ import annotations

from datetime import datetime
from typing import Any
from typing import Optional

from pydantic import Field
from pydantic import root_validator

from .base import BaseModel
from .beatmap import Beatmap
from .beatmap import Beatmapset
from .common import CurrentUserAttributes
from .gamemode import Gamemode
from .score import ScoreWeight
from .user import User

__all__ = (
"LazerMod",
"LazerScoreStatistics",
"LazerReplayData",
"LazerScore",
)


def calculate_score_completion(
statistics: LazerScoreStatistics,
beatmap: Beatmap,
) -> float:
"""Calculates completion for a score.
:param statistics: The statistics of the score
:type statistics: aiosu.models.lazer.LazerScoreStatistics
:param beatmap: The beatmap of the score
:type beatmap: aiosu.models.beatmap.Beatmap
:raises ValueError: If the gamemode is unknown
:return: Completion for the given score
:rtype: float
"""
return (
(
statistics.perfect
+ statistics.good
+ statistics.great
+ statistics.ok
+ statistics.meh
+ statistics.miss
)
/ beatmap.count_objects
) * 100


class LazerMod(BaseModel):
"""Temporary model for lazer mods."""

acronym: str
settings: dict[str, Any] = Field(default_factory=dict)

def __str__(self) -> str:
return self.acronym


class LazerScoreStatistics(BaseModel):
ok: Optional[int]
meh: Optional[int]
miss: Optional[int]
great: Optional[int]
ignore_hit: Optional[int]
ignore_miss: Optional[int]
large_bonus: Optional[int]
large_tick_hit: Optional[int]
large_tick_miss: Optional[int]
small_bonus: Optional[int]
small_tick_hit: Optional[int]
small_tick_miss: Optional[int]
good: Optional[int]
perfect: Optional[int]
legacy_combo_increase: Optional[int]
ok: int = 0
meh: int = 0
miss: int = 0
great: int = 0
ignore_hit: int = 0
ignore_miss: int = 0
large_bonus: int = 0
large_tick_hit: int = 0
large_tick_miss: int = 0
small_bonus: int = 0
small_tick_hit: int = 0
small_tick_miss: int = 0
good: int = 0
perfect: int = 0
legacy_combo_increase: int = 0

@property
def count_300(self) -> int:
return self.great

@property
def count_100(self) -> int:
return self.ok

@property
def count_50(self) -> int:
return self.meh

@property
def count_miss(self) -> int:
return self.miss

@property
def count_geki(self) -> int:
return self.perfect

@property
def count_katu(self) -> int:
return self.good


class LazerReplayData(BaseModel):
mods: list[LazerMod]
statistics: LazerScoreStatistics
maximum_statistics: LazerScoreStatistics


class LazerScore(BaseModel):
id: int
accuracy: float
beatmap_id: int
max_combo: int
maximum_statistics: LazerScoreStatistics
mods: list[LazerMod]
passed: bool
rank: str
ruleset_id: int
ended_at: datetime
statistics: LazerScoreStatistics
total_score: int
user_id: int
replay: bool
type: str
current_user_attributes: CurrentUserAttributes
beatmap: Beatmap
beatmapset: Beatmapset
user: User
build_id: Optional[int]
started_at: Optional[datetime]
best_id: Optional[int]
legacy_perfect: Optional[bool]
pp: Optional[float]
weight: Optional[ScoreWeight]

@property
def mods_str(self) -> str:
return "".join(str(mod) for mod in self.mods)

@property
def created_at(self) -> datetime:
return self.ended_at

@property
def completion(self) -> float:
"""Beatmap completion.
:raises ValueError: If beatmap is None
:raises ValueError: If mode is unknown
:return: Beatmap completion of a score (%). 100% for passes
:rtype: float
"""
if self.beatmap is None:
raise ValueError("Beatmap object is not set.")

if self.passed:
return 100.0

return calculate_score_completion(self.statistics, self.beatmap)

@property
def mode(self) -> Gamemode:
return Gamemode(self.ruleset_id)

@property
def score(self) -> int:
return self.total_score

@property
def score_url(self) -> Optional[str]:
# score.id has undefined behaviour, best_id is the one you should use as it returns None if the URL does not exist
r"""Link to the score.
:return: Link to the score on the osu! website
:rtype: Optional[str]
"""
return (
f"https://osu.ppy.sh/scores/{self.mode.name_api}/{self.best_id}"
if self.best_id
else None
)

@property
def replay_url(self) -> Optional[str]:
# score.id has undefined behaviour, best_id is the one you should use as it returns None if the URL does not exist
r"""Link to the replay.
:return: Link to download the replay on the osu! website
:rtype: Optional[str]
"""
return (
f"https://osu.ppy.sh/scores/{self.mode.name_api}/{self.best_id}/download"
if self.best_id and self.replay
else None
)

@root_validator
def _fail_rank(cls, values: dict[str, Any]) -> dict[str, Any]:
if not values["passed"]:
values["rank"] = "F"
return values
7 changes: 7 additions & 0 deletions aiosu/models/mods.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,15 @@ def from_type(cls, __o: object) -> Mod:
:rtype: Mod
:raises ValueError: If the Mod does not exist
"""
from .lazer import LazerMod # Lazy import to avoid circular imports

if isinstance(__o, cls):
return __o
if isinstance(__o, LazerMod):
try:
return cls.from_type(__o.acronym)
except ValueError:
return cls.NoMod
for mod in list(Mod):
if __o == mod.short_name or __o == mod.bitmask:
return mod
Expand Down
15 changes: 13 additions & 2 deletions aiosu/models/score.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from .base import BaseModel
from .beatmap import Beatmap
from .beatmap import Beatmapset
from .common import CurrentUserAttributes
from .gamemode import Gamemode
from .mods import Mods
from .user import User
Expand Down Expand Up @@ -100,9 +101,17 @@ class ScoreStatistics(BaseModel):
count_50: int
count_100: int
count_300: int
count_miss: int
count_geki: int
count_katu: int
count_miss: int

@root_validator(pre=True)
def _convert_none_to_zero(cls, values: dict[str, Any]) -> dict[str, Any]:
# Lazer API returns null for some statistics
for key in values:
if values[key] is None:
values[key] = 0
return values

@classmethod
def _from_api_v1(cls, data: Any) -> ScoreStatistics:
Expand Down Expand Up @@ -141,6 +150,8 @@ class Score(BaseModel):
user: Optional[User]
rank_global: Optional[int]
rank_country: Optional[int]
type: Optional[str]
current_user_attributes: Optional[CurrentUserAttributes]
beatmap_id: Optional[int]
"""Only present on API v1"""

Expand Down Expand Up @@ -182,7 +193,7 @@ def replay_url(self) -> Optional[str]:
"""
return (
f"https://osu.ppy.sh/scores/{self.mode.name_api}/{self.best_id}/download"
if self.best_id
if self.best_id and self.replay
else None
)

Expand Down
Loading

0 comments on commit 5702798

Please sign in to comment.