From 0b8beade5d0fd79042d4e9f11d6c8f75caeb1204 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 27 Jul 2025 03:00:22 +0000 Subject: [PATCH] refactor(mods): move models from `app.models.score` to `app.models.mods` --- app/models/mods.py | 119 +++++++++++++++++++++++++++++++------------- app/models/score.py | 95 ++--------------------------------- 2 files changed, 90 insertions(+), 124 deletions(-) diff --git a/app/models/mods.py b/app/models/mods.py index 529b89a..7b5e78d 100644 --- a/app/models/mods.py +++ b/app/models/mods.py @@ -1,47 +1,91 @@ from __future__ import annotations -from typing import TypedDict +import json +from typing import Literal, NotRequired, TypedDict + +from app.path import STATIC_DIR class APIMod(TypedDict): acronym: str - settings: dict[str, bool | float | str] + settings: NotRequired[dict[str, bool | float | str]] # https://github.com/ppy/osu-api/wiki#mods -LEGACY_MOD_TO_API_MOD = { - (1 << 0): APIMod(acronym="NF", settings={}), # No Fail - (1 << 1): APIMod(acronym="EZ", settings={}), - (1 << 2): APIMod(acronym="TD", settings={}), # Touch Device - (1 << 3): APIMod(acronym="HD", settings={}), # Hidden - (1 << 4): APIMod(acronym="HR", settings={}), # Hard Rock - (1 << 5): APIMod(acronym="SD", settings={}), # Sudden Death - (1 << 6): APIMod(acronym="DT", settings={}), # Double Time - (1 << 7): APIMod(acronym="RX", settings={}), # Relax - (1 << 8): APIMod(acronym="HT", settings={}), # Half Time - (1 << 9): APIMod(acronym="NC", settings={}), # Nightcore - (1 << 10): APIMod(acronym="FL", settings={}), # Flashlight - (1 << 11): APIMod(acronym="AT", settings={}), # Auto Play - (1 << 12): APIMod(acronym="SO", settings={}), # Spun Out - (1 << 13): APIMod(acronym="AP", settings={}), # Autopilot - (1 << 14): APIMod(acronym="PF", settings={}), # Perfect - (1 << 15): APIMod(acronym="4K", settings={}), # 4K - (1 << 16): APIMod(acronym="5K", settings={}), # 5K - (1 << 17): APIMod(acronym="6K", settings={}), # 6K - (1 << 18): APIMod(acronym="7K", settings={}), # 7K - (1 << 19): APIMod(acronym="8K", settings={}), # 8K - (1 << 20): APIMod(acronym="FI", settings={}), # Fade In - (1 << 21): APIMod(acronym="RD", settings={}), # Random - (1 << 22): APIMod(acronym="CN", settings={}), # Cinema - (1 << 23): APIMod(acronym="TP", settings={}), # Target Practice - (1 << 24): APIMod(acronym="9K", settings={}), # 9K - (1 << 25): APIMod(acronym="CO", settings={}), # Key Co-op - (1 << 26): APIMod(acronym="1K", settings={}), # 1K - (1 << 27): APIMod(acronym="2K", settings={}), # 2K - (1 << 28): APIMod(acronym="3K", settings={}), # 3K - (1 << 29): APIMod(acronym="SV2", settings={}), # Score V2 - (1 << 30): APIMod(acronym="MR", settings={}), # Mirror +API_MOD_TO_LEGACY: 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_TO_API_MOD = {} +for k, v in API_MOD_TO_LEGACY.items(): + LEGACY_MOD_TO_API_MOD[v] = APIMod(acronym=k, settings={}) +API_MOD_TO_LEGACY["NC"] |= API_MOD_TO_LEGACY["DT"] +API_MOD_TO_LEGACY["PF"] |= API_MOD_TO_LEGACY["SD"] + + +# 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 + + +API_MODS: dict[Literal[0, 1, 2, 3], dict[str, Mod]] = {} + + +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 + API_MODS[ruleset["RulesetID"]] = ruleset_mods def int_to_mods(mods: int) -> list[APIMod]: @@ -54,3 +98,10 @@ def int_to_mods(mods: int) -> list[APIMod]: if mods & (1 << 9): mod_list.remove(LEGACY_MOD_TO_API_MOD[(1 << 6)]) return mod_list + + +def mods_to_int(mods: list[APIMod]) -> int: + sum_ = 0 + for mod in mods: + sum_ |= API_MOD_TO_LEGACY.get(mod["acronym"], 0) + return sum_ diff --git a/app/models/score.py b/app/models/score.py index 680afd6..f038988 100644 --- a/app/models/score.py +++ b/app/models/score.py @@ -1,10 +1,9 @@ from __future__ import annotations from enum import Enum -import json -from typing import Any, Literal, TypedDict +from typing import Literal, TypedDict -from app.path import STATIC_DIR +from .mods import API_MOD_TO_LEGACY, API_MODS, APIMod, init_mods from pydantic import BaseModel, Field, ValidationInfo, field_validator import rosu_pp_py as rosu @@ -46,55 +45,6 @@ class Rank(str, Enum): 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)] @@ -140,44 +90,9 @@ class LeaderboardType(Enum): 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) @@ -194,8 +109,8 @@ class SoloScoreSubmissionInfo(BaseModel): @field_validator("mods", mode="after") @classmethod def validate_mods(cls, mods: list[APIMod], info: ValidationInfo): - if not MODS: - _init_mods() + if not API_MOD_TO_LEGACY: + init_mods() incompatible_mods = set() # check incompatible mods for mod in mods: @@ -203,7 +118,7 @@ class SoloScoreSubmissionInfo(BaseModel): raise ValueError( f"Mod {mod['acronym']} is incompatible with other mods" ) - setting_mods = MODS[info.data["ruleset_id"]].get(mod["acronym"]) + setting_mods = API_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"])