From 33f321952d0ee9a6a1928c2680fcc0aec6e67de8 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 26 Oct 2025 21:10:36 +0800 Subject: [PATCH] feat(custom-rulesets): support custom rulesets (#23) * 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 generator https://github.com/GooGuTeam/custom-rulesets/commit/c891bcd1599c7d6329bdda73b156949d8006c193 and https://github.com/GooGuTeam/custom-rulesets/commit/e25041ad3ba372155a37be8d7d2572779873aa44 * 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> --- README.en.md | 24 +- README.md | 24 +- app/achievements/hush_hush.py | 53 +- app/calculator.py | 38 +- app/calculators/performance/_base.py | 23 +- .../performance/performance_server.py | 63 +- app/calculators/performance/rosu.py | 25 +- app/config.py | 10 + app/database/playlists.py | 2 +- app/models/mods.py | 2 +- app/models/performance.py | 36 +- app/models/score.py | 116 +- app/router/lio.py | 8 +- app/router/v1/beatmap.py | 2 +- app/router/v2/beatmap.py | 7 +- app/router/v2/score.py | 36 +- app/tasks/__init__.py | 3 +- ...rx_statistics.py => special_statistics.py} | 31 + main.py | 6 +- ...2b4fd_gamemode_add_custom_rulesets_for_.py | 122 + scripts/generate_ruleset_attributes.py | 2 +- static/custom_ruleset_version_hash.json | 32 + static/mods.json | 2540 ++++++++++++++++- tools/recalculate.py | 3 +- 24 files changed, 3134 insertions(+), 74 deletions(-) rename app/tasks/{osu_rx_statistics.py => special_statistics.py} (65%) create mode 100644 migrations/versions/2025-10-25_2d395ba2b4fd_gamemode_add_custom_rulesets_for_.py create mode 100644 static/custom_ruleset_version_hash.json diff --git a/README.en.md b/README.en.md index 6d87539..42b79b3 100644 --- a/README.en.md +++ b/README.en.md @@ -15,12 +15,32 @@ This is an osu! API server implemented with FastAPI + MySQL + Redis, supporting - **OAuth 2.0 Authentication**: Supports password and refresh token flows. - **User Data Management**: Complete user information, statistics, achievements, etc. -- **Multi-game Mode Support**: osu! (RX, AP), taiko (RX), catch (RX), mania. +- **Multi-game Mode Support**: osu! (RX, AP), taiko (RX), catch (RX), mania and custom rulesets (see below). - **Database Persistence**: MySQL for storing user data. - **Cache Support**: Redis for caching tokens and session information. - **Multiple Storage Backends**: Supports local storage, Cloudflare R2, and AWS S3. - **Containerized Deployment**: Docker and Docker Compose support. +## Supported Rulesets + +**Ruleset**|**ID**|**ShortName**|**PP Algorithm (rosu)**|**PP Algorithm (performance-server)** +:-----:|:-----:|:-----:|:-----:|:-----: +osu!|`0`|`osu`|✅|✅ +osu!taiko|`1`|`taiko`|✅|✅ +osu!catch|`2`|`fruits`|✅|✅ +osu!mania|`3`|`mania`|✅|✅ +osu! (RX)|`4`|`osurx`|✅|✅ +osu! (AP)|`5`|`osuap`|✅|✅ +osu!taiko (RX)|`6`|`taikorx`|✅|✅ +osu!catch (RX)|`7`|`fruitsrx`|✅|✅ +[Sentakki](https://github.com/LumpBloom7/sentakki)|`10`|`Sentakki`|❌|❌ +[tau](https://github.com/taulazer/tau)|`11`|`tau`|❌|✅ +[Rush!](https://github.com/Beamographic/rush)|`12`|`rush`|❌|❌ +[hishigata](https://github.com/LumpBloom7/hishigata)|`13`|`hishigata`|❌|❌ +[soyokaze!](https://github.com/goodtrailer/soyokaze)|`14`|`soyokaze`|❌|✅ + +Go to [custom-rulesets](https://github.com/GooGuTeam/custom-rulesets) to download the custom rulesets modified for g0v0-server. + ## Quick Start ### Using Docker Compose (Recommended) @@ -102,7 +122,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d -This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!⏎ +This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! ## Discussion diff --git a/README.md b/README.md index 8bfca35..20dfb30 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,33 @@ - **OAuth 2.0 认证**: 支持密码流和刷新令牌流 - **用户数据管理**: 完整的用户信息、统计数据、成就等 -- **多游戏模式支持**: osu! (RX, AP), taiko (RX), catch (RX), mania +- **多游戏模式支持**: osu! (RX, AP), taiko (RX), catch (RX), mania 和自定义 ruleset(见下) - **数据库持久化**: MySQL 存储用户数据 - **缓存支持**: Redis 缓存令牌和会话信息 - **多种存储后端**: 支持本地存储、Cloudflare R2、AWS S3 - **容器化部署**: Docker 和 Docker Compose 支持 - **资源文件反向代理**: 可以将 osu! 官方的资源链接(头像、谱面封面、音频等)替换为自定义域名。 +## 支持的 ruleset + +**Ruleset**|**ID**|**ShortName**|**PP 算法 (rosu)**|**PP 算法 (performance-server)** +:-----:|:-----:|:-----:|:-----:|:-----: +osu!|`0`|`osu`|✅|✅ +osu!taiko|`1`|`taiko`|✅|✅ +osu!catch|`2`|`fruits`|✅|✅ +osu!mania|`3`|`mania`|✅|✅ +osu! (RX)|`4`|`osurx`|✅|✅ +osu! (AP)|`5`|`osuap`|✅|✅ +osu!taiko (RX)|`6`|`taikorx`|✅|✅ +osu!catch (RX)|`7`|`fruitsrx`|✅|✅ +[Sentakki](https://github.com/LumpBloom7/sentakki)|`10`|`Sentakki`|❌|❌ +[tau](https://github.com/taulazer/tau)|`11`|`tau`|❌|✅ +[Rush!](https://github.com/Beamographic/rush)|`12`|`rush`|❌|❌ +[hishigata](https://github.com/LumpBloom7/hishigata)|`13`|`hishigata`|❌|❌ +[soyokaze!](https://github.com/goodtrailer/soyokaze)|`14`|`soyokaze`|❌|✅ + +前往 [custom-rulesets](https://github.com/GooGuTeam/custom-rulesets/releases/latest) 下载为 g0v0-server 修改的自定义 ruleset。 + ## 快速开始 ### 使用 Docker Compose (推荐) @@ -113,5 +133,3 @@ docker-compose -f docker-compose-osurx.yml up -d - QQ 群:`1059561526` - Discord: https://discord.gg/AhzJXXWYfF - - diff --git a/app/achievements/hush_hush.py b/app/achievements/hush_hush.py index 178b033..8c02da4 100644 --- a/app/achievements/hush_hush.py +++ b/app/achievements/hush_hush.py @@ -1,5 +1,6 @@ from datetime import datetime +from app.calculator import get_calculator from app.database.beatmap import calculate_beatmap_attributes from app.database.score import Beatmap, Score from app.dependencies.database import get_redis @@ -7,11 +8,24 @@ from app.dependencies.fetcher import get_fetcher from app.models.achievement import Achievement, Medals from app.models.beatmap import BeatmapRankStatus from app.models.mods import get_speed_rate, mod_to_save +from app.models.performance import DifficultyAttributesUnion from app.models.score import Rank from sqlmodel.ext.asyncio.session import AsyncSession +async def _calculate_attributes(score: Score, beatmap: Beatmap) -> DifficultyAttributesUnion | None: + fetcher = await get_fetcher() + redis = get_redis() + mods_ = score.mods.copy() + + if await get_calculator().can_calculate_difficulty(score.gamemode) is False: + return None + + attribute = await calculate_beatmap_attributes(beatmap.id, score.gamemode, mods_, redis, fetcher) + return attribute + + async def jackpot( session: AsyncSession, score: Score, @@ -105,10 +119,10 @@ async def reckless_adandon( mods_ = mod_to_save(score.mods) if "HR" not in mods_ or "SD" not in mods_: return False - fetcher = await get_fetcher() - redis = get_redis() - mods_ = score.mods.copy() - attribute = await calculate_beatmap_attributes(beatmap.id, score.gamemode, mods_, redis, fetcher) + + attribute = await _calculate_attributes(score, beatmap) + if attribute is None: + return False return not attribute.star_rating < 3 @@ -169,10 +183,10 @@ async def slow_and_steady( mods_ = mod_to_save(score.mods) if "HT" not in mods_ or "PF" not in mods_: return False - fetcher = await get_fetcher() - redis = get_redis() - mods_ = score.mods.copy() - attribute = await calculate_beatmap_attributes(beatmap.id, score.gamemode, mods_, redis, fetcher) + + attribute = await _calculate_attributes(score, beatmap) + if attribute is None: + return False return attribute.star_rating >= 3 @@ -231,10 +245,10 @@ async def impeccable( # DT and NC interchangeable if not ("DT" in mods_ or "NC" in mods_) or "PF" not in mods_: return False - fetcher = await get_fetcher() - redis = get_redis() - mods_ = score.mods.copy() - attribute = await calculate_beatmap_attributes(beatmap.id, score.gamemode, mods_, redis, fetcher) + + attribute = await _calculate_attributes(score, beatmap) + if attribute is None: + return False return attribute.star_rating >= 4 @@ -255,10 +269,10 @@ async def aeon( return False if beatmap.total_length < 180: return False - fetcher = await get_fetcher() - redis = get_redis() - mods_ = score.mods.copy() - attribute = await calculate_beatmap_attributes(beatmap.id, score.gamemode, mods_, redis, fetcher) + + attribute = await _calculate_attributes(score, beatmap) + if attribute is None: + return False return attribute.star_rating >= 4 @@ -345,10 +359,9 @@ async def deliberation( if not beatmap.beatmap_status.has_pp() and beatmap.beatmap_status != BeatmapRankStatus.LOVED: return False - fetcher = await get_fetcher() - redis = get_redis() - mods_copy = score.mods.copy() - attribute = await calculate_beatmap_attributes(beatmap.id, score.gamemode, mods_copy, redis, fetcher) + attribute = await _calculate_attributes(score, beatmap) + if attribute is None: + return False return attribute.star_rating >= 6 diff --git a/app/calculator.py b/app/calculator.py index a0aef79..01fa51c 100644 --- a/app/calculator.py +++ b/app/calculator.py @@ -25,11 +25,13 @@ logger = log("Calculator") CALCULATOR: PerformanceCalculator | None = None -def init_calculator(): +async def init_calculator(): global CALCULATOR try: module = importlib.import_module(f"app.calculators.performance.{settings.calculator}") CALCULATOR = module.PerformanceCalculator(**settings.calculator_config) + if CALCULATOR is not None: + await CALCULATOR.init() except (ImportError, AttributeError) as e: raise ImportError(f"Failed to import performance calculator for {settings.calculator}") from e return CALCULATOR @@ -50,6 +52,26 @@ def clamp[T: int | float](n: T, min_value: T, max_value: T) -> T: return n +def calculate_pp_for_no_calculator(score: "Score", star_rating: float) -> float: + # TODO: Improve this algorithm + # https://www.desmos.com/calculator/i2aa7qm3o6 + k = 4.0 + + pmax = 1.4 * (star_rating**2.8) + b = 0.95 - 0.33 * ((clamp(star_rating, 1, 8) - 1) / 7) + + x = score.total_score / 1000000 + + if x < b: + # Linear section + return pmax * x + else: + # Exponential reward section + x = (x - b) / (1 - b) + exp_part = (math.exp(k * x) - 1) / (math.exp(k) - 1) + return pmax * (b + (1 - b) * exp_part) + + async def calculate_pp(score: "Score", beatmap: str, session: AsyncSession) -> float: from app.database.beatmap import BannedBeatmaps @@ -68,8 +90,18 @@ async def calculate_pp(score: "Score", beatmap: str, session: AsyncSession) -> f except Exception: logger.exception(f"Error checking if beatmap {score.beatmap_id} is suspicious") - attrs = await get_calculator().calculate_performance(beatmap, score) - pp = attrs.pp + if not (await get_calculator().can_calculate_performance(score.gamemode)): + if not settings.fallback_no_calculator_pp: + return 0 + star_rating = -1 + if await get_calculator().can_calculate_difficulty(score.gamemode): + star_rating = (await get_calculator().calculate_difficulty(beatmap, score.mods, score.gamemode)).star_rating + if star_rating < 0: + star_rating = (await score.awaitable_attrs.beatmap).difficulty_rating + pp = calculate_pp_for_no_calculator(score, star_rating) + else: + attrs = await get_calculator().calculate_performance(beatmap, score) + pp = attrs.pp if settings.suspicious_score_check and (pp > 3000): logger.warning( diff --git a/app/calculators/performance/_base.py b/app/calculators/performance/_base.py index 3f7bf4e..8382024 100644 --- a/app/calculators/performance/_base.py +++ b/app/calculators/performance/_base.py @@ -1,5 +1,5 @@ import abc -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, NamedTuple from app.models.mods import APIMod from app.models.performance import DifficultyAttributes, PerformanceAttributes @@ -25,7 +25,16 @@ class PerformanceError(CalculateError): """The performance could not be calculated.""" +class AvailableModes(NamedTuple): + has_performance_calculator: set[GameMode] + has_difficulty_calculator: set[GameMode] + + class PerformanceCalculator(abc.ABC): + @abc.abstractmethod + async def get_available_modes(self) -> AvailableModes: + raise NotImplementedError + @abc.abstractmethod async def calculate_performance(self, beatmap_raw: str, score: "Score") -> PerformanceAttributes: raise NotImplementedError @@ -35,3 +44,15 @@ class PerformanceCalculator(abc.ABC): self, beatmap_raw: str, mods: list[APIMod] | None = None, gamemode: GameMode | None = None ) -> DifficultyAttributes: raise NotImplementedError + + async def can_calculate_performance(self, gamemode: GameMode) -> bool: + modes = await self.get_available_modes() + return gamemode in modes.has_performance_calculator + + async def can_calculate_difficulty(self, gamemode: GameMode) -> bool: + modes = await self.get_available_modes() + return gamemode in modes.has_difficulty_calculator + + async def init(self) -> None: + """Initialize the calculator (if needed).""" + pass diff --git a/app/calculators/performance/performance_server.py b/app/calculators/performance/performance_server.py index 956f9c6..b0916db 100644 --- a/app/calculators/performance/performance_server.py +++ b/app/calculators/performance/performance_server.py @@ -1,4 +1,6 @@ -from typing import TYPE_CHECKING +import asyncio +import datetime +from typing import TYPE_CHECKING, TypedDict, cast from app.models.mods import APIMod from app.models.performance import ( @@ -10,6 +12,7 @@ from app.models.performance import ( from app.models.score import GameMode from ._base import ( + AvailableModes, CalculateError, DifficultyError, PerformanceCalculator as BasePerformanceCalculator, @@ -23,10 +26,61 @@ if TYPE_CHECKING: from app.database.score import Score -class PerformanceCalculator(BasePerformanceCalculator): +class AvailableRulesetResp(TypedDict): + has_performance_calculator: list[str] + has_difficulty_calculator: list[str] + loaded_rulesets: list[str] + + +class PerformanceServerPerformanceCalculator(BasePerformanceCalculator): def __init__(self, server_url: str = "http://localhost:5225") -> None: self.server_url = server_url + self._available_modes: AvailableModes | None = None + self._modes_lock = asyncio.Lock() + self._today = datetime.date.today() + + async def init(self): + await self.get_available_modes() + + def _process_modes(self, modes: AvailableRulesetResp) -> AvailableModes: + performance_modes = { + m for mode in modes["has_performance_calculator"] if (m := GameMode.parse(mode)) is not None + } + difficulty_modes = {m for mode in modes["has_difficulty_calculator"] if (m := GameMode.parse(mode)) is not None} + if GameMode.OSU in performance_modes: + performance_modes.add(GameMode.OSURX) + performance_modes.add(GameMode.OSUAP) + if GameMode.TAIKO in performance_modes: + performance_modes.add(GameMode.TAIKORX) + if GameMode.FRUITS in performance_modes: + performance_modes.add(GameMode.FRUITSRX) + + return AvailableModes( + has_performance_calculator=performance_modes, + has_difficulty_calculator=difficulty_modes, + ) + + async def get_available_modes(self) -> AvailableModes: + # https://github.com/GooGuTeam/osu-performance-server#get-available_rulesets + if self._available_modes is not None and self._today == datetime.date.today(): + return self._available_modes + async with self._modes_lock, AsyncClient() as client: + try: + resp = await client.get(f"{self.server_url}/available_rulesets") + if resp.status_code != 200: + raise CalculateError(f"Failed to get available modes: {resp.text}") + modes = cast(AvailableRulesetResp, resp.json()) + result = self._process_modes(modes) + + self._available_modes = result + self._today = datetime.date.today() + return result + except HTTPError as e: + raise CalculateError(f"Failed to get available modes: {e}") from e + except Exception as e: + raise CalculateError(f"Unknown error: {e}") from e + async def calculate_performance(self, beatmap_raw: str, score: "Score") -> PerformanceAttributes: # https://github.com/GooGuTeam/osu-performance-server#post-performance async with AsyncClient() as client: @@ -74,7 +128,7 @@ class PerformanceCalculator(BasePerformanceCalculator): json={ "beatmap_file": beatmap_raw, "mods": mods or [], - "ruleset": int(gamemode) if gamemode else None, + "ruleset": gamemode.value if gamemode else None, }, ) if resp.status_code != 200: @@ -84,3 +138,6 @@ class PerformanceCalculator(BasePerformanceCalculator): raise DifficultyError(f"Failed to calculate difficulty: {e}") from e except Exception as e: raise DifficultyError(f"Unknown error: {e}") from e + + +PerformanceCalculator = PerformanceServerPerformanceCalculator diff --git a/app/calculators/performance/rosu.py b/app/calculators/performance/rosu.py index f2d1ca9..f54fe63 100644 --- a/app/calculators/performance/rosu.py +++ b/app/calculators/performance/rosu.py @@ -1,6 +1,6 @@ from asyncio import get_event_loop from copy import deepcopy -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar from app.calculator import clamp from app.models.mods import APIMod, parse_enum_to_str @@ -16,6 +16,7 @@ from app.models.performance import ( from app.models.score import GameMode from ._base import ( + AvailableModes, CalculateError, ConvertError, DifficultyError, @@ -47,7 +48,18 @@ DIFFICULTY_CLASS = { } -class PerformanceCalculator(BasePerformanceCalculator): +class RosuPerformanceCalculator(BasePerformanceCalculator): + SUPPORT_MODES: ClassVar[set[GameMode]] = { + GameMode.OSU, + GameMode.TAIKO, + GameMode.FRUITS, + GameMode.MANIA, + GameMode.OSURX, + GameMode.OSUAP, + GameMode.TAIKORX, + GameMode.FRUITSRX, + } + @classmethod def _to_rosu_mode(cls, mode: GameMode) -> rosu.GameMode: return { @@ -70,6 +82,12 @@ class PerformanceCalculator(BasePerformanceCalculator): rosu.GameMode.Mania: GameMode.MANIA, }[mode] + async def get_available_modes(self) -> AvailableModes: + return AvailableModes( + has_performance_calculator=self.SUPPORT_MODES, + has_difficulty_calculator=self.SUPPORT_MODES, + ) + @classmethod def _perf_attr_to_model(cls, attr: rosu.PerformanceAttributes, gamemode: GameMode) -> PerformanceAttributes: attr_class = PERFORMANCE_CLASS.get(gamemode, PerformanceAttributes) @@ -185,3 +203,6 @@ class PerformanceCalculator(BasePerformanceCalculator): raise DifficultyError(f"Beatmap parse error: {e}") except Exception as e: raise CalculateError(f"Unknown error: {e}") from e + + +PerformanceCalculator = RosuPerformanceCalculator diff --git a/app/config.py b/app/config.py index 2f0791f..e1dc8fd 100644 --- a/app/config.py +++ b/app/config.py @@ -536,6 +536,11 @@ CALCULATOR_CONFIG='{ ), "表现计算设置", ] + fallback_no_calculator_pp: Annotated[ + bool, + Field(default=False, description="当计算器不支持某个模式时,使用简化的 pp 计算方法作为后备"), + "表现计算设置", + ] # 谱面缓存设置 enable_beatmap_preload: Annotated[ @@ -687,6 +692,11 @@ CALCULATOR_CONFIG='{ Field(default=False, description="允许用户删除自己的成绩"), "反作弊设置", ] + check_ruleset_version: Annotated[ + bool, + Field(default=True, description="检查自定义 ruleset 版本"), + "反作弊设置", + ] # 存储设置 storage_service: Annotated[ diff --git a/app/database/playlists.py b/app/database/playlists.py index 4eeb2ef..6edf16f 100644 --- a/app/database/playlists.py +++ b/app/database/playlists.py @@ -28,7 +28,7 @@ if TYPE_CHECKING: class PlaylistBase(SQLModel, UTCBaseModel): id: int = Field(index=True) owner_id: int = Field(sa_column=Column(BigInteger, ForeignKey("lazer_users.id"))) - ruleset_id: int = Field(ge=0, le=3) + ruleset_id: int expired: bool = Field(default=False) playlist_order: int = Field(default=0) played_at: datetime | None = Field( diff --git a/app/models/mods.py b/app/models/mods.py index 7bc5848..38e84c0 100644 --- a/app/models/mods.py +++ b/app/models/mods.py @@ -335,7 +335,7 @@ def _mods_can_get_pp(ruleset_id: int, mods: list[APIMod], ranked_mods: RankedMod continue if app_settings.enable_ap and mod["acronym"] == "AP" and ruleset_id == 0: continue - check_settings_result = check_settings(mod, ranked_mods[ruleset_id]) + check_settings_result = check_settings(mod, ranked_mods.get(ruleset_id, {})) if not check_settings_result: return False return True diff --git a/app/models/performance.py b/app/models/performance.py index 612edb3..84573d8 100644 --- a/app/models/performance.py +++ b/app/models/performance.py @@ -1,4 +1,4 @@ -# Version: 2025.10.19 +# Version: 2025.1012.1 # Auto-generated by scripts/generate_ruleset_attributes.py. # Schema generated by https://github.com/GooGuTeam/custom-rulesets # Do not edit this file directly. @@ -91,30 +91,12 @@ class ManiaPerformanceAttributes(PerformanceAttributes): ManiaDifficultyAttributes = DifficultyAttributes -HishigataPerformanceAttributes = PerformanceAttributes - - -HishigataDifficultyAttributes = DifficultyAttributes - - -RushPerformanceAttributes = PerformanceAttributes - - -RushDifficultyAttributes = DifficultyAttributes - - SentakkiPerformanceAttributes = PerformanceAttributes SentakkiDifficultyAttributes = DifficultyAttributes -SoyokazePerformanceAttributes = PerformanceAttributes - - -SoyokazeDifficultyAttributes = DifficultyAttributes - - class TauPerformanceAttribute(PerformanceAttributes): aim: float speed: float @@ -132,6 +114,22 @@ class TauDifficultyAttributes(DifficultyAttributes): overall_difficulty: float +RushPerformanceAttributes = PerformanceAttributes + + +RushDifficultyAttributes = DifficultyAttributes + + +HishigataPerformanceAttributes = PerformanceAttributes + + +HishigataDifficultyAttributes = DifficultyAttributes + + +SoyokazePerformanceAttributes = PerformanceAttributes + + +SoyokazeDifficultyAttributes = DifficultyAttributes PerformanceAttributesUnion = ( OsuPerformanceAttributes | TaikoPerformanceAttributes | ManiaPerformanceAttributes | PerformanceAttributes ) diff --git a/app/models/score.py b/app/models/score.py index abf2d68..9ae3308 100644 --- a/app/models/score.py +++ b/app/models/score.py @@ -1,23 +1,56 @@ from enum import Enum -from typing import Literal, TypedDict, cast +import json +from typing import NamedTuple, TypedDict, cast from app.config import settings +from app.path import STATIC_DIR from .mods import API_MODS, APIMod from pydantic import BaseModel, Field, ValidationInfo, field_serializer, field_validator +VersionEntry = TypedDict("VersionEntry", {"latest-version": str, "versions": dict[str, str]}) +DOWNLOAD_URL = "https://github.com/GooGuTeam/custom-rulesets/releases/tag/{version}" + + +class RulesetCheckResult(NamedTuple): + is_current: bool + latest_version: str = "" + current_version: str | None = None + download_url: str | None = None + + def __bool__(self) -> bool: + return self.is_current + + @property + def error_msg(self) -> str | None: + if self.is_current: + return None + msg = f"Ruleset is outdated. Latest version: {self.latest_version}." + if self.current_version: + msg += f" Current version: {self.current_version}." + if self.download_url: + msg += f" Download at: {self.download_url}" + return msg + class GameMode(str, Enum): OSU = "osu" TAIKO = "taiko" FRUITS = "fruits" MANIA = "mania" + OSURX = "osurx" OSUAP = "osuap" TAIKORX = "taikorx" FRUITSRX = "fruitsrx" + SENTAKKI = "Sentakki" + TAU = "tau" + RUSH = "rush" + HISHIGATA = "hishigata" + SOYOKAZE = "soyokaze" + def __int__(self) -> int: return { GameMode.OSU: 0, @@ -28,6 +61,11 @@ class GameMode(str, Enum): GameMode.OSUAP: 0, GameMode.TAIKORX: 1, GameMode.FRUITSRX: 2, + GameMode.SENTAKKI: 10, + GameMode.TAU: 11, + GameMode.RUSH: 12, + GameMode.HISHIGATA: 13, + GameMode.SOYOKAZE: 14, }[self] def __str__(self) -> str: @@ -40,20 +78,22 @@ class GameMode(str, Enum): 1: GameMode.TAIKO, 2: GameMode.FRUITS, 3: GameMode.MANIA, + 10: GameMode.SENTAKKI, + 11: GameMode.TAU, + 12: GameMode.RUSH, + 13: GameMode.HISHIGATA, + 14: GameMode.SOYOKAZE, }[v] @classmethod def from_int_extra(cls, v: int) -> "GameMode": - return { - 0: GameMode.OSU, - 1: GameMode.TAIKO, - 2: GameMode.FRUITS, - 3: GameMode.MANIA, + gamemode = { 4: GameMode.OSURX, 5: GameMode.OSUAP, 6: GameMode.TAIKORX, 7: GameMode.FRUITSRX, - }[v] + }.get(v) + return gamemode or cls.from_int(v) def readable(self) -> str: return { @@ -65,8 +105,27 @@ class GameMode(str, Enum): GameMode.OSUAP: "osu!autopilot", GameMode.TAIKORX: "taiko relax", GameMode.FRUITSRX: "catch relax", + GameMode.SENTAKKI: "sentakki", + GameMode.TAU: "tau", + GameMode.RUSH: "Rush!", + GameMode.HISHIGATA: "hishigata", + GameMode.SOYOKAZE: "soyokaze!", }[self] + def is_official(self) -> bool: + return self in { + GameMode.OSU, + GameMode.TAIKO, + GameMode.FRUITS, + GameMode.MANIA, + GameMode.OSURX, + GameMode.TAIKORX, + GameMode.FRUITSRX, + } + + def is_custom_ruleset(self) -> bool: + return not self.is_official() + def to_base_ruleset(self) -> "GameMode": gamemode = { GameMode.OSURX: GameMode.OSU, @@ -74,7 +133,7 @@ class GameMode(str, Enum): GameMode.TAIKORX: GameMode.TAIKO, GameMode.FRUITSRX: GameMode.FRUITS, }.get(self) - return gamemode if gamemode else self + return gamemode or self def to_special_mode(self, mods: list[APIMod] | list[str]) -> "GameMode": if self not in (GameMode.OSU, GameMode.TAIKO, GameMode.FRUITS): @@ -93,6 +152,27 @@ class GameMode(str, Enum): }[self] return self + def check_ruleset_version(self, hash: str) -> RulesetCheckResult: + if not settings.check_ruleset_version or self.is_official(): + return RulesetCheckResult(True) + + entry = RULESETS_VERSION_HASH.get(self) + if not entry: + return RulesetCheckResult(True) + latest_version = entry["latest-version"] + current_version = None + for version, version_hash in entry["versions"].items(): + if version_hash == hash: + current_version = version + break + is_current = current_version == latest_version + return RulesetCheckResult( + is_current=is_current, + latest_version=latest_version, + current_version=current_version, + download_url=DOWNLOAD_URL.format(version=latest_version) if not is_current else None, + ) + @classmethod def parse(cls, v: str | int) -> "GameMode | None": if isinstance(v, int) or v.isdigit(): @@ -189,7 +269,7 @@ class SoloScoreSubmissionInfo(BaseModel): 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] + ruleset_id: int passed: bool = False mods: list[APIMod] = Field(default_factory=list) statistics: ScoreStatistics = Field(default_factory=dict) @@ -241,3 +321,21 @@ class LegacyReplaySoloScoreInfo(TypedDict): rank: Rank user_id: int total_score_without_mods: int + + +RULESETS_VERSION_HASH: dict[GameMode, VersionEntry] = {} + + +def init_ruleset_version_hash() -> None: + hash_file = STATIC_DIR / "custom_ruleset_version_hash.json" + if not hash_file.exists(): + if settings.check_ruleset_version: + raise RuntimeError("Custom ruleset version hash file is missing") + rulesets = {} + else: + rulesets = json.loads(hash_file.read_text(encoding="utf-8")) + for mode_str, entry in rulesets.items(): + mode = GameMode.parse(mode_str) + if mode is None: + continue + RULESETS_VERSION_HASH[mode] = entry diff --git a/app/router/lio.py b/app/router/lio.py index ec0ab7e..33fbaa1 100644 --- a/app/router/lio.py +++ b/app/router/lio.py @@ -16,6 +16,7 @@ from app.dependencies.storage import StorageService from app.log import log from app.models.playlist import PlaylistItem from app.models.room import MatchType, QueueMode, RoomCategory, RoomStatus +from app.models.score import RULESETS_VERSION_HASH, GameMode, VersionEntry from app.utils import camel_to_snake, utcnow from .notification.server import server @@ -150,7 +151,7 @@ def _validate_playlist_items(items: list[dict[str, Any]]) -> None: ) ruleset_id = item["ruleset_id"] - if not isinstance(ruleset_id, int) or not (0 <= ruleset_id <= 3): + if not isinstance(ruleset_id, int): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Playlist item at index {idx} has invalid ruleset_id {ruleset_id}", @@ -679,3 +680,8 @@ async def save_replay( replay_data = req.mreplay replay_path = f"replays/{req.score_id}_{req.beatmap_id}_{req.user_id}_lazer_replay.osr" await storage_service.write_file(replay_path, base64.b64decode(replay_data), "application/x-osu-replay") + + +@router.get("/ruleset-hashes", response_model=dict[GameMode, VersionEntry]) +async def get_ruleset_version(): + return RULESETS_VERSION_HASH diff --git a/app/router/v1/beatmap.py b/app/router/v1/beatmap.py index 4e4dd97..d77f85b 100644 --- a/app/router/v1/beatmap.py +++ b/app/router/v1/beatmap.py @@ -152,7 +152,7 @@ async def get_beatmaps( beatmap_id: Annotated[int | None, Query(alias="b", description="谱面 ID")] = None, user: Annotated[str | None, Query(alias="u", description="谱师")] = None, type: Annotated[Literal["string", "id"] | None, Query(description="用户类型:string 用户名称 / id 用户 ID")] = None, - ruleset_id: Annotated[int | None, Query(alias="m", description="Ruleset ID", ge=0, le=3)] = None, # TODO + ruleset_id: Annotated[int | None, Query(alias="m", description="Ruleset ID")] = None, # TODO convert: Annotated[bool, Query(alias="a", description="转谱")] = False, # TODO checksum: Annotated[str | None, Query(alias="h", description="谱面文件 MD5")] = None, limit: Annotated[int, Query(ge=1, le=500, description="返回结果数量限制")] = 500, diff --git a/app/router/v2/beatmap.py b/app/router/v2/beatmap.py index 8ebd7ec..53cf70f 100644 --- a/app/router/v2/beatmap.py +++ b/app/router/v2/beatmap.py @@ -3,6 +3,7 @@ import hashlib import json from typing import Annotated +from app.calculator import get_calculator from app.calculators.performance import ConvertError from app.database import Beatmap, BeatmapResp, User from app.database.beatmap import calculate_beatmap_attributes @@ -147,7 +148,7 @@ async def get_beatmap_attributes( redis: Redis, fetcher: Fetcher, ruleset: Annotated[GameMode | None, Query(description="指定 ruleset;为空则使用谱面自身模式")] = None, - ruleset_id: Annotated[int | None, Query(description="以数字指定 ruleset (与 ruleset 二选一)", ge=0, le=3)] = None, + ruleset_id: Annotated[int | None, Query(description="以数字指定 ruleset (与 ruleset 二选一)")] = None, ): mods_ = [] if mods and mods[0].isdigit(): @@ -170,6 +171,10 @@ async def get_beatmap_attributes( ) if await redis.exists(key): return DifficultyAttributes.model_validate_json(await redis.get(key)) # pyright: ignore[reportArgumentType] + + if await get_calculator().can_calculate_difficulty(ruleset) is False: + raise HTTPException(status_code=422, detail="Cannot calculate difficulty for the specified ruleset") + try: return await calculate_beatmap_attributes(beatmap_id, ruleset, mods_, redis, fetcher) except HTTPStatusError: diff --git a/app/router/v2/score.py b/app/router/v2/score.py index 78b72c8..55eb6ec 100644 --- a/app/router/v2/score.py +++ b/app/router/v2/score.py @@ -374,13 +374,29 @@ async def create_solo_score( db: Database, beatmap_id: Annotated[int, Path(description="谱面 ID")], beatmap_hash: Annotated[str, Form(description="谱面文件哈希")], - ruleset_id: Annotated[int, Form(..., ge=0, le=3, description="ruleset 数字 ID (0-3)")], + ruleset_id: Annotated[int, Form(..., description="ruleset 数字 ID (0-3)")], current_user: ClientUser, version_hash: Annotated[str, Form(description="游戏版本哈希")] = "", + ruleset_hash: Annotated[str, Form(description="ruleset 版本哈希")] = "", ): # 立即获取用户ID,避免懒加载问题 user_id = current_user.id + try: + gamemode = GameMode.from_int(ruleset_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid ruleset ID") + + if not (result := gamemode.check_ruleset_version(ruleset_hash)): + logger.info( + f"Ruleset version check failed for user {current_user.id} on beatmap {beatmap_id} " + f"(ruleset: {ruleset_id}, hash: {ruleset_hash})" + ) + raise HTTPException( + status_code=422, + detail=result.error_msg or "Ruleset version check failed", + ) + background_task.add_task(_preload_beatmap_for_pp_calculation, beatmap_id) async with db: score_token = ScoreToken( @@ -428,10 +444,26 @@ async def create_playlist_score( playlist_id: int, beatmap_id: Annotated[int, Form(description="谱面 ID")], beatmap_hash: Annotated[str, Form(description="游戏版本哈希")], - ruleset_id: Annotated[int, Form(..., ge=0, le=3, description="ruleset 数字 ID (0-3)")], + ruleset_id: Annotated[int, Form(..., description="ruleset 数字 ID (0-3)")], current_user: ClientUser, version_hash: Annotated[str, Form(description="谱面版本哈希")] = "", + ruleset_hash: Annotated[str, Form(description="ruleset 版本哈希")] = "", ): + try: + gamemode = GameMode.from_int(ruleset_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid ruleset ID") + + if not (result := gamemode.check_ruleset_version(ruleset_hash)): + logger.info( + f"Ruleset version check failed for user {current_user.id} on room {room_id}, playlist {playlist_id}," + f" (ruleset: {ruleset_id}, hash: {ruleset_hash})" + ) + raise HTTPException( + status_code=422, + detail=result.error_msg or "Ruleset version check failed", + ) + if await current_user.is_restricted(session): raise HTTPException(status_code=403, detail="You are restricted from submitting multiplayer scores") diff --git a/app/tasks/__init__.py b/app/tasks/__init__.py index 0d520ae..3c2dc94 100644 --- a/app/tasks/__init__.py +++ b/app/tasks/__init__.py @@ -13,11 +13,12 @@ from .create_banchobot import create_banchobot from .daily_challenge import daily_challenge_job, process_daily_challenge_top from .geoip import init_geoip from .load_achievements import load_achievements -from .osu_rx_statistics import create_rx_statistics +from .special_statistics import create_custom_ruleset_statistics, create_rx_statistics __all__ = [ "calculate_user_rank", "create_banchobot", + "create_custom_ruleset_statistics", "create_rx_statistics", "daily_challenge_job", "init_geoip", diff --git a/app/tasks/osu_rx_statistics.py b/app/tasks/special_statistics.py similarity index 65% rename from app/tasks/osu_rx_statistics.py rename to app/tasks/special_statistics.py index e21a345..4ec5da6 100644 --- a/app/tasks/osu_rx_statistics.py +++ b/app/tasks/special_statistics.py @@ -57,3 +57,34 @@ async def create_rx_statistics(): logger.success( f"Created {rx_created} RX statistics rows and {ap_created} AP statistics rows during backfill" ) + + +async def create_custom_ruleset_statistics(): + async with with_db() as session: + users = (await session.exec(select(User.id))).all() + total_users = len(users) + logger.info(f"Ensuring custom ruleset statistics exist for {total_users} users") + created_count = 0 + for i in users: + if i == BANCHOBOT_ID: + continue + + for mode in GameMode: + if not mode.is_custom_ruleset(): + continue + + is_exist = ( + await session.exec( + select(exists()).where( + UserStatistics.user_id == i, + UserStatistics.mode == mode, + ) + ) + ).first() + if not is_exist: + statistics = UserStatistics(mode=mode, user_id=i) + session.add(statistics) + created_count += 1 + await session.commit() + if created_count: + logger.success(f"Created {created_count} custom ruleset statistics rows during backfill") diff --git a/main.py b/main.py index dded8c4..13b9701 100644 --- a/main.py +++ b/main.py @@ -18,6 +18,7 @@ from app.dependencies.scheduler import start_scheduler, stop_scheduler from app.log import system_logger from app.middleware.verify_session import VerifySessionMiddleware from app.models.mods import init_mods, init_ranked_mods +from app.models.score import init_ruleset_version_hash from app.router import ( api_v1_router, api_v2_router, @@ -37,6 +38,7 @@ from app.service.redis_message_system import redis_message_system from app.tasks import ( calculate_user_rank, create_banchobot, + create_custom_ruleset_statistics, create_rx_statistics, daily_challenge_job, init_geoip, @@ -61,8 +63,9 @@ async def lifespan(app: FastAPI): # noqa: ARG001 # init mods, achievements and performance calculator init_mods() init_ranked_mods() + init_ruleset_version_hash() load_achievements() - init_calculator() + await init_calculator() # init rate limiter await FastAPILimiter.init(redis_rate_limit_client) @@ -74,6 +77,7 @@ async def lifespan(app: FastAPI): # noqa: ARG001 # init game server await create_rx_statistics() + await create_custom_ruleset_statistics() await calculate_user_rank(True) await daily_challenge_job() await process_daily_challenge_top() diff --git a/migrations/versions/2025-10-25_2d395ba2b4fd_gamemode_add_custom_rulesets_for_.py b/migrations/versions/2025-10-25_2d395ba2b4fd_gamemode_add_custom_rulesets_for_.py new file mode 100644 index 0000000..48b5450 --- /dev/null +++ b/migrations/versions/2025-10-25_2d395ba2b4fd_gamemode_add_custom_rulesets_for_.py @@ -0,0 +1,122 @@ +"""gamemode: add custom rulesets for sentakki, tau, rush, hishigata & soyokaze + +Revision ID: 2d395ba2b4fd +Revises: ceabe941b207 +Create Date: 2025-10-25 12:20:06.681929 + +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = "2d395ba2b4fd" +down_revision: str | Sequence[str] | None = "ceabe941b207" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +OLD_MODES: tuple[str, ...] = ( + "OSU", + "TAIKO", + "FRUITS", + "MANIA", + "OSURX", + "OSUAP", + "TAIKORX", + "FRUITSRX", +) + +CUSTOM_MODES: tuple[str, ...] = ( + "SENTAKKI", + "TAU", + "RUSH", + "HISHIGATA", + "SOYOKAZE", +) + +NEW_MODES: tuple[str, ...] = OLD_MODES + CUSTOM_MODES + +TARGET_COLUMNS: tuple[tuple[str, str], ...] = ( + ("lazer_users", "playmode"), + ("lazer_users", "g0v0_playmode"), + ("beatmaps", "mode"), + ("lazer_user_statistics", "mode"), + ("score_tokens", "ruleset_id"), + ("scores", "gamemode"), + ("best_scores", "gamemode"), + ("total_score_best_scores", "gamemode"), + ("rank_history", "mode"), + ("rank_top", "mode"), + ("teams", "playmode"), +) + + +def _gamemode_enum(values: tuple[str, ...]) -> mysql.ENUM: + return mysql.ENUM(*values, name="gamemode") + + +def upgrade() -> None: + """Upgrade schema.""" + for table, column in TARGET_COLUMNS: + op.alter_column( + table, + column, + existing_type=_gamemode_enum(OLD_MODES), + type_=_gamemode_enum(NEW_MODES), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + placeholders = ", ".join(f":mode_{index}" for index in range(len(CUSTOM_MODES))) + mode_params = {f"mode_{index}": mode for index, mode in enumerate(CUSTOM_MODES)} + + cleanup_templates = [ + "DELETE FROM playlist_best_scores WHERE score_id IN (SELECT id FROM scores WHERE gamemode IN ({placeholders}))", + "DELETE FROM total_score_best_scores WHERE gamemode IN ({placeholders})", + "DELETE FROM best_scores WHERE gamemode IN ({placeholders})", + "DELETE FROM score_tokens WHERE ruleset_id IN ({placeholders})", + "DELETE FROM score_tokens WHERE score_id IN (SELECT id FROM scores WHERE gamemode IN ({placeholders}))", + "DELETE FROM score_tokens WHERE beatmap_id IN (SELECT id FROM beatmaps WHERE mode IN ({placeholders}))", + "DELETE FROM scores WHERE gamemode IN ({placeholders})", + "DELETE FROM rank_history WHERE mode IN ({placeholders})", + "DELETE FROM rank_top WHERE mode IN ({placeholders})", + "DELETE FROM lazer_user_statistics WHERE mode IN ({placeholders})", + "DELETE FROM team_requests WHERE team_id IN (SELECT id FROM teams WHERE playmode IN ({placeholders}))", + "DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE playmode IN ({placeholders}))", + "DELETE FROM teams WHERE playmode IN ({placeholders})", + ( + "DELETE FROM matchmaking_pool_beatmaps WHERE beatmap_id IN " + "(SELECT id FROM beatmaps WHERE mode IN ({placeholders}))" + ), + "DELETE FROM beatmap_playcounts WHERE beatmap_id IN (SELECT id FROM beatmaps WHERE mode IN ({placeholders}))", + "DELETE FROM beatmap_tags WHERE beatmap_id IN (SELECT id FROM beatmaps WHERE mode IN ({placeholders}))", + "DELETE FROM failtime WHERE beatmap_id IN (SELECT id FROM beatmaps WHERE mode IN ({placeholders}))", + "DELETE FROM room_playlists WHERE beatmap_id IN (SELECT id FROM beatmaps WHERE mode IN ({placeholders}))", + "DELETE FROM banned_beatmaps WHERE beatmap_id IN (SELECT id FROM beatmaps WHERE mode IN ({placeholders}))", + "DELETE FROM beatmaps WHERE mode IN ({placeholders})", + ] + + for template in cleanup_templates: + statement = template.format(placeholders=placeholders) + op.execute(sa.text(statement), parameters=dict(mode_params)) + + # Reset persisted user modes to a supported option before shrinking the enum domain. + update_templates = [ + "UPDATE lazer_users SET g0v0_playmode = 'OSU' WHERE g0v0_playmode IN ({placeholders})", + "UPDATE lazer_users SET playmode = 'OSU' WHERE playmode IN ({placeholders})", + ] + + for template in update_templates: + op.execute(sa.text(template.format(placeholders=placeholders)), parameters=dict(mode_params)) + + for table, column in TARGET_COLUMNS: + op.alter_column( + table, + column, + existing_type=_gamemode_enum(NEW_MODES), + type_=_gamemode_enum(OLD_MODES), + ) diff --git a/scripts/generate_ruleset_attributes.py b/scripts/generate_ruleset_attributes.py index 9a7cf63..7c8ab95 100644 --- a/scripts/generate_ruleset_attributes.py +++ b/scripts/generate_ruleset_attributes.py @@ -16,7 +16,7 @@ def generate_model(schema_file: Path, version: str = ""): temp_file.unlink() temp_file.touch() - version = version or datetime.datetime.now().strftime("%Y.%m.%d") + version = version or f"{datetime.datetime.now().strftime('%Y.%m%d')}.0" generate( input_=schema_file, diff --git a/static/custom_ruleset_version_hash.json b/static/custom_ruleset_version_hash.json new file mode 100644 index 0000000..fe12552 --- /dev/null +++ b/static/custom_ruleset_version_hash.json @@ -0,0 +1,32 @@ +{ + "Sentakki": { + "latest-version": "2025.1012.1", + "versions": { + "2025.1012.1": "66e02af2097f446246b146641295573a" + } + }, + "tau": { + "latest-version": "2025.1012.1", + "versions": { + "2025.1012.1": "3a2dd168c2e520a3620a5dfd7b3c0b73" + } + }, + "rush": { + "latest-version": "2025.1012.1", + "versions": { + "2025.1012.1": "df0c211c8c40f42feb119a3a11549a6f" + } + }, + "hishigata": { + "latest-version": "2025.1012.1", + "versions": { + "2025.1012.1": "af26c2946cd0b2258ac52f5cce91958c" + } + }, + "soyokaze": { + "latest-version": "2025.1012.1", + "versions": { + "2025.1012.1": "fea5c97b8b436305ba98ef8b39b133b6" + } + } +} \ No newline at end of file diff --git a/static/mods.json b/static/mods.json index 0a8449b..c045e21 100644 --- a/static/mods.json +++ b/static/mods.json @@ -3673,5 +3673,2543 @@ "AlwaysValidForSubmission": false } ] + }, + { + "Name": "Sentakki", + "RulesetID": 10, + "Mods": [ + { + "Acronym": "HT", + "Name": "Half Time", + "Description": "Less zoom...", + "Type": "DifficultyReduction", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed decrease", + "Description": "The actual decrease to apply" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DC", + "Name": "Daycore", + "Description": "Whoaaaaa...", + "Type": "DifficultyReduction", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed decrease", + "Description": "The actual decrease to apply" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "HR", + "Name": "Hard Rock", + "Description": "Everything just got a bit harder...", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "judgement_mode", + "Type": "string", + "Label": "Judgement mode", + "Description": "Judgement modes determine how strict the hitwindows are during gameplay." + }, + { + "Name": "minimum_valid_result", + "Type": "string", + "Label": "Minimum hit result", + "Description": "The minimum HitResult that is accepted during gameplay. Anything below will be considered a miss." + }, + { + "Name": "strict_slider_tracking", + "Type": "boolean", + "Label": "Enable strict slider tracking", + "Description": "" + } + ], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "SD", + "Name": "Sudden Death", + "Description": "Miss and fail.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "restart", + "Type": "boolean", + "Label": "Restart on fail", + "Description": "Automatically restarts when failed." + } + ], + "IncompatibleMods": [ + "PF", + "C", + "AT" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "PF", + "Name": "Perfect", + "Description": "SS or quit.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "restart", + "Type": "boolean", + "Label": "Restart on fail", + "Description": "Automatically restarts when failed." + } + ], + "IncompatibleMods": [ + "SD", + "C", + "AT" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "C", + "Name": "Challenge", + "Description": "You only get a small margin for errors.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "live_setting", + "Type": "string", + "Label": "Number of lives", + "Description": "The number of lives you start with." + }, + { + "Name": "restart", + "Type": "boolean", + "Label": "Restart on fail", + "Description": "Automatically restarts when failed." + } + ], + "IncompatibleMods": [ + "SD", + "PF", + "C", + "AC", + "AT" + ], + "RequiresConfiguration": true, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "AC", + "Name": "Accuracy Challenge", + "Description": "Fail if your accuracy drops too low!", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "minimum_accuracy", + "Type": "number", + "Label": "Minimum accuracy", + "Description": "Trigger a failure if your accuracy goes below this value." + }, + { + "Name": "accuracy_judge_mode", + "Type": "string", + "Label": "Accuracy mode", + "Description": "The mode of accuracy that will trigger failure." + }, + { + "Name": "restart", + "Type": "boolean", + "Label": "Restart on fail", + "Description": "Automatically restarts when failed." + } + ], + "IncompatibleMods": [ + "SD", + "PF", + "C", + "AC" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DT", + "Name": "Double Time", + "Description": "Zoooooooooom...", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed increase", + "Description": "The actual increase to apply" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "NC", + "Name": "Nightcore", + "Description": "Uguuuuuuuu...", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed increase", + "Description": "The actual increase to apply" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "HD", + "Name": "Hidden", + "Description": "Notes fade out just before you hit them.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "visible_radius", + "Type": "number", + "Label": "Visible radius", + "Description": "The visible radius of laned notes." + } + ], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "Ex", + "Name": "Experimental", + "Description": "Some experimental features to be added to future sentakki builds. Autoplay/No-Fail recommended. Replays unsupported.", + "Type": "Conversion", + "Settings": [ + { + "Name": "enable_slide_fans", + "Type": "boolean", + "Label": "Fan slides", + "Description": "Allow fan slides to occasionally appear (Requires multitouch)" + }, + { + "Name": "old_conversion", + "Type": "boolean", + "Label": "Use old converter", + "Description": "The old converter relied on RNG for just about everything. Included for comparison purposes." + }, + { + "Name": "enable_twin_notes", + "Type": "boolean", + "Label": "Twin notes", + "Description": "Allow more than one note to share the same times (Requires multitouch)" + }, + { + "Name": "enable_twin_slides", + "Type": "boolean", + "Label": "Twin slides", + "Description": "Allow more than one Slide-body to share the same time and origin (Requires multitouch)" + } + ], + "IncompatibleMods": [], + "RequiresConfiguration": true, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "CL", + "Name": "Classic", + "Description": "Remove gameplay elements introduced in maimaiDX, for the Finale purists.", + "Type": "Conversion", + "Settings": [], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "MR", + "Name": "Mirror", + "Description": "Flip the playfield horizontally, vertically, or both!", + "Type": "Conversion", + "Settings": [ + { + "Name": "vertical_mirrored", + "Type": "boolean", + "Label": "⇅ Mirror vertically", + "Description": "Mirror entire playfield across the x-axis" + }, + { + "Name": "horizontal_mirrored", + "Type": "boolean", + "Label": "⇆ Mirror horizontally", + "Description": "Mirror entire playfield across the y-axis" + } + ], + "IncompatibleMods": [], + "RequiresConfiguration": true, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DA", + "Name": "Difficulty Adjust", + "Description": "Override a beatmap's difficulty settings.", + "Type": "Conversion", + "Settings": [ + { + "Name": "break_removal", + "Type": "boolean", + "Label": "No BREAK notes", + "Description": "" + }, + { + "Name": "ex_removal", + "Type": "boolean", + "Label": "No EX notes", + "Description": "" + }, + { + "Name": "all_ex", + "Type": "boolean", + "Label": "All EX notes", + "Description": "" + } + ], + "IncompatibleMods": [], + "RequiresConfiguration": true, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "AT", + "Name": "Autoplay", + "Description": "Watch a perfect automated play through the song.", + "Type": "Automation", + "Settings": [], + "IncompatibleMods": [ + "SD", + "PF", + "C", + "AC", + "NT" + ], + "RequiresConfiguration": false, + "UserPlayable": false, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "NT", + "Name": "No Touch", + "Description": "Focus on the laned notes. Touch notes and Slide bodies will be automatically completed.", + "Type": "Automation", + "Settings": [], + "IncompatibleMods": [ + "AT" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "WU", + "Name": "Wind Up", + "Description": "Can you keep up?", + "Type": "Fun", + "Settings": [ + { + "Name": "initial_rate", + "Type": "number", + "Label": "Initial rate", + "Description": "The starting speed of the track" + }, + { + "Name": "final_rate", + "Type": "number", + "Label": "Final rate", + "Description": "The final speed to ramp to" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WD", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "WD", + "Name": "Wind Down", + "Description": "Sloooow doooown...", + "Type": "Fun", + "Settings": [ + { + "Name": "initial_rate", + "Type": "number", + "Label": "Initial rate", + "Description": "The starting speed of the track" + }, + { + "Name": "final_rate", + "Type": "number", + "Label": "Final rate", + "Description": "The final speed to ramp to" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "BR", + "Name": "Barrel Roll", + "Description": "The whole playfield is on a wheel!", + "Type": "Fun", + "Settings": [ + { + "Name": "spin_speed", + "Type": "number", + "Label": "Roll speed", + "Description": "Rotations per minute" + }, + { + "Name": "direction", + "Type": "string", + "Label": "Direction", + "Description": "The direction of rotation" + } + ], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "MU", + "Name": "Muted", + "Description": "Can you still feel the rhythm without music?", + "Type": "Fun", + "Settings": [ + { + "Name": "inverse_muting", + "Type": "boolean", + "Label": "Start muted", + "Description": "Increase volume as combo builds." + }, + { + "Name": "enable_metronome", + "Type": "boolean", + "Label": "Enable metronome", + "Description": "Add a metronome beat to help you keep track of the rhythm." + }, + { + "Name": "mute_combo_count", + "Type": "number", + "Label": "Final volume at combo", + "Description": "The combo count at which point the track reaches its final volume." + }, + { + "Name": "affects_hit_sounds", + "Type": "boolean", + "Label": "Mute hit sounds", + "Description": "Hit sounds are also muted alongside the track." + } + ], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "AS", + "Name": "Adaptive Speed", + "Description": "Let track speed adapt to you.", + "Type": "Fun", + "Settings": [ + { + "Name": "initial_rate", + "Type": "number", + "Label": "Initial rate", + "Description": "The starting speed of the track" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "AT", + "WU", + "WD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "SY", + "Name": "Synesthesia", + "Description": "Colours hit objects based on the rhythm.", + "Type": "Fun", + "Settings": [ + { + "Name": "interval_colouring", + "Type": "boolean", + "Label": "Interval colouring", + "Description": "Colour hitobjects based on distance to previous/next hitobject." + } + ], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "TD", + "Name": "Touch Device", + "Description": "Automatically applied to plays on devices with a touchscreen.", + "Type": "System", + "Settings": [], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": true + } + ] + }, + { + "Name": "tau", + "RulesetID": 11, + "Mods": [ + { + "Acronym": "EZ", + "Name": "Easy", + "Description": "Larger paddle, more forgiving HP drain, less accuracy required, and three lives!", + "Type": "DifficultyReduction", + "Settings": [ + { + "Name": "retries", + "Type": "number", + "Label": "Extra Lives", + "Description": "Number of extra lives" + } + ], + "IncompatibleMods": [ + "HR", + "DA" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "NF", + "Name": "No Fail", + "Description": "You can't fail, no matter what.", + "Type": "DifficultyReduction", + "Settings": [], + "IncompatibleMods": [ + "SD", + "PF", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "HT", + "Name": "Half Time", + "Description": "Less zoom...", + "Type": "DifficultyReduction", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed decrease", + "Description": "The actual decrease to apply" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DC", + "Name": "Daycore", + "Description": "Whoaaaaa...", + "Type": "DifficultyReduction", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed decrease", + "Description": "The actual decrease to apply" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "LN", + "Name": "Lenience", + "Description": "Hard beats are more forgiving", + "Type": "DifficultyReduction", + "Settings": [], + "IncompatibleMods": [ + "ST", + "LT" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "HR", + "Name": "Hard Rock", + "Description": "Everything just got a bit harder...", + "Type": "DifficultyIncrease", + "Settings": [], + "IncompatibleMods": [ + "EZ", + "DA" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "SD", + "Name": "Sudden Death", + "Description": "Miss and fail.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "restart", + "Type": "boolean", + "Label": "Restart on fail", + "Description": "Automatically restarts when failed." + } + ], + "IncompatibleMods": [ + "NF", + "PF", + "CN", + "AP" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "PF", + "Name": "Perfect", + "Description": "SS or quit.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "restart", + "Type": "boolean", + "Label": "Restart on fail", + "Description": "Automatically restarts when failed." + } + ], + "IncompatibleMods": [ + "NF", + "SD", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DT", + "Name": "Double Time", + "Description": "Zoooooooooom...", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed increase", + "Description": "The actual increase to apply" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "NC", + "Name": "Nightcore", + "Description": "Uguuuuuuuu...", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed increase", + "Description": "The actual increase to apply" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "FO", + "Name": "Fade Out", + "Description": "Beats fade out before you hit them!", + "Type": "DifficultyIncrease", + "Settings": [], + "IncompatibleMods": [ + "IN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "FI", + "Name": "Fade In", + "Description": "Beats appear out of nowhere!", + "Type": "DifficultyIncrease", + "Settings": [], + "IncompatibleMods": [ + "IN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "FL", + "Name": "Flashlight", + "Description": "Restricted view area.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "size_multiplier", + "Type": "number", + "Label": "Flashlight size", + "Description": "Multiplier applied to the default flashlight size." + }, + { + "Name": "combo_based_size", + "Type": "boolean", + "Label": "Change size based on combo", + "Description": "Decrease the flashlight size as combo increases." + } + ], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "ST", + "Name": "Strict", + "Description": "Aim the hard beats!", + "Type": "DifficultyIncrease", + "Settings": [], + "IncompatibleMods": [ + "LN", + "LT" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DA", + "Name": "Difficulty Adjust", + "Description": "Override a beatmap's difficulty settings.", + "Type": "Conversion", + "Settings": [ + { + "Name": "paddle_size", + "Type": "number", + "Label": "Paddle Size", + "Description": "Override a beatmap's set PS." + }, + { + "Name": "approach_rate", + "Type": "number", + "Label": "Approach Rate", + "Description": "Override a beatmap's set AR." + }, + { + "Name": "drain_rate", + "Type": "number", + "Label": "HP Drain", + "Description": "Override a beatmap's set HP." + }, + { + "Name": "overall_difficulty", + "Type": "number", + "Label": "Accuracy", + "Description": "Override a beatmap's set OD." + }, + { + "Name": "extended_limits", + "Type": "boolean", + "Label": "Extended Limits", + "Description": "Adjust difficulty beyond sane limits." + } + ], + "IncompatibleMods": [ + "EZ", + "HR" + ], + "RequiresConfiguration": true, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "LT", + "Name": "Lite", + "Description": "Removes certain aspects of the ruleset.", + "Type": "Conversion", + "Settings": [ + { + "Name": "toggle_sliders", + "Type": "boolean", + "Label": "Sliders conversion", + "Description": "Completely disables sliders altogether." + }, + { + "Name": "toggle_hard_beats", + "Type": "boolean", + "Label": "Hard beats conversion", + "Description": "Completely disables hard beats altogether." + }, + { + "Name": "sliders_division_level", + "Type": "number", + "Label": "Slider division level", + "Description": "The minimum slider length divisor." + } + ], + "IncompatibleMods": [ + "LN", + "ST" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "AT", + "Name": "Autoplay", + "Description": "Watch a perfect automated play through the song.", + "Type": "Automation", + "Settings": [], + "IncompatibleMods": [ + "CN", + "RX", + "AP", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": false, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "AT", + "Name": "Autoplay", + "Description": "Watch a perfect automated play through the song.", + "Type": "Automation", + "Settings": [], + "IncompatibleMods": [ + "CN", + "RX", + "AP", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": false, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "CN", + "Name": "Cinema", + "Description": "Watch the video without visual distractions.", + "Type": "Automation", + "Settings": [], + "IncompatibleMods": [ + "NF", + "SD", + "PF", + "AT", + "AT", + "CN", + "RX", + "AP", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": false, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "RX", + "Name": "Relax", + "Description": "You don't need to click. Give your clicking/tapping fingers a break from the heat of things.", + "Type": "Automation", + "Settings": [], + "IncompatibleMods": [ + "AT", + "AT", + "CN", + "AP" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "AP", + "Name": "Autopilot", + "Description": "Automatic paddle movement - just follow the rhythm.", + "Type": "Automation", + "Settings": [], + "IncompatibleMods": [ + "NF", + "SD", + "PF", + "AT", + "AT", + "CN", + "RX" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "WU", + "Name": "Wind Up", + "Description": "Can you keep up?", + "Type": "Fun", + "Settings": [ + { + "Name": "initial_rate", + "Type": "number", + "Label": "Initial rate", + "Description": "The starting speed of the track" + }, + { + "Name": "final_rate", + "Type": "number", + "Label": "Final rate", + "Description": "The final speed to ramp to" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WD", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "WD", + "Name": "Wind Down", + "Description": "Sloooow doooown...", + "Type": "Fun", + "Settings": [ + { + "Name": "initial_rate", + "Type": "number", + "Label": "Initial rate", + "Description": "The starting speed of the track" + }, + { + "Name": "final_rate", + "Type": "number", + "Label": "Final rate", + "Description": "The final speed to ramp to" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "AS", + "Name": "Adaptive Speed", + "Description": "Let track speed adapt to you.", + "Type": "Fun", + "Settings": [ + { + "Name": "initial_rate", + "Type": "number", + "Label": "Initial rate", + "Description": "The starting speed of the track" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "AT", + "AT", + "CN", + "WU", + "WD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "IN", + "Name": "Inverse", + "Description": "Beats will appear outside of the playfield.", + "Type": "Fun", + "Settings": [], + "IncompatibleMods": [ + "FO", + "FI" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "IS", + "Name": "Impossible Sliders", + "Description": "Entirely removes the check for very sharp angles", + "Type": "Fun", + "Settings": [], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "RB", + "Name": "Roundabout", + "Description": "You can only rotate the paddle in one direction.", + "Type": "Fun", + "Settings": [ + { + "Name": "direction", + "Type": "string", + "Label": "Direction", + "Description": "" + } + ], + "IncompatibleMods": [ + "AT" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "NS", + "Name": "No Scope", + "Description": "Where's the paddle?", + "Type": "Fun", + "Settings": [ + { + "Name": "hidden_combo_count", + "Type": "number", + "Label": "Hidden at combo", + "Description": "The combo count at which the paddle becomes completely hidden" + } + ], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "TC", + "Name": "Traceable", + "Description": "Brim with no yankie", + "Type": "Fun", + "Settings": [], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DL", + "Name": "Dual", + "Description": "When one isn't enough", + "Type": "Fun", + "Settings": [ + { + "Name": "paddle_count", + "Type": "number", + "Label": "Paddle count", + "Description": "" + } + ], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + } + ] + }, + { + "Name": "rush", + "RulesetID": 12, + "Mods": [ + { + "Acronym": "NF", + "Name": "No Fail", + "Description": "You can't fail, no matter what.", + "Type": "DifficultyReduction", + "Settings": [], + "IncompatibleMods": [ + "SD", + "PF", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "HT", + "Name": "Half Time", + "Description": "Less zoom...", + "Type": "DifficultyReduction", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed decrease", + "Description": "The actual decrease to apply" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DC", + "Name": "Daycore", + "Description": "Whoaaaaa...", + "Type": "DifficultyReduction", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed decrease", + "Description": "The actual decrease to apply" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "SD", + "Name": "Sudden Death", + "Description": "Miss and fail.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "restart", + "Type": "boolean", + "Label": "Restart on fail", + "Description": "Automatically restarts when failed." + } + ], + "IncompatibleMods": [ + "NF", + "PF", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "PF", + "Name": "Perfect", + "Description": "SS or quit.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "restart", + "Type": "boolean", + "Label": "Restart on fail", + "Description": "Automatically restarts when failed." + } + ], + "IncompatibleMods": [ + "NF", + "SD", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DT", + "Name": "Double Time", + "Description": "Zoooooooooom...", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed increase", + "Description": "The actual increase to apply" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "NC", + "Name": "Nightcore", + "Description": "Uguuuuuuuu...", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed increase", + "Description": "The actual increase to apply" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "FL", + "Name": "Flashlight", + "Description": "Restricted view area.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "size_multiplier", + "Type": "number", + "Label": "Flashlight size", + "Description": "Multiplier applied to the default flashlight size." + }, + { + "Name": "combo_based_size", + "Type": "boolean", + "Label": "Change size based on combo", + "Description": "Decrease the flashlight size as combo increases." + } + ], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "AT", + "Name": "Autoplay", + "Description": "Watch a perfect automated play through the song.", + "Type": "Automation", + "Settings": [], + "IncompatibleMods": [ + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": false, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "CN", + "Name": "Cinema", + "Description": "Watch the video without visual distractions.", + "Type": "Automation", + "Settings": [], + "IncompatibleMods": [ + "NF", + "SD", + "PF", + "AT", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": false, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "WU", + "Name": "Wind Up", + "Description": "Can you keep up?", + "Type": "Fun", + "Settings": [ + { + "Name": "initial_rate", + "Type": "number", + "Label": "Initial rate", + "Description": "The starting speed of the track" + }, + { + "Name": "final_rate", + "Type": "number", + "Label": "Final rate", + "Description": "The final speed to ramp to" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "WD", + "Name": "Wind Down", + "Description": "Sloooow doooown...", + "Type": "Fun", + "Settings": [ + { + "Name": "initial_rate", + "Type": "number", + "Label": "Initial rate", + "Description": "The starting speed of the track" + }, + { + "Name": "final_rate", + "Type": "number", + "Label": "Final rate", + "Description": "The final speed to ramp to" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + } + ] + }, + { + "Name": "hishigata", + "RulesetID": 13, + "Mods": [ + { + "Acronym": "HT", + "Name": "Half Time", + "Description": "Less zoom...", + "Type": "DifficultyReduction", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed decrease", + "Description": "The actual decrease to apply" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DC", + "Name": "Daycore", + "Description": "Whoaaaaa...", + "Type": "DifficultyReduction", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed decrease", + "Description": "The actual decrease to apply" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "NF", + "Name": "No Fail", + "Description": "You can't fail, no matter what.", + "Type": "DifficultyReduction", + "Settings": [], + "IncompatibleMods": [ + "SD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "HR", + "Name": "Hard Rock", + "Description": "Everything just got a bit harder...", + "Type": "DifficultyIncrease", + "Settings": [], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "SD", + "Name": "Sudden Death", + "Description": "Miss and fail.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "restart", + "Type": "boolean", + "Label": "Restart on fail", + "Description": "Automatically restarts when failed." + } + ], + "IncompatibleMods": [ + "NF" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DT", + "Name": "Double Time", + "Description": "Zoooooooooom...", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed increase", + "Description": "The actual increase to apply" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "NC", + "Name": "Nightcore", + "Description": "Uguuuuuuuu...", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed increase", + "Description": "The actual increase to apply" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "HD", + "Name": "Hidden", + "Description": "Notes fade out just before you hit them.", + "Type": "DifficultyIncrease", + "Settings": [], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "TW", + "Name": "Trustworthy", + "Description": "Notes don't switch sides.", + "Type": "Conversion", + "Settings": [], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "AT", + "Name": "Autoplay", + "Description": "Watch a perfect automated play through the song.", + "Type": "Automation", + "Settings": [], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": false, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "WU", + "Name": "Wind Up", + "Description": "Can you keep up?", + "Type": "Fun", + "Settings": [ + { + "Name": "initial_rate", + "Type": "number", + "Label": "Initial rate", + "Description": "The starting speed of the track" + }, + { + "Name": "final_rate", + "Type": "number", + "Label": "Final rate", + "Description": "The final speed to ramp to" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "WD", + "Name": "Wind Down", + "Description": "Sloooow doooown...", + "Type": "Fun", + "Settings": [ + { + "Name": "initial_rate", + "Type": "number", + "Label": "Initial rate", + "Description": "The starting speed of the track" + }, + { + "Name": "final_rate", + "Type": "number", + "Label": "Final rate", + "Description": "The final speed to ramp to" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + } + ] + }, + { + "Name": "soyokaze", + "RulesetID": 14, + "Mods": [ + { + "Acronym": "EZ", + "Name": "Easy", + "Description": "Larger circles, more forgiving HP drain, less accuracy required, and multiple lives. As a bonus, it makes the map impossible to read!", + "Type": "DifficultyReduction", + "Settings": [ + { + "Name": "retries", + "Type": "number", + "Label": "Extra Lives", + "Description": "Number of extra lives" + } + ], + "IncompatibleMods": [ + "HR", + "DA" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "NF", + "Name": "No Fail", + "Description": "You can't fail, no matter what.", + "Type": "DifficultyReduction", + "Settings": [], + "IncompatibleMods": [ + "SD", + "PF", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "HT", + "Name": "Half Time", + "Description": "Less zoom...", + "Type": "DifficultyReduction", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed decrease", + "Description": "The actual decrease to apply" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DC", + "Name": "Daycore", + "Description": "Whoaaaaa...", + "Type": "DifficultyReduction", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed decrease", + "Description": "The actual decrease to apply" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "ST", + "Name": "Staccato", + "Description": "We hate hold notes!", + "Type": "DifficultyReduction", + "Settings": [], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "HR", + "Name": "Hard Rock", + "Description": "Everything just got a bit harder...", + "Type": "DifficultyIncrease", + "Settings": [], + "IncompatibleMods": [ + "EZ", + "DA" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "SD", + "Name": "Sudden Death", + "Description": "Miss and fail.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "restart", + "Type": "boolean", + "Label": "Restart on fail", + "Description": "Automatically restarts when failed." + } + ], + "IncompatibleMods": [ + "NF", + "PF", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "PF", + "Name": "Perfect", + "Description": "SS or quit.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "restart", + "Type": "boolean", + "Label": "Restart on fail", + "Description": "Automatically restarts when failed." + } + ], + "IncompatibleMods": [ + "NF", + "SD", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DT", + "Name": "Double Time", + "Description": "Zoooooooooom...", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed increase", + "Description": "The actual increase to apply" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "NC", + "Name": "Nightcore", + "Description": "Uguuuuuuuu...", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed increase", + "Description": "The actual increase to apply" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "HD", + "Name": "Hidden", + "Description": "Play with fading circles.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "fading_approach_circle", + "Type": "boolean", + "Label": "Fading Approach Circle", + "Description": "Allow approach circles to fade instead of disappearing" + } + ], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "RD", + "Name": "Random", + "Description": "Shuffle around the notes!", + "Type": "Conversion", + "Settings": [ + { + "Name": "seed", + "Type": "number", + "Label": "Seed", + "Description": "Use a custom seed instead of a random one" + } + ], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DA", + "Name": "Difficulty Adjust", + "Description": "Override a beatmap's difficulty settings.", + "Type": "Conversion", + "Settings": [ + { + "Name": "circle_size", + "Type": "number", + "Label": "Circle Size", + "Description": "Override a beatmap's set CS." + }, + { + "Name": "approach_rate", + "Type": "number", + "Label": "Approach Rate", + "Description": "Override a beatmap's set AR." + }, + { + "Name": "drain_rate", + "Type": "number", + "Label": "HP Drain", + "Description": "Override a beatmap's set HP." + }, + { + "Name": "overall_difficulty", + "Type": "number", + "Label": "Accuracy", + "Description": "Override a beatmap's set OD." + }, + { + "Name": "extended_limits", + "Type": "boolean", + "Label": "Extended Limits", + "Description": "Adjust difficulty beyond sane limits." + } + ], + "IncompatibleMods": [ + "EZ", + "HR" + ], + "RequiresConfiguration": true, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "AT", + "Name": "Autoplay", + "Description": "Watch a perfect automated play through the song.", + "Type": "Automation", + "Settings": [], + "IncompatibleMods": [ + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": false, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "CN", + "Name": "Cinema", + "Description": "Watch the video without visual distractions.", + "Type": "Automation", + "Settings": [], + "IncompatibleMods": [ + "NF", + "SD", + "PF", + "AT", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": false, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "WU", + "Name": "Wind Up", + "Description": "Can you keep up?", + "Type": "Fun", + "Settings": [ + { + "Name": "initial_rate", + "Type": "number", + "Label": "Initial rate", + "Description": "The starting speed of the track" + }, + { + "Name": "final_rate", + "Type": "number", + "Label": "Final rate", + "Description": "The final speed to ramp to" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "WD", + "Name": "Wind Down", + "Description": "Sloooow doooown...", + "Type": "Fun", + "Settings": [ + { + "Name": "initial_rate", + "Type": "number", + "Label": "Initial rate", + "Description": "The starting speed of the track" + }, + { + "Name": "final_rate", + "Type": "number", + "Label": "Final rate", + "Description": "The final speed to ramp to" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "HO", + "Name": "Holds [Obsolete]", + "Description": "", + "Type": "Fun", + "Settings": [], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + } + ] } -] +] \ No newline at end of file diff --git a/tools/recalculate.py b/tools/recalculate.py index 3d9b251..5f89bc1 100644 --- a/tools/recalculate.py +++ b/tools/recalculate.py @@ -12,7 +12,7 @@ import warnings sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -from app.calculator import calculate_pp, calculate_score_to_level +from app.calculator import calculate_pp, calculate_score_to_level, init_calculator from app.config import settings from app.const import BANCHOBOT_ID from app.database import TotalScoreBestScore, UserStatistics @@ -546,6 +546,7 @@ async def recalculate(config: RecalculateConfig) -> None: init_mods() init_ranked_mods() + await init_calculator() targets = await determine_targets(config) if not targets: