feat(mods): configure ranked mods by file (#49)

This commit is contained in:
MingxuanGame
2025-09-30 20:47:04 +08:00
committed by GitHub
parent 017b058e63
commit 860ebe9fa9
10 changed files with 259 additions and 85 deletions

3
.gitignore vendored
View File

@@ -227,3 +227,6 @@ osu-web-master/*
osu-web-master/.env.dusk.local.example osu-web-master/.env.dusk.local.example
osu-web-master/.env.example osu-web-master/.env.example
osu-web-master/.env.testing.example osu-web-master/.env.testing.example
config/*
!config/
!config/.gitkeep

View File

@@ -433,11 +433,6 @@ STORAGE_SETTINGS='{
), ),
"游戏设置", "游戏设置",
] ]
enable_all_mods_pp: Annotated[
bool,
Field(default=False, description="启用所有 Mod 的 PP 计算"),
"游戏设置",
]
enable_supporter_for_all_users: Annotated[ enable_supporter_for_all_users: Annotated[
bool, bool,
Field(default=False, description="启用所有新注册用户的支持者状态"), Field(default=False, description="启用所有新注册用户的支持者状态"),

View File

@@ -1,11 +1,15 @@
from __future__ import annotations from __future__ import annotations
from copy import deepcopy import hashlib
import json import json
from typing import Literal, NotRequired, TypedDict from typing import Any, Literal, NotRequired, TypedDict
from app.config import settings as app_settings from app.config import settings as app_settings
from app.path import STATIC_DIR from app.log import logger
from app.path import CONFIG_DIR, STATIC_DIR
from pydantic import ConfigDict, Field, create_model
from pydantic.main import BaseModel
class APIMod(TypedDict): class APIMod(TypedDict):
@@ -79,13 +83,15 @@ class Mod(TypedDict):
API_MODS: dict[Literal[0, 1, 2, 3], dict[str, Mod]] = {} API_MODS: dict[Literal[0, 1, 2, 3], dict[str, Mod]] = {}
mods_file = STATIC_DIR / "mods.json"
raw_mods = json.loads(mods_file.read_text(encoding="utf-8")) def init_mods():
for ruleset in raw_mods: mods_file = STATIC_DIR / "mods.json"
ruleset_mods = {} raw_mods = json.loads(mods_file.read_text(encoding="utf-8"))
for mod in ruleset["Mods"]: for ruleset in raw_mods:
ruleset_mods[mod["Acronym"]] = mod ruleset_mods = {}
API_MODS[ruleset["RulesetID"]] = 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]: def int_to_mods(mods: int) -> list[APIMod]:
@@ -107,91 +113,244 @@ def mods_to_int(mods: list[APIMod]) -> int:
return sum_ return sum_
NO_CHECK = "DO_NO_CHECK" DEFAULT_RANKED_MODS = {
0: {
# FIXME: 这里为空表示了两种情况mod 没有配置项;任何时候都可以获得 pp "EZ": {"retries": {"type": "number", "eq": 2}},
# 如果是后者,则 mod 更新的时候可能会误判。 "NF": {},
COMMON_CONFIG: dict[str, dict] = { "HT": {"speed_change": {"type": "number", "eq": 0.75}, "adjust_pitch": {"check": False, "type": "boolean"}},
"EZ": {"retries": 2}, "DC": {"speed_change": {"type": "number", "eq": 0.75}},
"NF": {}, "HR": {},
"HT": {"speed_change": 0.75, "adjust_pitch": NO_CHECK}, "SD": {
"DC": {"speed_change": 0.75}, "fail_on_slider_tail": {"check": False, "type": "boolean"},
"HR": {}, "restart": {"check": False, "type": "boolean"},
"SD": {}, },
"PF": {}, "PF": {"restart": {"check": False, "type": "boolean"}},
"HD": {}, "HD": {"only_fade_approach_circles": {"type": "boolean", "eq": False}},
"DT": {"speed_change": 1.5, "adjust_pitch": NO_CHECK}, "DT": {"speed_change": {"type": "number", "eq": 1.5}, "adjust_pitch": {"check": False, "type": "boolean"}},
"NC": {"speed_change": 1.5}, "NC": {"speed_change": {"type": "number", "eq": 1.5}},
"FL": {"size_multiplier": 1.0, "combo_based_size": True}, "FL": {
"AC": {}, "follow_delay": {"type": "number", "eq": 1.0},
"MU": {}, "size_multiplier": {"type": "number", "eq": 1.0},
"TD": {}, "combo_based_size": {"type": "boolean", "eq": True},
},
"AC": {
"minimum_accuracy": {"check": False, "type": "number"},
"accuracy_judge_mode": {"check": False, "type": "string"},
"restart": {"check": False, "type": "boolean"},
},
"MU": {
"inverse_muting": {"check": False, "type": "boolean"},
"enable_metronome": {"check": False, "type": "boolean"},
"mute_combo_count": {"check": False, "type": "number"},
"affects_hit_sounds": {"check": False, "type": "boolean"},
},
"TD": {},
"BL": {},
"NS": {"hidden_combo_count": {"check": False, "type": "number"}},
"SO": {},
"TC": {},
},
1: {
"EZ": {},
"NF": {},
"HT": {"speed_change": {"type": "number", "eq": 0.75}, "adjust_pitch": {"check": False, "type": "boolean"}},
"DC": {"speed_change": {"type": "number", "eq": 0.75}},
"HR": {},
"SD": {"restart": {"check": False, "type": "boolean"}},
"PF": {"restart": {"check": False, "type": "boolean"}},
"HD": {},
"DT": {"speed_change": {"type": "number", "eq": 1.5}, "adjust_pitch": {"check": False, "type": "boolean"}},
"NC": {"speed_change": {"type": "number", "eq": 1.5}},
"FL": {"size_multiplier": {"type": "number", "eq": 1.0}, "combo_based_size": {"type": "boolean", "eq": True}},
"AC": {
"minimum_accuracy": {"check": False, "type": "number"},
"accuracy_judge_mode": {"check": False, "type": "string"},
"restart": {"check": False, "type": "boolean"},
},
"MU": {
"inverse_muting": {"check": False, "type": "boolean"},
"enable_metronome": {"check": False, "type": "boolean"},
"mute_combo_count": {"check": False, "type": "number"},
"affects_hit_sounds": {"check": False, "type": "boolean"},
},
},
2: {
"EZ": {"retries": {"type": "number", "eq": 2}},
"NF": {},
"HT": {"speed_change": {"type": "number", "eq": 0.75}, "adjust_pitch": {"check": False, "type": "boolean"}},
"DC": {"speed_change": {"type": "number", "eq": 0.75}},
"HR": {},
"SD": {"restart": {"check": False, "type": "boolean"}},
"PF": {"restart": {"check": False, "type": "boolean"}},
"HD": {},
"DT": {"speed_change": {"type": "number", "eq": 1.5}, "adjust_pitch": {"check": False, "type": "boolean"}},
"NC": {"speed_change": {"type": "number", "eq": 1.5}},
"FL": {"size_multiplier": {"type": "number", "eq": 1.0}, "combo_based_size": {"type": "boolean", "eq": True}},
"AC": {
"minimum_accuracy": {"check": False, "type": "number"},
"accuracy_judge_mode": {"check": False, "type": "string"},
"restart": {"check": False, "type": "boolean"},
},
"MU": {
"inverse_muting": {"check": False, "type": "boolean"},
"enable_metronome": {"check": False, "type": "boolean"},
"mute_combo_count": {"check": False, "type": "number"},
"affects_hit_sounds": {"check": False, "type": "boolean"},
},
"NS": {"hidden_combo_count": {"check": False, "type": "number"}},
},
3: {
"EZ": {"retries": {"type": "number", "eq": 2}},
"NF": {},
"HT": {"speed_change": {"type": "number", "eq": 0.75}, "adjust_pitch": {"check": False, "type": "boolean"}},
"DC": {"speed_change": {"type": "number", "eq": 0.75}},
"SD": {"restart": {"check": False, "type": "boolean"}},
"PF": {
"require_perfect_hits": {"check": False, "type": "boolean"},
"restart": {"check": False, "type": "boolean"},
},
"HD": {},
"DT": {"speed_change": {"type": "number", "eq": 1.5}, "adjust_pitch": {"check": False, "type": "boolean"}},
"NC": {"speed_change": {"type": "number", "eq": 1.5}},
"FL": {"size_multiplier": {"type": "number", "eq": 1.0}, "combo_based_size": {"type": "boolean", "eq": False}},
"AC": {
"minimum_accuracy": {"check": False, "type": "number"},
"accuracy_judge_mode": {"check": False, "type": "string"},
"restart": {"check": False, "type": "boolean"},
},
"MU": {
"inverse_muting": {"check": False, "type": "boolean"},
"enable_metronome": {"check": False, "type": "boolean"},
"mute_combo_count": {"check": False, "type": "number"},
"affects_hit_sounds": {"check": False, "type": "boolean"},
},
"MR": {},
"4K": {},
"5K": {},
"6K": {},
"7K": {},
"8K": {},
"9K": {},
},
}
TYPE_TO_PY = {
"number": int | float,
"boolean": bool,
"string": str,
} }
RANKED_MODS: dict[int, dict[str, dict]] = { RulesetRankedMods = dict[str, dict[str, Any]]
0: deepcopy(COMMON_CONFIG), RankedMods = dict[int, RulesetRankedMods]
1: deepcopy(COMMON_CONFIG), RANKED_MODS: RankedMods = {}
2: deepcopy(COMMON_CONFIG),
3: deepcopy(COMMON_CONFIG),
}
# osu
RANKED_MODS[0]["HD"]["only_fade_approach_circles"] = False
RANKED_MODS[0]["FL"]["follow_delay"] = 1.0
RANKED_MODS[0]["BL"] = {}
RANKED_MODS[0]["NS"] = {}
RANKED_MODS[0]["SO"] = {}
RANKED_MODS[0]["TC"] = {}
# taiko
del RANKED_MODS[1]["EZ"]["retries"]
# catch
RANKED_MODS[2]["NS"] = {}
# mania
del RANKED_MODS[3]["HR"]
RANKED_MODS[3]["FL"]["combo_based_size"] = False
RANKED_MODS[3]["MR"] = {}
for i in range(4, 10):
RANKED_MODS[3][f"{i}K"] = {}
def mods_can_get_pp_vanilla(ruleset_id: int, mods: list[APIMod]) -> bool: class _LegacyModSettings(BaseModel):
ranked_mods = RANKED_MODS[ruleset_id] enable_all_mods_pp: bool = False
for mod in mods:
mod["settings"] = mod.get("settings", {})
if (settings := ranked_mods.get(mod["acronym"])) is None:
return False
if settings == {}:
continue
for setting, value in mod["settings"].items():
if (expected_value := settings.get(setting)) is None:
return False
if expected_value != NO_CHECK and value != expected_value:
return False
return True
def mods_can_get_pp(ruleset_id: int, mods: list[APIMod]) -> bool: def _get_mods_file_checksum() -> str:
if app_settings.enable_all_mods_pp: current_mods_file = STATIC_DIR / "mods.json"
if not current_mods_file.exists():
return ""
return hashlib.md5(current_mods_file.read_bytes(), usedforsecurity=False).hexdigest()
def generate_ranked_mod_settings(enable_all: bool = False):
ranked_mods_file = CONFIG_DIR / "ranked_mods.json"
checksum = _get_mods_file_checksum()
legacy_setting = _LegacyModSettings.model_validate(app_settings.model_dump())
if not legacy_setting.enable_all_mods_pp and not enable_all:
result = DEFAULT_RANKED_MODS
else:
result = {}
for ruleset_id, ruleset_mods in API_MODS.items():
result[ruleset_id] = {}
for mod_acronym in ruleset_mods:
result[ruleset_id][mod_acronym] = {}
if not enable_all:
logger.info("ENABLE_ALL_MODS_PP is deprecated, transformed to config/ranked_mods.json")
result["$mods_checksum"] = checksum # pyright: ignore[reportArgumentType]
ranked_mods_file.write_text(json.dumps(result, indent=4))
def init_ranked_mods():
ranked_mods_file = CONFIG_DIR / "ranked_mods.json"
if ranked_mods_file.exists():
raw_ranked_mods = json.loads(ranked_mods_file.read_text(encoding="utf-8"))
mods_file_checksum = raw_ranked_mods.pop("$mods_checksum", None)
if mods_file_checksum is not None and mods_file_checksum != (current_checksum := _get_mods_file_checksum()):
raise RuntimeError(
f"Mods file has changed, please modify ranked_mods.json or delete it to regenerate\n"
f"Current mods checksum: {current_checksum}"
)
for ruleset_id_str, mods in raw_ranked_mods.items():
ruleset_id = int(ruleset_id_str)
RANKED_MODS[ruleset_id] = mods
else:
generate_ranked_mod_settings()
init_ranked_mods()
def _generate_model(settings: dict[str, dict[str, Any]]) -> type[BaseModel]:
fields = {}
for setting, validation in settings.items():
type_ = validation.get("type")
if type_ is None:
raise ValueError("Type is required")
py_type = TYPE_TO_PY.get(type_)
if py_type is None:
raise ValueError(f"Unknown type: {type_}")
if validation.get("check", True) is False:
fields[setting] = (Any, None)
elif (const_value := validation.get("eq")) is not None:
fields[setting] = (Literal[const_value], const_value)
elif (some_values := validation.get("in")) is not None:
if not isinstance(some_values, list) or len(some_values) == 0:
raise ValueError("In must be a non-empty list")
fields[setting] = (Literal[*some_values], some_values[0])
else:
copy = validation.copy()
copy.pop("type", None)
fields[setting] = (py_type | None, Field(default=None, **copy))
if not fields:
raise ValueError("No fields")
return create_model("ModSettingsValidator", __config__=ConfigDict(extra="forbid"), **fields)
def check_settings(mod: APIMod, ranked_mods: RulesetRankedMods) -> bool:
if (settings := ranked_mods.get(mod["acronym"])) is None:
return False
if settings == {}:
return True return True
ranked_mods = RANKED_MODS[ruleset_id] model = _generate_model(settings)
try:
model.model_validate(mod.get("settings", {}))
return True
except ValueError:
return False
def _mods_can_get_pp(ruleset_id: int, mods: list[APIMod], ranked_mods: RankedMods) -> bool:
for mod in mods: for mod in mods:
if app_settings.enable_rx and mod["acronym"] == "RX" and ruleset_id in {0, 1, 2}: if app_settings.enable_rx and mod["acronym"] == "RX" and ruleset_id in {0, 1, 2}:
continue continue
if app_settings.enable_ap and mod["acronym"] == "AP" and ruleset_id == 0: if app_settings.enable_ap and mod["acronym"] == "AP" and ruleset_id == 0:
continue continue
check_settings_result = check_settings(mod, ranked_mods[ruleset_id])
mod["settings"] = mod.get("settings", {}) if not check_settings_result:
if (settings := ranked_mods.get(mod["acronym"])) is None:
return False return False
if settings == {}:
continue
for setting, value in mod["settings"].items():
if (expected_value := settings.get(setting)) is None:
return False
if expected_value != NO_CHECK and value != expected_value:
return False
return True return True
def mods_can_get_pp_vanilla(ruleset_id: int, mods: list[APIMod]) -> bool:
return _mods_can_get_pp(ruleset_id, mods, DEFAULT_RANKED_MODS)
def mods_can_get_pp(ruleset_id: int, mods: list[APIMod]) -> bool:
return _mods_can_get_pp(ruleset_id, mods, RANKED_MODS)
ENUM_TO_STR = { ENUM_TO_STR = {
0: { 0: {
"MR": {"reflection"}, "MR": {"reflection"},

View File

@@ -3,4 +3,5 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
STATIC_DIR = Path(__file__).parent.parent / "static" STATIC_DIR = Path(__file__).parent.parent / "static"
CONFIG_DIR = Path(__file__).parent.parent / "config"
ACHIEVEMENTS_DIR = Path(__file__).parent / "achievements" ACHIEVEMENTS_DIR = Path(__file__).parent / "achievements"

0
config/.gitkeep Normal file
View File

View File

@@ -30,6 +30,7 @@ services:
- ./static:/app/static - ./static:/app/static
- ./logs:/app/logs - ./logs:/app/logs
- ./newrelic.ini:/app/newrelic.ini:ro - ./newrelic.ini:/app/newrelic.ini:ro
- ./config:/app/config
restart: unless-stopped restart: unless-stopped
networks: networks:
- osu-network - osu-network

View File

@@ -27,6 +27,7 @@ services:
- ./static:/app/static - ./static:/app/static
- ./logs:/app/logs - ./logs:/app/logs
- ./newrelic.ini:/app/newrelic.ini:ro - ./newrelic.ini:/app/newrelic.ini:ro
- ./config:/app/config
restart: unless-stopped restart: unless-stopped
networks: networks:
- osu-network - osu-network

View File

@@ -10,6 +10,7 @@ from app.dependencies.fetcher import get_fetcher
from app.dependencies.scheduler import start_scheduler, stop_scheduler from app.dependencies.scheduler import start_scheduler, stop_scheduler
from app.log import logger from app.log import logger
from app.middleware.verify_session import VerifySessionMiddleware from app.middleware.verify_session import VerifySessionMiddleware
from app.models.mods import init_mods, init_ranked_mods
from app.router import ( from app.router import (
api_v1_router, api_v1_router,
api_v2_router, api_v2_router,
@@ -51,6 +52,8 @@ import sentry_sdk
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# on startup # on startup
init_mods()
init_ranked_mods()
await FastAPILimiter.init(get_redis()) await FastAPILimiter.init(get_redis())
await get_fetcher() # 初始化 fetcher await get_fetcher() # 初始化 fetcher
await init_geoip() # 初始化 GeoIP 数据库 await init_geoip() # 初始化 GeoIP 数据库

View File

@@ -0,0 +1,11 @@
from __future__ import annotations
import os
import sys
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from app.models.mods import generate_ranked_mod_settings, init_mods
if __name__ == "__main__":
init_mods()
generate_ranked_mod_settings(enable_all="--all" in sys.argv)

View File

@@ -242,7 +242,7 @@ async def _recalculate_statistics(statistics: UserStatistics, session: AsyncSess
for score in scores: for score in scores:
beatmap: Beatmap = score.beatmap beatmap: Beatmap = score.beatmap
ranked = beatmap.beatmap_status.has_pp() | settings.enable_all_mods_pp ranked = beatmap.beatmap_status.has_pp() | settings.enable_all_beatmap_pp
statistics.play_count += 1 statistics.play_count += 1
statistics.total_score += score.total_score statistics.total_score += score.total_score