feat(calculator): support generate PerformanceAttributes & DifficultyAttributes from JSON Schema (#59)

Prepare for custom rulesets.

Schema Genetator: https://github.com/GooGuTeam/custom-rulesets/tree/main/CustomRulesetMetadataGenerator

```bash
dotnet -- schemas path/to/rulesets -o schema.json
```

```bash
python scripts/generate_ruleset_attributes.py schema.json 
```
This commit is contained in:
MingxuanGame
2025-10-25 19:10:53 +08:00
committed by GitHub
parent f792d146b5
commit 2c81e22749
12 changed files with 370 additions and 85 deletions

View File

@@ -2,7 +2,7 @@ import abc
from typing import TYPE_CHECKING
from app.models.mods import APIMod
from app.models.performance import BeatmapAttributes, PerformanceAttributes
from app.models.performance import DifficultyAttributes, PerformanceAttributes
from app.models.score import GameMode
if TYPE_CHECKING:
@@ -33,5 +33,5 @@ class PerformanceCalculator(abc.ABC):
@abc.abstractmethod
async def calculate_difficulty(
self, beatmap_raw: str, mods: list[APIMod] | None = None, gamemode: GameMode | None = None
) -> BeatmapAttributes:
) -> DifficultyAttributes:
raise NotImplementedError

View File

@@ -2,10 +2,10 @@ from typing import TYPE_CHECKING
from app.models.mods import APIMod
from app.models.performance import (
DIFFICULTY_CLASS,
PERFORMANCE_CLASS,
BeatmapAttributes,
DifficultyAttributes,
DifficultyAttributesUnion,
PerformanceAttributes,
PerformanceAttributesUnion,
)
from app.models.score import GameMode
@@ -17,6 +17,7 @@ from ._base import (
)
from httpx import AsyncClient, HTTPError
from pydantic import TypeAdapter
if TYPE_CHECKING:
from app.database.score import Score
@@ -56,8 +57,7 @@ class PerformanceCalculator(BasePerformanceCalculator):
)
if resp.status_code != 200:
raise PerformanceError(f"Failed to calculate performance: {resp.text}")
data = resp.json()
return PERFORMANCE_CLASS.get(score.gamemode, PerformanceAttributes).model_validate(data)
return TypeAdapter(PerformanceAttributesUnion).validate_json(resp.text)
except HTTPError as e:
raise PerformanceError(f"Failed to calculate performance: {e}") from e
except Exception as e:
@@ -65,7 +65,7 @@ class PerformanceCalculator(BasePerformanceCalculator):
async def calculate_difficulty(
self, beatmap_raw: str, mods: list[APIMod] | None = None, gamemode: GameMode | None = None
) -> BeatmapAttributes:
) -> DifficultyAttributes:
# https://github.com/GooGuTeam/osu-performance-server#post-difficulty
async with AsyncClient() as client:
try:
@@ -79,9 +79,7 @@ class PerformanceCalculator(BasePerformanceCalculator):
)
if resp.status_code != 200:
raise DifficultyError(f"Failed to calculate difficulty: {resp.text}")
data = resp.json()
ruleset_id = data.pop("ruleset", "osu")
return DIFFICULTY_CLASS.get(GameMode(ruleset_id), BeatmapAttributes).model_validate(data)
return TypeAdapter(DifficultyAttributesUnion).validate_json(resp.text)
except HTTPError as e:
raise DifficultyError(f"Failed to calculate difficulty: {e}") from e
except Exception as e:

View File

@@ -5,14 +5,12 @@ from typing import TYPE_CHECKING
from app.calculator import clamp
from app.models.mods import APIMod, parse_enum_to_str
from app.models.performance import (
DIFFICULTY_CLASS,
PERFORMANCE_CLASS,
BeatmapAttributes,
DifficultyAttributes,
ManiaPerformanceAttributes,
OsuBeatmapAttributes,
OsuDifficultyAttributes,
OsuPerformanceAttributes,
PerformanceAttributes,
TaikoBeatmapAttributes,
TaikoDifficultyAttributes,
TaikoPerformanceAttributes,
)
from app.models.score import GameMode
@@ -38,6 +36,16 @@ except ImportError:
" gu: uv add git+https://github.com/GooGuTeam/gu-pp-py.git"
)
PERFORMANCE_CLASS = {
GameMode.OSU: OsuPerformanceAttributes,
GameMode.TAIKO: TaikoPerformanceAttributes,
GameMode.MANIA: ManiaPerformanceAttributes,
}
DIFFICULTY_CLASS = {
GameMode.OSU: OsuDifficultyAttributes,
GameMode.TAIKO: TaikoDifficultyAttributes,
}
class PerformanceCalculator(BasePerformanceCalculator):
@classmethod
@@ -75,6 +83,10 @@ class PerformanceCalculator(BasePerformanceCalculator):
flashlight=attr.pp_flashlight or 0,
effective_miss_count=attr.effective_miss_count or 0,
speed_deviation=attr.speed_deviation,
combo_based_estimated_miss_count=0,
score_based_estimated_miss_count=0,
aim_estimated_slider_breaks=0,
speed_estimated_slider_breaks=0,
)
elif attr_class is TaikoPerformanceAttributes:
return TaikoPerformanceAttributes(
@@ -120,11 +132,11 @@ class PerformanceCalculator(BasePerformanceCalculator):
raise CalculateError(f"Unknown error: {e}") from e
@classmethod
def _diff_attr_to_model(cls, diff: rosu.DifficultyAttributes, gamemode: GameMode) -> BeatmapAttributes:
attr_class = DIFFICULTY_CLASS.get(gamemode, BeatmapAttributes)
def _diff_attr_to_model(cls, diff: rosu.DifficultyAttributes, gamemode: GameMode) -> DifficultyAttributes:
attr_class = DIFFICULTY_CLASS.get(gamemode, DifficultyAttributes)
if attr_class is OsuBeatmapAttributes:
return OsuBeatmapAttributes(
if attr_class is OsuDifficultyAttributes:
return OsuDifficultyAttributes(
star_rating=diff.stars,
max_combo=diff.max_combo,
aim_difficulty=diff.aim or 0,
@@ -135,23 +147,29 @@ class PerformanceCalculator(BasePerformanceCalculator):
aim_difficult_strain_count=diff.aim_difficult_strain_count or 0,
speed_difficult_strain_count=diff.speed_difficult_strain_count or 0,
flashlight_difficulty=diff.flashlight or 0,
aim_top_weighted_slider_factor=0,
speed_top_weighted_slider_factor=0,
nested_score_per_object=0,
legacy_score_base_multiplier=0,
maximum_legacy_combo_score=0,
)
elif attr_class is TaikoBeatmapAttributes:
return TaikoBeatmapAttributes(
elif attr_class is TaikoDifficultyAttributes:
return TaikoDifficultyAttributes(
star_rating=diff.stars,
max_combo=diff.max_combo,
rhythm_difficulty=diff.rhythm or 0,
mono_stamina_factor=diff.stamina or 0,
consistency_factor=0,
)
else:
return BeatmapAttributes(
return DifficultyAttributes(
star_rating=diff.stars,
max_combo=diff.max_combo,
)
async def calculate_difficulty(
self, beatmap_raw: str, mods: list[APIMod] | None = None, gamemode: GameMode | None = None
) -> BeatmapAttributes:
) -> DifficultyAttributes:
try:
map = rosu.Beatmap(content=beatmap_raw)
if gamemode is not None: