From 860ebe9fa96ab289ac5b257e906890d273a1cfa4 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Tue, 30 Sep 2025 20:47:04 +0800 Subject: [PATCH] feat(mods): configure ranked mods by file (#49) --- .gitignore | 3 + app/config.py | 5 - app/models/mods.py | 317 +++++++++++++++++++++++++--------- app/path.py | 1 + config/.gitkeep | 0 docker-compose-osurx.yml | 1 + docker-compose.yml | 1 + main.py | 3 + tools/generate_ranked_mods.py | 11 ++ tools/recalculate.py | 2 +- 10 files changed, 259 insertions(+), 85 deletions(-) create mode 100644 config/.gitkeep create mode 100644 tools/generate_ranked_mods.py diff --git a/.gitignore b/.gitignore index 364138f..f174e46 100644 --- a/.gitignore +++ b/.gitignore @@ -227,3 +227,6 @@ osu-web-master/* osu-web-master/.env.dusk.local.example osu-web-master/.env.example osu-web-master/.env.testing.example +config/* +!config/ +!config/.gitkeep diff --git a/app/config.py b/app/config.py index cf1107e..437de19 100644 --- a/app/config.py +++ b/app/config.py @@ -433,11 +433,6 @@ STORAGE_SETTINGS='{ ), "游戏设置", ] - enable_all_mods_pp: Annotated[ - bool, - Field(default=False, description="启用所有 Mod 的 PP 计算"), - "游戏设置", - ] enable_supporter_for_all_users: Annotated[ bool, Field(default=False, description="启用所有新注册用户的支持者状态"), diff --git a/app/models/mods.py b/app/models/mods.py index f168fae..88a375d 100644 --- a/app/models/mods.py +++ b/app/models/mods.py @@ -1,11 +1,15 @@ from __future__ import annotations -from copy import deepcopy +import hashlib 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.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): @@ -79,13 +83,15 @@ class Mod(TypedDict): 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")) -for ruleset in raw_mods: - ruleset_mods = {} - for mod in ruleset["Mods"]: - ruleset_mods[mod["Acronym"]] = mod - API_MODS[ruleset["RulesetID"]] = ruleset_mods + +def init_mods(): + mods_file = STATIC_DIR / "mods.json" + raw_mods = json.loads(mods_file.read_text(encoding="utf-8")) + 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]: @@ -107,91 +113,244 @@ def mods_to_int(mods: list[APIMod]) -> int: return sum_ -NO_CHECK = "DO_NO_CHECK" - -# FIXME: 这里为空表示了两种情况:mod 没有配置项;任何时候都可以获得 pp -# 如果是后者,则 mod 更新的时候可能会误判。 -COMMON_CONFIG: dict[str, dict] = { - "EZ": {"retries": 2}, - "NF": {}, - "HT": {"speed_change": 0.75, "adjust_pitch": NO_CHECK}, - "DC": {"speed_change": 0.75}, - "HR": {}, - "SD": {}, - "PF": {}, - "HD": {}, - "DT": {"speed_change": 1.5, "adjust_pitch": NO_CHECK}, - "NC": {"speed_change": 1.5}, - "FL": {"size_multiplier": 1.0, "combo_based_size": True}, - "AC": {}, - "MU": {}, - "TD": {}, +DEFAULT_RANKED_MODS = { + 0: { + "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": { + "fail_on_slider_tail": {"check": False, "type": "boolean"}, + "restart": {"check": False, "type": "boolean"}, + }, + "PF": {"restart": {"check": False, "type": "boolean"}}, + "HD": {"only_fade_approach_circles": {"type": "boolean", "eq": False}}, + "DT": {"speed_change": {"type": "number", "eq": 1.5}, "adjust_pitch": {"check": False, "type": "boolean"}}, + "NC": {"speed_change": {"type": "number", "eq": 1.5}}, + "FL": { + "follow_delay": {"type": "number", "eq": 1.0}, + "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"}, + }, + "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]] = { - 0: deepcopy(COMMON_CONFIG), - 1: deepcopy(COMMON_CONFIG), - 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"] = {} +RulesetRankedMods = dict[str, dict[str, Any]] +RankedMods = dict[int, RulesetRankedMods] +RANKED_MODS: RankedMods = {} -def mods_can_get_pp_vanilla(ruleset_id: int, mods: list[APIMod]) -> bool: - ranked_mods = RANKED_MODS[ruleset_id] - 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 +class _LegacyModSettings(BaseModel): + enable_all_mods_pp: bool = False -def mods_can_get_pp(ruleset_id: int, mods: list[APIMod]) -> bool: - if app_settings.enable_all_mods_pp: +def _get_mods_file_checksum() -> str: + 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 - 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: if app_settings.enable_rx and mod["acronym"] == "RX" and ruleset_id in {0, 1, 2}: continue if app_settings.enable_ap and mod["acronym"] == "AP" and ruleset_id == 0: continue - - mod["settings"] = mod.get("settings", {}) - if (settings := ranked_mods.get(mod["acronym"])) is None: + check_settings_result = check_settings(mod, ranked_mods[ruleset_id]) + if not check_settings_result: 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_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 = { 0: { "MR": {"reflection"}, diff --git a/app/path.py b/app/path.py index ce02502..63793b2 100644 --- a/app/path.py +++ b/app/path.py @@ -3,4 +3,5 @@ from __future__ import annotations from pathlib import Path STATIC_DIR = Path(__file__).parent.parent / "static" +CONFIG_DIR = Path(__file__).parent.parent / "config" ACHIEVEMENTS_DIR = Path(__file__).parent / "achievements" diff --git a/config/.gitkeep b/config/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose-osurx.yml b/docker-compose-osurx.yml index ffaaad2..9cd1c92 100644 --- a/docker-compose-osurx.yml +++ b/docker-compose-osurx.yml @@ -30,6 +30,7 @@ services: - ./static:/app/static - ./logs:/app/logs - ./newrelic.ini:/app/newrelic.ini:ro + - ./config:/app/config restart: unless-stopped networks: - osu-network diff --git a/docker-compose.yml b/docker-compose.yml index f4350d2..1a82af3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,7 @@ services: - ./static:/app/static - ./logs:/app/logs - ./newrelic.ini:/app/newrelic.ini:ro + - ./config:/app/config restart: unless-stopped networks: - osu-network diff --git a/main.py b/main.py index 86436e4..36700b0 100644 --- a/main.py +++ b/main.py @@ -10,6 +10,7 @@ from app.dependencies.fetcher import get_fetcher from app.dependencies.scheduler import start_scheduler, stop_scheduler from app.log import logger from app.middleware.verify_session import VerifySessionMiddleware +from app.models.mods import init_mods, init_ranked_mods from app.router import ( api_v1_router, api_v2_router, @@ -51,6 +52,8 @@ import sentry_sdk @asynccontextmanager async def lifespan(app: FastAPI): # on startup + init_mods() + init_ranked_mods() await FastAPILimiter.init(get_redis()) await get_fetcher() # 初始化 fetcher await init_geoip() # 初始化 GeoIP 数据库 diff --git a/tools/generate_ranked_mods.py b/tools/generate_ranked_mods.py new file mode 100644 index 0000000..bfe31b8 --- /dev/null +++ b/tools/generate_ranked_mods.py @@ -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) diff --git a/tools/recalculate.py b/tools/recalculate.py index b5c0fc6..791488d 100644 --- a/tools/recalculate.py +++ b/tools/recalculate.py @@ -242,7 +242,7 @@ async def _recalculate_statistics(statistics: UserStatistics, session: AsyncSess for score in scores: 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.total_score += score.total_score