Merge branch 'main' of https://github.com/GooGuTeam/g0v0-server
This commit is contained in:
@@ -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
56
app/database/failtime.py
Normal 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)),
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
44
migrations/versions/2fcfc28846c1_beatmap_add_failtime.py
Normal file
44
migrations/versions/2fcfc28846c1_beatmap_add_failtime.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""beatmap: add failtime
|
||||
|
||||
Revision ID: 2fcfc28846c1
|
||||
Revises: 2dcd04d3f4dc
|
||||
Create Date: 2025-08-18 06:06:30.929740
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2fcfc28846c1"
|
||||
down_revision: str | Sequence[str] | None = "2dcd04d3f4dc"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"failtime",
|
||||
sa.Column("beatmap_id", sa.Integer(), nullable=False),
|
||||
sa.Column("exit", sa.VARBINARY(length=400), nullable=False),
|
||||
sa.Column("fail", sa.VARBINARY(length=400), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["beatmap_id"],
|
||||
["beatmaps.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("beatmap_id"),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table("failtime")
|
||||
# ### end Alembic commands ###
|
||||
Reference in New Issue
Block a user