221 lines
5.7 KiB
Python
221 lines
5.7 KiB
Python
from __future__ import annotations
|
|
|
|
from enum import Enum
|
|
import json
|
|
from typing import Any, Literal, TypedDict
|
|
|
|
from app.path import STATIC_DIR
|
|
|
|
from pydantic import BaseModel, Field, ValidationInfo, field_validator
|
|
import rosu_pp_py as rosu
|
|
|
|
|
|
class GameMode(str, Enum):
|
|
OSU = "osu"
|
|
TAIKO = "taiko"
|
|
FRUITS = "fruits"
|
|
MANIA = "mania"
|
|
|
|
def to_rosu(self) -> rosu.GameMode:
|
|
return {
|
|
GameMode.OSU: rosu.GameMode.Osu,
|
|
GameMode.TAIKO: rosu.GameMode.Taiko,
|
|
GameMode.FRUITS: rosu.GameMode.Catch,
|
|
GameMode.MANIA: rosu.GameMode.Mania,
|
|
}[self]
|
|
|
|
|
|
MODE_TO_INT = {
|
|
GameMode.OSU: 0,
|
|
GameMode.TAIKO: 1,
|
|
GameMode.FRUITS: 2,
|
|
GameMode.MANIA: 3,
|
|
}
|
|
INT_TO_MODE = {v: k for k, v in MODE_TO_INT.items()}
|
|
|
|
|
|
class Rank(str, Enum):
|
|
X = "X"
|
|
XH = "XH"
|
|
S = "S"
|
|
SH = "SH"
|
|
A = "A"
|
|
B = "B"
|
|
C = "C"
|
|
D = "D"
|
|
F = "F"
|
|
|
|
|
|
class APIMod(TypedDict, total=False):
|
|
acronym: str
|
|
settings: dict[str, Any]
|
|
|
|
|
|
legacy_mod: dict[str, int] = {
|
|
"NF": 1 << 0, # No Fail
|
|
"EZ": 1 << 1, # Easy
|
|
"TD": 1 << 2, # Touch Device
|
|
"HD": 1 << 3, # Hidden
|
|
"HR": 1 << 4, # Hard Rock
|
|
"SD": 1 << 5, # Sudden Death
|
|
"DT": 1 << 6, # Double Time
|
|
"RX": 1 << 7, # Relax
|
|
"HT": 1 << 8, # Half Time
|
|
"NC": 1 << 9, # Nightcore
|
|
"FL": 1 << 10, # Flashlight
|
|
"AT": 1 << 11, # Autoplay
|
|
"SO": 1 << 12, # Spun Out
|
|
"AP": 1 << 13, # Auto Pilot
|
|
"PF": 1 << 14, # Perfect
|
|
"4K": 1 << 15, # 4K
|
|
"5K": 1 << 16, # 5K
|
|
"6K": 1 << 17, # 6K
|
|
"7K": 1 << 18, # 7K
|
|
"8K": 1 << 19, # 8K
|
|
"FI": 1 << 20, # Fade In
|
|
"RD": 1 << 21, # Random
|
|
"CN": 1 << 22, # Cinema
|
|
"TP": 1 << 23, # Target Practice
|
|
"9K": 1 << 24, # 9K
|
|
"CO": 1 << 25, # Key Co-op
|
|
"1K": 1 << 26, # 1K
|
|
"3K": 1 << 27, # 3K
|
|
"2K": 1 << 28, # 2K
|
|
"SV2": 1 << 29, # ScoreV2
|
|
"MR": 1 << 30, # Mirror
|
|
}
|
|
legacy_mod["NC"] |= legacy_mod["DT"]
|
|
legacy_mod["PF"] |= legacy_mod["SD"]
|
|
|
|
|
|
def api_mod_to_int(mods: list[APIMod]) -> int:
|
|
sum_ = 0
|
|
for mod in mods:
|
|
sum_ |= legacy_mod.get(mod["acronym"], 0)
|
|
return sum_
|
|
|
|
|
|
# https://github.com/ppy/osu/blob/master/osu.Game/Rulesets/Scoring/HitResult.cs
|
|
class HitResult(str, Enum):
|
|
PERFECT = "perfect" # [Order(0)]
|
|
GREAT = "great" # [Order(1)]
|
|
GOOD = "good" # [Order(2)]
|
|
OK = "ok" # [Order(3)]
|
|
MEH = "meh" # [Order(4)]
|
|
MISS = "miss" # [Order(5)]
|
|
|
|
LARGE_TICK_HIT = "large_tick_hit" # [Order(6)]
|
|
SMALL_TICK_HIT = "small_tick_hit" # [Order(7)]
|
|
SLIDER_TAIL_HIT = "slider_tail_hit" # [Order(8)]
|
|
|
|
LARGE_BONUS = "large_bonus" # [Order(9)]
|
|
SMALL_BONUS = "small_bonus" # [Order(10)]
|
|
|
|
LARGE_TICK_MISS = "large_tick_miss" # [Order(11)]
|
|
SMALL_TICK_MISS = "small_tick_miss" # [Order(12)]
|
|
|
|
IGNORE_HIT = "ignore_hit" # [Order(13)]
|
|
IGNORE_MISS = "ignore_miss" # [Order(14)]
|
|
|
|
NONE = "none" # [Order(15)]
|
|
COMBO_BREAK = "combo_break" # [Order(16)]
|
|
|
|
LEGACY_COMBO_INCREASE = "legacy_combo_increase" # [Order(99)] @deprecated
|
|
|
|
def is_hit(self) -> bool:
|
|
return self not in (
|
|
HitResult.NONE,
|
|
HitResult.IGNORE_MISS,
|
|
HitResult.COMBO_BREAK,
|
|
HitResult.LARGE_TICK_MISS,
|
|
HitResult.SMALL_TICK_MISS,
|
|
HitResult.MISS,
|
|
)
|
|
|
|
|
|
class LeaderboardType(Enum):
|
|
GLOBAL = "global"
|
|
FRIENDS = "friends"
|
|
COUNTRY = "country"
|
|
TEAM = "team"
|
|
|
|
|
|
# see static/mods.json
|
|
class Settings(TypedDict):
|
|
Name: str
|
|
Type: str
|
|
Label: str
|
|
Description: str
|
|
|
|
|
|
class Mod(TypedDict):
|
|
Acronym: str
|
|
Name: str
|
|
Description: str
|
|
Type: str
|
|
Settings: list[Settings]
|
|
IncompatibleMods: list[str]
|
|
RequiresConfiguration: bool
|
|
UserPlayable: bool
|
|
ValidForMultiplayer: bool
|
|
ValidForFreestyleAsRequiredMod: bool
|
|
ValidForMultiplayerAsFreeMod: bool
|
|
AlwaysValidForSubmission: bool
|
|
|
|
|
|
MODS: dict[int, dict[str, Mod]] = {}
|
|
|
|
ScoreStatistics = dict[HitResult, int]
|
|
|
|
|
|
def _init_mods():
|
|
mods_file = STATIC_DIR / "mods.json"
|
|
raw_mods = json.loads(mods_file.read_text())
|
|
for ruleset in raw_mods:
|
|
ruleset_mods = {}
|
|
for mod in ruleset["Mods"]:
|
|
ruleset_mods[mod["Acronym"]] = mod
|
|
MODS[ruleset["RulesetID"]] = ruleset_mods
|
|
|
|
|
|
class SoloScoreSubmissionInfo(BaseModel):
|
|
rank: Rank
|
|
total_score: int = Field(ge=0, le=2**31 - 1)
|
|
total_score_without_mods: int = Field(ge=0, le=2**31 - 1)
|
|
accuracy: float = Field(ge=0, le=1)
|
|
pp: float = Field(default=0, ge=0, le=2**31 - 1)
|
|
max_combo: int = 0
|
|
ruleset_id: Literal[0, 1, 2, 3]
|
|
passed: bool = False
|
|
mods: list[APIMod] = Field(default_factory=list)
|
|
statistics: ScoreStatistics = Field(default_factory=dict)
|
|
maximum_statistics: ScoreStatistics = Field(default_factory=dict)
|
|
|
|
@field_validator("mods", mode="after")
|
|
@classmethod
|
|
def validate_mods(cls, mods: list[APIMod], info: ValidationInfo):
|
|
if not MODS:
|
|
_init_mods()
|
|
incompatible_mods = set()
|
|
# check incompatible mods
|
|
for mod in mods:
|
|
if mod["acronym"] in incompatible_mods:
|
|
raise ValueError(
|
|
f"Mod {mod['acronym']} is incompatible with other mods"
|
|
)
|
|
setting_mods = MODS[info.data["ruleset_id"]].get(mod["acronym"])
|
|
if not setting_mods:
|
|
raise ValueError(f"Invalid mod: {mod['acronym']}")
|
|
incompatible_mods.update(setting_mods["IncompatibleMods"])
|
|
|
|
|
|
class LegacyReplaySoloScoreInfo(TypedDict):
|
|
online_id: int
|
|
mods: list[APIMod]
|
|
statistics: ScoreStatistics
|
|
maximum_statistics: ScoreStatistics
|
|
client_version: str
|
|
rank: Rank
|
|
user_id: int
|
|
total_score_without_mods: int
|