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.calculator import calculate_beatmap_attribute
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.database.failtime import FailTime, FailTimeResp
|
||||||
from app.models.beatmap import BeatmapAttributes, BeatmapRankStatus
|
from app.models.beatmap import BeatmapAttributes, BeatmapRankStatus
|
||||||
from app.models.mods import APIMod
|
from app.models.mods import APIMod
|
||||||
from app.models.score import GameMode
|
from app.models.score import GameMode
|
||||||
@@ -67,6 +68,9 @@ class Beatmap(BeatmapBase, table=True):
|
|||||||
beatmapset: Beatmapset = Relationship(
|
beatmapset: Beatmapset = Relationship(
|
||||||
back_populates="beatmaps", sa_relationship_kwargs={"lazy": "joined"}
|
back_populates="beatmaps", sa_relationship_kwargs={"lazy": "joined"}
|
||||||
)
|
)
|
||||||
|
failtimes: FailTime | None = Relationship(
|
||||||
|
back_populates="beatmap", sa_relationship_kwargs={"lazy": "joined"}
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def from_resp(cls, session: AsyncSession, resp: "BeatmapResp") -> "Beatmap":
|
async def from_resp(cls, session: AsyncSession, resp: "BeatmapResp") -> "Beatmap":
|
||||||
@@ -156,6 +160,7 @@ class BeatmapResp(BeatmapBase):
|
|||||||
url: str = ""
|
url: str = ""
|
||||||
playcount: int = 0
|
playcount: int = 0
|
||||||
passcount: int = 0
|
passcount: int = 0
|
||||||
|
failtimes: FailTimeResp | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def from_db(
|
async def from_db(
|
||||||
@@ -187,6 +192,10 @@ class BeatmapResp(BeatmapBase):
|
|||||||
beatmap_["beatmapset"] = await BeatmapsetResp.from_db(
|
beatmap_["beatmapset"] = await BeatmapsetResp.from_db(
|
||||||
beatmap.beatmapset, session=session, user=user
|
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:
|
if session:
|
||||||
beatmap_["playcount"] = (
|
beatmap_["playcount"] = (
|
||||||
await session.exec(
|
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.config import settings
|
||||||
from app.database.events import Event, EventType
|
from app.database.events import Event, EventType
|
||||||
from app.database.team import TeamMember
|
from app.database.team import TeamMember
|
||||||
|
from app.dependencies.database import get_redis
|
||||||
from app.models.model import (
|
from app.models.model import (
|
||||||
CurrentUserAttributes,
|
CurrentUserAttributes,
|
||||||
PinAttributes,
|
PinAttributes,
|
||||||
RespWithCursor,
|
RespWithCursor,
|
||||||
UTCBaseModel,
|
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 (
|
from app.models.score import (
|
||||||
GameMode,
|
GameMode,
|
||||||
HitResult,
|
HitResult,
|
||||||
@@ -393,7 +394,7 @@ async def get_score_position_by_user(
|
|||||||
subq = select(BestScore, rownum).join(Beatmap).where(*wheres).subquery()
|
subq = select(BestScore, rownum).join(Beatmap).where(*wheres).subquery()
|
||||||
stmt = select(subq.c.row_number).where(subq.c.user_id == user.id)
|
stmt = select(subq.c.row_number).where(subq.c.user_id == user.id)
|
||||||
result = await session.exec(stmt)
|
result = await session.exec(stmt)
|
||||||
s = result.one_or_none()
|
s = result.first()
|
||||||
return s if s else 0
|
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.gamemode == mode if mode is not None else True,
|
||||||
BestScore.beatmap_id == beatmap,
|
BestScore.beatmap_id == beatmap,
|
||||||
BestScore.user_id == user,
|
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())
|
.order_by(col(BestScore.total_score).desc())
|
||||||
)
|
)
|
||||||
@@ -497,11 +501,46 @@ async def get_user_best_pp(
|
|||||||
).all()
|
).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(
|
async def process_user(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
user: User,
|
user: User,
|
||||||
score: Score,
|
score: Score,
|
||||||
length: int,
|
score_token: int,
|
||||||
|
beatmap_length: int,
|
||||||
ranked: bool = False,
|
ranked: bool = False,
|
||||||
has_leaderboard: bool = False,
|
has_leaderboard: bool = False,
|
||||||
):
|
):
|
||||||
@@ -644,7 +683,7 @@ async def process_user(
|
|||||||
mods=mod_for_save,
|
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.total_score = score.total_score
|
||||||
previous_score_best.rank = score.rank
|
previous_score_best.rank = score.rank
|
||||||
previous_score_best.mods = mod_for_save
|
previous_score_best.mods = mod_for_save
|
||||||
@@ -652,7 +691,11 @@ async def process_user(
|
|||||||
|
|
||||||
statistics.play_count += 1
|
statistics.play_count += 1
|
||||||
mouthly_playcount.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_100 += score.n100 + score.nkatu
|
||||||
statistics.count_300 += score.n300 + score.ngeki
|
statistics.count_300 += score.n300 + score.ngeki
|
||||||
statistics.count_50 += score.n50
|
statistics.count_50 += score.n50
|
||||||
@@ -662,18 +705,19 @@ async def process_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if score.passed and ranked:
|
if score.passed and ranked:
|
||||||
best_pp_scores = await get_user_best_pp(session, user.id, score.gamemode)
|
with session.no_autoflush:
|
||||||
pp_sum = 0.0
|
best_pp_scores = await get_user_best_pp(session, user.id, score.gamemode)
|
||||||
acc_sum = 0.0
|
pp_sum = 0.0
|
||||||
for i, bp in enumerate(best_pp_scores):
|
acc_sum = 0.0
|
||||||
pp_sum += calculate_weighted_pp(bp.pp, i)
|
for i, bp in enumerate(best_pp_scores):
|
||||||
acc_sum += calculate_weighted_acc(bp.acc, i)
|
pp_sum += calculate_weighted_pp(bp.pp, i)
|
||||||
if len(best_pp_scores):
|
acc_sum += calculate_weighted_acc(bp.acc, i)
|
||||||
# https://github.com/ppy/osu-queue-score-statistics/blob/c538ae/osu.Server.Queues.ScoreStatisticsProcessor/Helpers/UserTotalPerformanceAggregateHelper.cs#L41-L45
|
if len(best_pp_scores):
|
||||||
acc_sum *= 100 / (20 * (1 - math.pow(0.95, 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 = clamp(acc_sum, 0.0, 100.0)
|
acc_sum *= 100 / (20 * (1 - math.pow(0.95, len(best_pp_scores))))
|
||||||
statistics.pp = pp_sum
|
acc_sum = clamp(acc_sum, 0.0, 100.0)
|
||||||
statistics.hit_accuracy = acc_sum
|
statistics.pp = pp_sum
|
||||||
|
statistics.hit_accuracy = acc_sum
|
||||||
if add_to_db:
|
if add_to_db:
|
||||||
session.add(mouthly_playcount)
|
session.add(mouthly_playcount)
|
||||||
await process_beatmap_playcount(session, user.id, score.beatmap_id)
|
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 = list({mod["acronym"] for mod in mods})
|
||||||
s.sort()
|
s.sort()
|
||||||
return s
|
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,
|
HitResult.MISS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def is_scorable(self) -> bool:
|
||||||
|
return self not in (
|
||||||
|
HitResult.NONE,
|
||||||
|
HitResult.IGNORE_HIT,
|
||||||
|
HitResult.IGNORE_MISS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LeaderboardType(Enum):
|
class LeaderboardType(Enum):
|
||||||
GLOBAL = "global"
|
GLOBAL = "global"
|
||||||
|
|||||||
@@ -121,7 +121,13 @@ async def submit_score(
|
|||||||
score_id = score.id
|
score_id = score.id
|
||||||
score_token.score_id = score_id
|
score_token.score_id = score_id
|
||||||
await process_user(
|
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()
|
score = (await db.exec(select(Score).where(Score.id == score_id))).first()
|
||||||
assert score is not None
|
assert score is not None
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from app.config import settings
|
|||||||
from app.database import BestScore, UserStatistics
|
from app.database import BestScore, UserStatistics
|
||||||
from app.database.beatmap import Beatmap
|
from app.database.beatmap import Beatmap
|
||||||
from app.database.pp_best_score import PPBestScore
|
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.database import engine, get_redis
|
||||||
from app.dependencies.fetcher import get_fetcher
|
from app.dependencies.fetcher import get_fetcher
|
||||||
from app.fetcher import Fetcher
|
from app.fetcher import Fetcher
|
||||||
@@ -245,7 +245,9 @@ async def _recalculate_statistics(statistics: UserStatistics, session: AsyncSess
|
|||||||
|
|
||||||
statistics.play_count += 1
|
statistics.play_count += 1
|
||||||
statistics.total_score += score.total_score
|
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_300 += score.n300 + score.ngeki
|
||||||
statistics.count_100 += score.n100 + score.nkatu
|
statistics.count_100 += score.n100 + score.nkatu
|
||||||
statistics.count_50 += score.n50
|
statistics.count_50 += score.n50
|
||||||
|
|||||||
@@ -1114,6 +1114,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
|
|||||||
if settings.match_type == MatchType.PLAYLISTS:
|
if settings.match_type == MatchType.PLAYLISTS:
|
||||||
raise InvokeException("Invalid match type selected")
|
raise InvokeException("Invalid match type selected")
|
||||||
|
|
||||||
|
settings.playlist_item_id = room.settings.playlist_item_id
|
||||||
previous_settings = room.settings
|
previous_settings = room.settings
|
||||||
room.settings = settings
|
room.settings = settings
|
||||||
|
|
||||||
|
|||||||
@@ -8,16 +8,19 @@ import time
|
|||||||
from typing import override
|
from typing import override
|
||||||
from venv import logger
|
from venv import logger
|
||||||
|
|
||||||
|
from app.calculator import clamp
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import Beatmap, User
|
from app.database import Beatmap, User
|
||||||
|
from app.database.failtime import FailTime, FailTimeResp
|
||||||
from app.database.score import Score
|
from app.database.score import Score
|
||||||
from app.database.score_token import ScoreToken
|
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.fetcher import get_fetcher
|
||||||
from app.dependencies.storage import get_storage_service
|
from app.dependencies.storage import get_storage_service
|
||||||
from app.exception import InvokeException
|
from app.exception import InvokeException
|
||||||
from app.models.mods import mods_to_int
|
from app.models.mods import APIMod, mods_to_int
|
||||||
from app.models.score import LegacyReplaySoloScoreInfo, ScoreStatistics
|
from app.models.score import GameMode, LegacyReplaySoloScoreInfo, ScoreStatistics
|
||||||
from app.models.spectator_hub import (
|
from app.models.spectator_hub import (
|
||||||
APIUser,
|
APIUser,
|
||||||
FrameDataBundle,
|
FrameDataBundle,
|
||||||
@@ -164,7 +167,7 @@ class SpectatorHub(Hub[StoreClientState]):
|
|||||||
@override
|
@override
|
||||||
async def _clean_state(self, state: StoreClientState) -> None:
|
async def _clean_state(self, state: StoreClientState) -> None:
|
||||||
if state.state:
|
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:
|
for target in self.waited_clients:
|
||||||
target_client = self.get_client_by_id(target)
|
target_client = self.get_client_by_id(target)
|
||||||
if target_client:
|
if target_client:
|
||||||
@@ -254,20 +257,22 @@ class SpectatorHub(Hub[StoreClientState]):
|
|||||||
or store.score_token is None
|
or store.score_token is None
|
||||||
or store.beatmap_status is None
|
or store.beatmap_status is None
|
||||||
or store.state is None
|
or store.state is None
|
||||||
|
or store.score is None
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
if (
|
if (
|
||||||
settings.enable_all_beatmap_leaderboard
|
settings.enable_all_beatmap_leaderboard
|
||||||
and store.beatmap_status.has_leaderboard()
|
and store.beatmap_status.has_leaderboard()
|
||||||
) and any(k.is_hit() and v > 0 for k, v in score.score_info.statistics.items()):
|
) 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._process_score(store, client)
|
||||||
|
await self._end_session(user_id, state, store)
|
||||||
store.state = None
|
store.state = None
|
||||||
store.beatmap_status = None
|
store.beatmap_status = None
|
||||||
store.checksum = None
|
store.checksum = None
|
||||||
store.ruleset_id = None
|
store.ruleset_id = None
|
||||||
store.score_token = None
|
store.score_token = None
|
||||||
store.score = None
|
store.score = None
|
||||||
await self._end_session(user_id, state)
|
|
||||||
|
|
||||||
async def _process_score(self, store: StoreClientState, client: Client) -> None:
|
async def _process_score(self, store: StoreClientState, client: Client) -> None:
|
||||||
user_id = int(client.connection_id)
|
user_id = int(client.connection_id)
|
||||||
@@ -319,9 +324,80 @@ class SpectatorHub(Hub[StoreClientState]):
|
|||||||
frames=store.score.replay_frames,
|
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:
|
if state.state == SpectatedUserState.Playing:
|
||||||
state.state = SpectatedUserState.Quit
|
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(
|
logger.info(
|
||||||
f"[SpectatorHub] {user_id} finished playing {state.beatmap_id} "
|
f"[SpectatorHub] {user_id} finished playing {state.beatmap_id} "
|
||||||
f"with {state.state}"
|
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