feat(solo-score): support submit solo scores
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum, IntEnum
|
||||
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
|
||||
|
||||
|
||||
@@ -30,40 +35,186 @@ INT_TO_MODE = {v: k for k, v in MODE_TO_INT.items()}
|
||||
|
||||
|
||||
class Rank(str, Enum):
|
||||
X = "ss"
|
||||
XH = "ssh"
|
||||
S = "s"
|
||||
SH = "sh"
|
||||
A = "a"
|
||||
B = "b"
|
||||
C = "c"
|
||||
D = "d"
|
||||
F = "f"
|
||||
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(IntEnum):
|
||||
PERFECT = 0 # [Order(0)]
|
||||
GREAT = 1 # [Order(1)]
|
||||
GOOD = 2 # [Order(2)]
|
||||
OK = 3 # [Order(3)]
|
||||
MEH = 4 # [Order(4)]
|
||||
MISS = 5 # [Order(5)]
|
||||
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 = 6 # [Order(6)]
|
||||
SMALL_TICK_HIT = 7 # [Order(7)]
|
||||
SLIDER_TAIL_HIT = 8 # [Order(8)]
|
||||
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 = 9 # [Order(9)]
|
||||
SMALL_BONUS = 10 # [Order(10)]
|
||||
LARGE_BONUS = "large_bonus" # [Order(9)]
|
||||
SMALL_BONUS = "small_bonus" # [Order(10)]
|
||||
|
||||
LARGE_TICK_MISS = 11 # [Order(11)]
|
||||
SMALL_TICK_MISS = 12 # [Order(12)]
|
||||
LARGE_TICK_MISS = "large_tick_miss" # [Order(11)]
|
||||
SMALL_TICK_MISS = "small_tick_miss" # [Order(12)]
|
||||
|
||||
IGNORE_HIT = 13 # [Order(13)]
|
||||
IGNORE_MISS = 14 # [Order(14)]
|
||||
IGNORE_HIT = "ignore_hit" # [Order(13)]
|
||||
IGNORE_MISS = "ignore_miss" # [Order(14)]
|
||||
|
||||
NONE = 15 # [Order(15)]
|
||||
COMBO_BREAK = 16 # [Order(16)]
|
||||
NONE = "none" # [Order(15)]
|
||||
COMBO_BREAK = "combo_break" # [Order(16)]
|
||||
|
||||
LEGACY_COMBO_INCREASE = 99 # [Order(99)] @deprecated
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user