Files
g0v0-server/tools/recalculate.py
2025-08-22 11:16:08 +00:00

292 lines
10 KiB
Python

from __future__ import annotations
import asyncio
import math
import os
import sys
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from app.calculator import (
calculate_pp,
calculate_score_to_level,
calculate_weighted_acc,
calculate_weighted_pp,
clamp,
)
from app.config import settings
from app.const import BANCHOBOT_ID
from app.database import BestScore, UserStatistics
from app.database.beatmap import Beatmap
from app.database.pp_best_score import PPBestScore
from app.database.score import Score, calculate_playtime, get_user_best_pp
from app.dependencies.database import engine, get_redis
from app.dependencies.fetcher import get_fetcher
from app.fetcher import Fetcher
from app.log import logger
from app.models.mods import mod_to_save, mods_can_get_pp
from app.models.score import GameMode, Rank
from httpx import HTTPError
from redis.asyncio import Redis
from sqlalchemy.orm import joinedload
from sqlmodel import col, delete, select
from sqlmodel.ext.asyncio.session import AsyncSession
async def recalculate():
async with AsyncSession(engine, autoflush=False) as session:
fetcher = await get_fetcher()
redis = get_redis()
for mode in GameMode:
await session.execute(delete(PPBestScore).where(col(PPBestScore.gamemode) == mode))
await session.execute(delete(BestScore).where(col(BestScore.gamemode) == mode))
await session.commit()
logger.info(f"Recalculating for mode: {mode}")
statistics_list = (
await session.exec(
select(UserStatistics).where(
UserStatistics.mode == mode,
UserStatistics.user_id != BANCHOBOT_ID,
)
)
).all()
await asyncio.gather(
*[
_recalculate_pp(statistics.user_id, statistics.mode, session, fetcher, redis)
for statistics in statistics_list
]
)
await asyncio.gather(
*[
_recalculate_best_score(statistics.user_id, statistics.mode, session)
for statistics in statistics_list
]
)
await session.commit()
await asyncio.gather(*[_recalculate_statistics(statistics, session) for statistics in statistics_list])
await session.commit()
logger.success(f"Recalculated for mode: {mode}, total users: {len(statistics_list)}")
await engine.dispose()
async def _recalculate_pp(
user_id: int,
gamemode: GameMode,
session: AsyncSession,
fetcher: Fetcher,
redis: Redis,
):
scores = (
await session.exec(
select(Score).where(
Score.user_id == user_id,
Score.gamemode == gamemode,
col(Score.passed).is_(True),
)
)
).all()
prev: dict[int, PPBestScore] = {}
async def cal(score: Score):
time = 10
beatmap_id = score.beatmap_id
while time > 0:
try:
db_beatmap = await Beatmap.get_or_fetch(session, fetcher, bid=beatmap_id)
except HTTPError:
time -= 1
await asyncio.sleep(2)
continue
ranked = db_beatmap.beatmap_status.has_pp() | settings.enable_all_beatmap_pp
if not ranked or not mods_can_get_pp(int(score.gamemode), score.mods):
score.pp = 0
return
try:
beatmap_raw = await fetcher.get_or_fetch_beatmap_raw(redis, beatmap_id)
pp = await calculate_pp(score, beatmap_raw, session)
if pp == 0:
return
if score.beatmap_id not in prev or prev[score.beatmap_id].pp < pp:
best_score = PPBestScore(
user_id=user_id,
beatmap_id=beatmap_id,
acc=score.accuracy,
score_id=score.id,
pp=pp,
gamemode=score.gamemode,
)
prev[score.beatmap_id] = best_score
return
except HTTPError:
time -= 1
await asyncio.sleep(2)
continue
except Exception:
logger.exception(f"Error calculating pp for score {score.id} on beatmap {beatmap_id}")
return
if time <= 0:
logger.warning(f"Failed to fetch beatmap {beatmap_id} after 10 attempts, retrying later...")
return score
while len(scores) > 0:
results = await asyncio.gather(*[cal(s) for s in scores])
scores = [s for s in results if s is not None]
if len(scores) == 0:
break
await asyncio.sleep(30)
logger.info(f"Retry to calculate for {gamemode}, total: {len(scores)}")
for best_score in prev.values():
session.add(best_score)
async def _recalculate_best_score(
user_id: int,
gamemode: GameMode,
session: AsyncSession,
):
beatmap_best_score: dict[int, list[BestScore]] = {}
scores = (
await session.exec(
select(Score).where(
Score.gamemode == gamemode,
col(Score.passed).is_(True),
Score.user_id == user_id,
)
)
).all()
for score in scores:
if not (
(await score.awaitable_attrs.beatmap).beatmap_status.has_leaderboard()
| settings.enable_all_beatmap_leaderboard
):
continue
mod_for_save = mod_to_save(score.mods)
bs = BestScore(
user_id=score.user_id,
score_id=score.id,
beatmap_id=score.beatmap_id,
gamemode=score.gamemode,
total_score=score.total_score,
mods=mod_for_save,
rank=score.rank,
)
if score.beatmap_id not in beatmap_best_score:
beatmap_best_score[score.beatmap_id] = [bs]
else:
b = next(
(
s
for s in beatmap_best_score[score.beatmap_id]
if s.mods == mod_for_save and s.beatmap_id == score.beatmap_id
),
None,
)
if b is None:
beatmap_best_score[score.beatmap_id].append(bs)
elif score.total_score > b.total_score:
beatmap_best_score[score.beatmap_id].remove(b)
beatmap_best_score[score.beatmap_id].append(bs)
for best_score_in_beatmap in beatmap_best_score.values():
for score in best_score_in_beatmap:
session.add(score)
async def _recalculate_statistics(statistics: UserStatistics, session: AsyncSession):
await session.refresh(statistics)
pp_sum = 0
acc_sum = 0
bps = await get_user_best_pp(session, statistics.user_id, statistics.mode)
for i, s in enumerate(bps):
pp_sum += calculate_weighted_pp(s.pp, i)
acc_sum += calculate_weighted_acc(s.acc, i)
if len(bps):
# https://github.com/ppy/osu-queue-score-statistics/blob/c538ae/osu.Server.Queues.ScoreStatisticsProcessor/Helpers/UserTotalPerformanceAggregateHelper.cs#L41-L45
acc_sum *= 100 / (20 * (1 - math.pow(0.95, len(bps))))
acc_sum = clamp(acc_sum, 0.0, 100.0)
statistics.pp = pp_sum
statistics.hit_accuracy = acc_sum
statistics.play_count = 0
statistics.total_score = 0
statistics.maximum_combo = 0
statistics.play_time = 0
statistics.total_hits = 0
statistics.count_100 = 0
statistics.count_300 = 0
statistics.count_50 = 0
statistics.count_miss = 0
statistics.ranked_score = 0
statistics.grade_ss = 0
statistics.grade_ssh = 0
statistics.grade_s = 0
statistics.grade_sh = 0
statistics.grade_a = 0
scores = (
await session.exec(
select(Score)
.where(
Score.user_id == statistics.user_id,
Score.gamemode == statistics.mode,
)
.options(joinedload(Score.beatmap))
)
).all()
cached_beatmap_best: dict[int, Score] = {}
for score in scores:
beatmap: Beatmap = score.beatmap
ranked = beatmap.beatmap_status.has_pp() | settings.enable_all_mods_pp
statistics.play_count += 1
statistics.total_score += score.total_score
playtime, is_valid = calculate_playtime(score, beatmap.hit_length)
if is_valid:
statistics.play_time += playtime
statistics.count_300 += score.n300 + score.ngeki
statistics.count_100 += score.n100 + score.nkatu
statistics.count_50 += score.n50
statistics.count_miss += score.nmiss
statistics.total_hits += score.n300 + score.ngeki + score.n100 + score.nkatu + score.n50
if ranked and score.passed:
statistics.maximum_combo = max(statistics.maximum_combo, score.max_combo)
previous = cached_beatmap_best.get(score.beatmap_id)
difference = score.total_score - (previous.total_score if previous else 0)
if difference > 0:
cached_beatmap_best[score.beatmap_id] = score
statistics.ranked_score += difference
match score.rank:
case Rank.X:
statistics.grade_ss += 1
case Rank.XH:
statistics.grade_ssh += 1
case Rank.S:
statistics.grade_s += 1
case Rank.SH:
statistics.grade_sh += 1
case Rank.A:
statistics.grade_a += 1
if previous is not None:
match previous.rank:
case Rank.X:
statistics.grade_ss -= 1
case Rank.XH:
statistics.grade_ssh -= 1
case Rank.S:
statistics.grade_s -= 1
case Rank.SH:
statistics.grade_sh -= 1
case Rank.A:
statistics.grade_a -= 1
statistics.level_current = calculate_score_to_level(statistics.total_score)
if __name__ == "__main__":
asyncio.run(recalculate())