* feat(custom_ruleset): add custom rulesets support * feat(custom-ruleset): add version check * feat(custom-ruleset): add LegacyIO API to get ruleset hashes * feat(pp): add check for rulesets whose pp cannot be calculated * docs(readme): update README to include support for custom rulesets * fix(custom-ruleset): make `rulesets` empty instead of throw a error when version check is disabled Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * chore(custom-ruleset): apply the latest changes of generatorc891bcd159ande25041ad3b* feat(calculator): add fallback performance calculation for unsupported modes * fix(calculator): remove debug print * fix: resolve reviews * feat(calculator): add difficulty calculation checks --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
414 lines
15 KiB
Python
414 lines
15 KiB
Python
import hashlib
|
|
import json
|
|
from typing import Any, Literal, NotRequired, TypedDict
|
|
|
|
from app.config import settings as app_settings
|
|
from app.log import log
|
|
from app.path import CONFIG_DIR, STATIC_DIR
|
|
|
|
from pydantic import ConfigDict, Field, create_model
|
|
from pydantic.main import BaseModel
|
|
|
|
|
|
class APIMod(TypedDict):
|
|
acronym: str
|
|
settings: NotRequired[dict[str, bool | float | str | int]]
|
|
|
|
|
|
# https://github.com/ppy/osu-api/wiki#mods
|
|
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(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]:
|
|
mod_list = []
|
|
for mod in range(31):
|
|
if mods & (1 << mod):
|
|
mod_list.append(LEGACY_MOD_TO_API_MOD[(1 << mod)])
|
|
if mods & (1 << 14):
|
|
mod_list.remove(LEGACY_MOD_TO_API_MOD[(1 << 5)])
|
|
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_
|
|
|
|
|
|
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,
|
|
}
|
|
|
|
RulesetRankedMods = dict[str, dict[str, Any]]
|
|
RankedMods = dict[int, RulesetRankedMods]
|
|
RANKED_MODS: RankedMods = {}
|
|
|
|
|
|
class _LegacyModSettings(BaseModel):
|
|
enable_all_mods_pp: bool = False
|
|
|
|
|
|
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:
|
|
log("Mod").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
|
|
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
|
|
check_settings_result = check_settings(mod, ranked_mods.get(ruleset_id, {}))
|
|
if not check_settings_result:
|
|
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"},
|
|
"AC": {"accuracy_judge_mode"},
|
|
"BR": {"direction"},
|
|
"AD": {"style"},
|
|
},
|
|
1: {"AC": {"accuracy_judge_mode"}},
|
|
2: {"AC": {"accuracy_judge_mode"}},
|
|
3: {"AC": {"accuracy_judge_mode"}},
|
|
}
|
|
|
|
|
|
def parse_enum_to_str(ruleset_id: int, mods: list[APIMod]):
|
|
for mod in mods:
|
|
if mod["acronym"] in ENUM_TO_STR.get(ruleset_id, {}):
|
|
for setting in mod.get("settings", {}):
|
|
if setting in ENUM_TO_STR[ruleset_id][mod["acronym"]]:
|
|
mod["settings"][setting] = str(mod["settings"][setting]) # pyright: ignore[reportTypedDictNotRequiredAccess]
|
|
|
|
|
|
def mod_to_save(mods: list[APIMod]) -> list[str]:
|
|
s = list({mod["acronym"] for mod in mods})
|
|
s.sort()
|
|
return s
|
|
|
|
|
|
def get_speed_rate(mods: list[APIMod]):
|
|
rate = 1.0
|
|
for mod in mods:
|
|
if mod["acronym"] in {"DT", "NC", "HT", "DC"}:
|
|
rate *= mod.get("settings", {}).get("speed_change", 1.0) # pyright: ignore[reportOperatorIssue]
|
|
return rate
|
|
|
|
|
|
def get_available_mods(ruleset_id: int, required_mods: list[APIMod]) -> list[APIMod]:
|
|
if ruleset_id not in API_MODS:
|
|
return []
|
|
|
|
ruleset_mods = API_MODS[ruleset_id]
|
|
required_mod_acronyms = {mod["acronym"] for mod in required_mods}
|
|
|
|
incompatible_mods = set()
|
|
for mod_acronym in required_mod_acronyms:
|
|
if mod_acronym in ruleset_mods:
|
|
incompatible_mods.update(ruleset_mods[mod_acronym]["IncompatibleMods"])
|
|
|
|
available_mods = []
|
|
for mod_acronym, mod_data in ruleset_mods.items():
|
|
if mod_acronym in required_mod_acronyms:
|
|
continue
|
|
|
|
if mod_acronym in incompatible_mods:
|
|
continue
|
|
|
|
if any(required_acronym in mod_data["IncompatibleMods"] for required_acronym in required_mod_acronyms):
|
|
continue
|
|
|
|
if mod_data.get("UserPlayable", False):
|
|
available_mods.append(mod_acronym)
|
|
|
|
return [APIMod(acronym=acronym) for acronym in available_mods]
|