This commit is contained in:
咕谷酱
2025-08-18 17:15:43 +08:00
10 changed files with 280 additions and 27 deletions

View File

@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING
from app.calculator import calculate_beatmap_attribute
from app.config import settings
from app.database.failtime import FailTime, FailTimeResp
from app.models.beatmap import BeatmapAttributes, BeatmapRankStatus
from app.models.mods import APIMod
from app.models.score import GameMode
@@ -67,6 +68,9 @@ class Beatmap(BeatmapBase, table=True):
beatmapset: Beatmapset = Relationship(
back_populates="beatmaps", sa_relationship_kwargs={"lazy": "joined"}
)
failtimes: FailTime | None = Relationship(
back_populates="beatmap", sa_relationship_kwargs={"lazy": "joined"}
)
@classmethod
async def from_resp(cls, session: AsyncSession, resp: "BeatmapResp") -> "Beatmap":
@@ -156,6 +160,7 @@ class BeatmapResp(BeatmapBase):
url: str = ""
playcount: int = 0
passcount: int = 0
failtimes: FailTimeResp | None = None
@classmethod
async def from_db(
@@ -187,6 +192,10 @@ class BeatmapResp(BeatmapBase):
beatmap_["beatmapset"] = await BeatmapsetResp.from_db(
beatmap.beatmapset, session=session, user=user
)
if beatmap.failtimes is not None:
beatmap_["failtimes"] = FailTimeResp.from_db(beatmap.failtimes)
else:
beatmap_["failtimes"] = FailTimeResp()
if session:
beatmap_["playcount"] = (
await session.exec(

56
app/database/failtime.py Normal file
View File

@@ -0,0 +1,56 @@
from struct import Struct
from typing import TYPE_CHECKING
from pydantic import BaseModel
from sqlmodel import (
VARBINARY,
Column,
Field,
Relationship,
SQLModel,
)
if TYPE_CHECKING:
from .beatmap import Beatmap
FAILTIME_STRUCT = Struct("<100i")
class FailTime(SQLModel, table=True):
__tablename__ = "failtime" # pyright: ignore[reportAssignmentType]
beatmap_id: int = Field(primary_key=True, index=True, foreign_key="beatmaps.id")
exit: bytes = Field(sa_column=Column(VARBINARY(400), nullable=False))
fail: bytes = Field(sa_column=Column(VARBINARY(400), nullable=False))
beatmap: "Beatmap" = Relationship(back_populates="failtimes")
@property
def exit_(self) -> list[int]:
return list(FAILTIME_STRUCT.unpack(self.exit))
@property
def fail_(self) -> list[int]:
return list(FAILTIME_STRUCT.unpack(self.fail))
@classmethod
def from_resp(cls, beatmap_id: int, failtime: "FailTimeResp") -> "FailTime":
return cls(
beatmap_id=beatmap_id,
exit=FAILTIME_STRUCT.pack(*failtime.exit),
fail=FAILTIME_STRUCT.pack(*failtime.fail),
)
class FailTimeResp(BaseModel):
exit: list[int] = Field(
default_factory=lambda: list(FAILTIME_STRUCT.unpack(b"\x00" * 400))
)
fail: list[int] = Field(
default_factory=lambda: list(FAILTIME_STRUCT.unpack(b"\x00" * 400))
)
@classmethod
def from_db(cls, failtime: FailTime) -> "FailTimeResp":
return cls(
exit=list(FAILTIME_STRUCT.unpack(failtime.exit)),
fail=list(FAILTIME_STRUCT.unpack(failtime.fail)),
)

View File

@@ -15,13 +15,14 @@ from app.calculator import (
from app.config import settings
from app.database.events import Event, EventType
from app.database.team import TeamMember
from app.dependencies.database import get_redis
from app.models.model import (
CurrentUserAttributes,
PinAttributes,
RespWithCursor,
UTCBaseModel,
)
from app.models.mods import APIMod, mod_to_save, mods_can_get_pp
from app.models.mods import APIMod, get_speed_rate, mod_to_save, mods_can_get_pp
from app.models.score import (
GameMode,
HitResult,
@@ -393,7 +394,7 @@ async def get_score_position_by_user(
subq = select(BestScore, rownum).join(Beatmap).where(*wheres).subquery()
stmt = select(subq.c.row_number).where(subq.c.user_id == user.id)
result = await session.exec(stmt)
s = result.one_or_none()
s = result.first()
return s if s else 0
@@ -457,7 +458,10 @@ async def get_user_best_score_with_mod_in_beatmap(
BestScore.gamemode == mode if mode is not None else True,
BestScore.beatmap_id == beatmap,
BestScore.user_id == user,
BestScore.mods == mod,
text(
"JSON_CONTAINS(total_score_best_scores.mods, :w)"
" AND JSON_CONTAINS(:w, total_score_best_scores.mods)"
).params(w=json.dumps(mod)),
)
.order_by(col(BestScore.total_score).desc())
)
@@ -497,11 +501,46 @@ async def get_user_best_pp(
).all()
# https://github.com/ppy/osu-queue-score-statistics/blob/master/osu.Server.Queues.ScoreStatisticsProcessor/Helpers/PlayValidityHelper.cs
def get_play_length(score: Score, beatmap_length: int):
speed_rate = get_speed_rate(score.mods)
length = beatmap_length / speed_rate
return int(min(length, (score.ended_at - score.started_at).total_seconds()))
def calculate_playtime(score: Score, beatmap_length: int) -> tuple[int, bool]:
total_length = get_play_length(score, beatmap_length)
total_obj_hited = (
score.n300
+ score.n100
+ score.n50
+ score.ngeki
+ score.nkatu
+ (score.nlarge_tick_hit or 0)
+ (score.nlarge_tick_miss or 0)
+ (score.nslider_tail_hit or 0)
+ (score.nsmall_tick_hit or 0)
)
total_obj = 0
for statistics, count in score.maximum_statistics.items():
if not isinstance(statistics, HitResult):
statistics = HitResult(statistics)
if statistics.is_scorable():
total_obj += count
return total_length, score.passed or (
total_length > 8
and score.total_score >= 5000
and total_obj_hited >= min(0.1 * total_obj, 20)
)
async def process_user(
session: AsyncSession,
user: User,
score: Score,
length: int,
score_token: int,
beatmap_length: int,
ranked: bool = False,
has_leaderboard: bool = False,
):
@@ -644,7 +683,7 @@ async def process_user(
mods=mod_for_save,
)
)
elif previous_score_best is not None:
if previous_score_best is not None:
previous_score_best.total_score = score.total_score
previous_score_best.rank = score.rank
previous_score_best.mods = mod_for_save
@@ -652,7 +691,11 @@ async def process_user(
statistics.play_count += 1
mouthly_playcount.count += 1
statistics.play_time += length
playtime, is_valid = calculate_playtime(score, beatmap_length)
if is_valid:
redis = get_redis()
await redis.xadd(f"score:existed_time:{score_token}", {"time": playtime})
statistics.play_time += playtime
statistics.count_100 += score.n100 + score.nkatu
statistics.count_300 += score.n300 + score.ngeki
statistics.count_50 += score.n50
@@ -662,18 +705,19 @@ async def process_user(
)
if score.passed and ranked:
best_pp_scores = await get_user_best_pp(session, user.id, score.gamemode)
pp_sum = 0.0
acc_sum = 0.0
for i, bp in enumerate(best_pp_scores):
pp_sum += calculate_weighted_pp(bp.pp, i)
acc_sum += calculate_weighted_acc(bp.acc, i)
if len(best_pp_scores):
# 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(best_pp_scores))))
acc_sum = clamp(acc_sum, 0.0, 100.0)
statistics.pp = pp_sum
statistics.hit_accuracy = acc_sum
with session.no_autoflush:
best_pp_scores = await get_user_best_pp(session, user.id, score.gamemode)
pp_sum = 0.0
acc_sum = 0.0
for i, bp in enumerate(best_pp_scores):
pp_sum += calculate_weighted_pp(bp.pp, i)
acc_sum += calculate_weighted_acc(bp.acc, i)
if len(best_pp_scores):
# 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(best_pp_scores))))
acc_sum = clamp(acc_sum, 0.0, 100.0)
statistics.pp = pp_sum
statistics.hit_accuracy = acc_sum
if add_to_db:
session.add(mouthly_playcount)
await process_beatmap_playcount(session, user.id, score.beatmap_id)

View File

@@ -207,3 +207,11 @@ def mod_to_save(mods: list[APIMod]) -> list[str]:
s = list({mod["acronym"] for mod in mods})
s.sort()
return s
def get_speed_rate(mods: list[APIMod]):
rate = 1.0
for mod in mods:
if mod["acronym"] in {"DT", "NC", "HT", "DC"}:
rate *= mod.get("settings", {}).get("speed_change", 1.0) # pyright: ignore[reportOperatorIssue]
return rate

View File

@@ -159,6 +159,13 @@ class HitResult(str, Enum):
HitResult.MISS,
)
def is_scorable(self) -> bool:
return self not in (
HitResult.NONE,
HitResult.IGNORE_HIT,
HitResult.IGNORE_MISS,
)
class LeaderboardType(Enum):
GLOBAL = "global"

View File

@@ -121,7 +121,13 @@ async def submit_score(
score_id = score.id
score_token.score_id = score_id
await process_user(
db, current_user, score, beatmap_length, has_pp, has_leaderboard
db,
current_user,
score,
token,
beatmap_length,
has_pp,
has_leaderboard,
)
score = (await db.exec(select(Score).where(Score.id == score_id))).first()
assert score is not None

View File

@@ -14,7 +14,7 @@ from app.config import settings
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, get_user_best_pp
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
@@ -245,7 +245,9 @@ async def _recalculate_statistics(statistics: UserStatistics, session: AsyncSess
statistics.play_count += 1
statistics.total_score += score.total_score
statistics.play_time += beatmap.hit_length
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

View File

@@ -1114,6 +1114,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
if settings.match_type == MatchType.PLAYLISTS:
raise InvokeException("Invalid match type selected")
settings.playlist_item_id = room.settings.playlist_item_id
previous_settings = room.settings
room.settings = settings

View File

@@ -8,16 +8,19 @@ import time
from typing import override
from venv import logger
from app.calculator import clamp
from app.config import settings
from app.database import Beatmap, User
from app.database.failtime import FailTime, FailTimeResp
from app.database.score import Score
from app.database.score_token import ScoreToken
from app.dependencies.database import engine
from app.database.statistics import UserStatistics
from app.dependencies.database import engine, get_redis
from app.dependencies.fetcher import get_fetcher
from app.dependencies.storage import get_storage_service
from app.exception import InvokeException
from app.models.mods import mods_to_int
from app.models.score import LegacyReplaySoloScoreInfo, ScoreStatistics
from app.models.mods import APIMod, mods_to_int
from app.models.score import GameMode, LegacyReplaySoloScoreInfo, ScoreStatistics
from app.models.spectator_hub import (
APIUser,
FrameDataBundle,
@@ -164,7 +167,7 @@ class SpectatorHub(Hub[StoreClientState]):
@override
async def _clean_state(self, state: StoreClientState) -> None:
if state.state:
await self._end_session(int(state.connection_id), state.state)
await self._end_session(int(state.connection_id), state.state, state)
for target in self.waited_clients:
target_client = self.get_client_by_id(target)
if target_client:
@@ -254,20 +257,22 @@ class SpectatorHub(Hub[StoreClientState]):
or store.score_token is None
or store.beatmap_status is None
or store.state is None
or store.score is None
):
return
if (
settings.enable_all_beatmap_leaderboard
and store.beatmap_status.has_leaderboard()
) and any(k.is_hit() and v > 0 for k, v in score.score_info.statistics.items()):
await self._process_score(store, client)
await self._end_session(user_id, state, store)
store.state = None
store.beatmap_status = None
store.checksum = None
store.ruleset_id = None
store.score_token = None
store.score = None
await self._end_session(user_id, state)
async def _process_score(self, store: StoreClientState, client: Client) -> None:
user_id = int(client.connection_id)
@@ -319,9 +324,80 @@ class SpectatorHub(Hub[StoreClientState]):
frames=store.score.replay_frames,
)
async def _end_session(self, user_id: int, state: SpectatorState) -> None:
async def _end_session(
self, user_id: int, state: SpectatorState, store: StoreClientState
) -> None:
async def _add_failtime():
async with AsyncSession(engine) as session:
failtime = await session.get(FailTime, state.beatmap_id)
total_length = (
await session.exec(
select(Beatmap.total_length).where(
Beatmap.id == state.beatmap_id
)
)
).one()
index = clamp(round((exit_time / total_length) * 100), 0, 99)
if failtime is not None:
resp = FailTimeResp.from_db(failtime)
else:
resp = FailTimeResp()
if state.state == SpectatedUserState.Failed:
resp.fail[index] += 1
elif state.state == SpectatedUserState.Quit:
resp.exit[index] += 1
new_failtime = FailTime.from_resp(state.beatmap_id, resp) # pyright: ignore[reportArgumentType]
if failtime is not None:
await session.merge(new_failtime)
else:
session.add(new_failtime)
await session.commit()
async def _edit_playtime(token: int, ruleset_id: int, mods: list[APIMod]):
redis = get_redis()
key = f"score:existed_time:{token}"
messages = await redis.xrange(key, min="-", max="+", count=1)
if not messages:
return
before_time = int(messages[0][1]["time"])
await redis.delete(key)
async with AsyncSession(engine) as session:
gamemode = GameMode.from_int(ruleset_id).to_special_mode(mods)
statistics = (
await session.exec(
select(UserStatistics).where(
UserStatistics.user_id == user_id,
UserStatistics.mode == gamemode,
)
)
).first()
if statistics is None:
return
statistics.play_time -= before_time
statistics.play_time += round(min(before_time, exit_time))
if state.state == SpectatedUserState.Playing:
state.state = SpectatedUserState.Quit
exit_time = max(frame.time for frame in store.score.replay_frames) // 1000 # pyright: ignore[reportOptionalMemberAccess]
task = asyncio.create_task(
_edit_playtime(
store.score_token, # pyright: ignore[reportArgumentType]
store.ruleset_id, # pyright: ignore[reportArgumentType]
store.score.score_info.mods, # pyright: ignore[reportOptionalMemberAccess]
)
)
self.tasks.add(task)
task.add_done_callback(self.tasks.discard)
if (
state.state == SpectatedUserState.Failed
or state.state == SpectatedUserState.Quit
):
task = asyncio.create_task(_add_failtime())
self.tasks.add(task)
task.add_done_callback(self.tasks.discard)
logger.info(
f"[SpectatorHub] {user_id} finished playing {state.beatmap_id} "
f"with {state.state}"