feat(daily-challenge): simple implement
This commit is contained in:
@@ -25,7 +25,7 @@ from .playlist_best_score import PlaylistBestScore
|
||||
from .playlists import Playlist, PlaylistResp
|
||||
from .pp_best_score import PPBestScore
|
||||
from .relationship import Relationship, RelationshipResp, RelationshipType
|
||||
from .room import Room, RoomResp
|
||||
from .room import APIUploadedRoom, Room, RoomResp
|
||||
from .room_participated_user import RoomParticipatedUser
|
||||
from .score import (
|
||||
MultiplayerScores,
|
||||
@@ -48,6 +48,7 @@ from .user_account_history import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"APIUploadedRoom",
|
||||
"Beatmap",
|
||||
"Beatmapset",
|
||||
"BeatmapsetResp",
|
||||
|
||||
@@ -138,6 +138,6 @@ class PlaylistResp(PlaylistBase):
|
||||
) -> "PlaylistResp":
|
||||
data = playlist.model_dump()
|
||||
if "beatmap" in include:
|
||||
data["beatmap"] = await BeatmapResp.from_db(playlist.beatmap, from_set=True)
|
||||
data["beatmap"] = await BeatmapResp.from_db(playlist.beatmap)
|
||||
resp = cls.model_validate(data)
|
||||
return resp
|
||||
|
||||
@@ -160,3 +160,18 @@ class RoomResp(RoomBase):
|
||||
participant_count=len(room.users),
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
class APIUploadedRoom(RoomBase):
|
||||
def to_room(self) -> Room:
|
||||
"""
|
||||
将 APIUploadedRoom 转换为 Room 对象,playlist 字段需单独处理。
|
||||
"""
|
||||
room_dict = self.model_dump()
|
||||
room_dict.pop("playlist", None)
|
||||
# host_id 已在字段中
|
||||
return Room(**room_dict)
|
||||
|
||||
id: int | None
|
||||
host_id: int | None = None
|
||||
playlist: list[Playlist] = Field(default_factory=list)
|
||||
|
||||
26
app/dependencies/scheduler.py
Normal file
26
app/dependencies/scheduler.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
|
||||
scheduler: AsyncIOScheduler | None = None
|
||||
|
||||
|
||||
def init_scheduler():
|
||||
global scheduler
|
||||
scheduler = AsyncIOScheduler(timezone=UTC)
|
||||
scheduler.start()
|
||||
|
||||
|
||||
def get_scheduler() -> AsyncIOScheduler:
|
||||
global scheduler
|
||||
if scheduler is None:
|
||||
init_scheduler()
|
||||
return scheduler # pyright: ignore[reportReturnType]
|
||||
|
||||
|
||||
def stop_scheduler():
|
||||
global scheduler
|
||||
if scheduler:
|
||||
scheduler.shutdown()
|
||||
@@ -123,3 +123,7 @@ class OnlineStatus(IntEnum):
|
||||
OFFLINE = 0 # 隐身
|
||||
DO_NOT_DISTURB = 1
|
||||
ONLINE = 2
|
||||
|
||||
|
||||
class DailyChallengeInfo(BaseModel):
|
||||
room_id: int
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
from app.database.beatmap import Beatmap, BeatmapResp
|
||||
@@ -8,13 +8,14 @@ from app.database.beatmapset import BeatmapsetResp
|
||||
from app.database.lazer_user import User, UserResp
|
||||
from app.database.multiplayer_event import MultiplayerEvent, MultiplayerEventResp
|
||||
from app.database.playlist_attempts import ItemAttemptsCount, ItemAttemptsResp
|
||||
from app.database.playlists import Playlist, PlaylistBase, PlaylistResp
|
||||
from app.database.room import Room, RoomBase, RoomResp
|
||||
from app.database.playlists import Playlist, PlaylistResp
|
||||
from app.database.room import APIUploadedRoom, Room, RoomResp
|
||||
from app.database.room_participated_user import RoomParticipatedUser
|
||||
from app.database.score import Score
|
||||
from app.dependencies.database import get_db, get_redis
|
||||
from app.dependencies.user import get_current_user
|
||||
from app.models.room import RoomCategory, RoomStatus
|
||||
from app.service.room import create_playlist_room_from_api
|
||||
from app.signalr.hub import MultiplayerHubs
|
||||
|
||||
from .api_router import router
|
||||
@@ -92,21 +93,6 @@ class APICreatedRoom(RoomResp):
|
||||
error: str = ""
|
||||
|
||||
|
||||
class APIUploadedRoom(RoomBase):
|
||||
def to_room(self) -> Room:
|
||||
"""
|
||||
将 APIUploadedRoom 转换为 Room 对象,playlist 字段需单独处理。
|
||||
"""
|
||||
room_dict = self.model_dump()
|
||||
room_dict.pop("playlist", None)
|
||||
# host_id 已在字段中
|
||||
return Room(**room_dict)
|
||||
|
||||
id: int | None
|
||||
host_id: int | None = None
|
||||
playlist: list[PlaylistBase] = Field(default_factory=list)
|
||||
|
||||
|
||||
async def _participate_room(
|
||||
room_id: int, user_id: int, db_room: Room, session: AsyncSession
|
||||
):
|
||||
@@ -137,27 +123,11 @@ async def create_room(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
# db_room = Room.from_resp(room)
|
||||
await db.refresh(current_user)
|
||||
user_id = current_user.id
|
||||
db_room = room.to_room()
|
||||
db_room.host_id = current_user.id if current_user.id else 1
|
||||
db_room.starts_at = datetime.now(UTC)
|
||||
db_room.ends_at = db_room.starts_at + timedelta(
|
||||
minutes=db_room.duration if db_room.duration is not None else 0
|
||||
)
|
||||
db.add(db_room)
|
||||
await db.commit()
|
||||
await db.refresh(db_room)
|
||||
db_room = await create_playlist_room_from_api(db, room, user_id)
|
||||
await _participate_room(db_room.id, user_id, db_room, db)
|
||||
|
||||
for item in room.playlist:
|
||||
item.id = await Playlist.get_next_id_for_room(db_room.id, db)
|
||||
item.room_id = db_room.id
|
||||
item.owner_id = user_id if user_id else 1
|
||||
db.add(item)
|
||||
await db.commit()
|
||||
await db.refresh(db_room)
|
||||
# await db.commit()
|
||||
# await db.refresh(db_room)
|
||||
created_room = APICreatedRoom.model_validate(await RoomResp.from_db(db_room, db))
|
||||
created_room.error = ""
|
||||
return created_room
|
||||
|
||||
@@ -32,6 +32,7 @@ from app.dependencies.fetcher import get_fetcher
|
||||
from app.dependencies.user import get_current_user
|
||||
from app.fetcher import Fetcher
|
||||
from app.models.beatmap import BeatmapRankStatus
|
||||
from app.models.room import RoomCategory
|
||||
from app.models.score import (
|
||||
INT_TO_MODE,
|
||||
GameMode,
|
||||
@@ -402,6 +403,10 @@ async def index_playlist_scores(
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
room = await session.get(Room, room_id)
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
|
||||
limit = clamp(limit, 1, 50)
|
||||
|
||||
scores = (
|
||||
@@ -426,6 +431,12 @@ async def index_playlist_scores(
|
||||
score.position = await get_position(room_id, playlist_id, score.id, session)
|
||||
if score.user_id == current_user.id:
|
||||
user_score = score
|
||||
|
||||
if room.category == RoomCategory.DAILY_CHALLENGE:
|
||||
score_resp = [s for s in score_resp if s.passed]
|
||||
if user_score and not user_score.passed:
|
||||
user_score = None
|
||||
|
||||
resp = IndexedScoreResp(
|
||||
scores=score_resp,
|
||||
user_score=user_score,
|
||||
|
||||
10
app/service/__init__.py
Normal file
10
app/service/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .daily_challenge import create_daily_challenge_room
|
||||
from .room import create_playlist_room, create_playlist_room_from_api
|
||||
|
||||
__all__ = [
|
||||
"create_daily_challenge_room",
|
||||
"create_playlist_room",
|
||||
"create_playlist_room_from_api",
|
||||
]
|
||||
103
app/service/daily_challenge.py
Normal file
103
app/service/daily_challenge.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import json
|
||||
|
||||
from app.database.playlists import Playlist
|
||||
from app.database.room import Room
|
||||
from app.dependencies.database import engine, get_redis
|
||||
from app.dependencies.scheduler import get_scheduler
|
||||
from app.log import logger
|
||||
from app.models.metadata_hub import DailyChallengeInfo
|
||||
from app.models.mods import APIMod
|
||||
from app.models.room import RoomCategory
|
||||
from app.signalr.hub import MetadataHubs
|
||||
|
||||
from .room import create_playlist_room
|
||||
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
|
||||
async def create_daily_challenge_room(
|
||||
beatmap: int, ruleset_id: int, required_mods: list[APIMod] = []
|
||||
) -> Room:
|
||||
async with AsyncSession(engine) as session:
|
||||
today = datetime.now(UTC).date()
|
||||
return await create_playlist_room(
|
||||
session=session,
|
||||
name=str(today),
|
||||
host_id=3,
|
||||
playlist=[
|
||||
Playlist(
|
||||
id=0,
|
||||
room_id=0,
|
||||
owner_id=3,
|
||||
ruleset_id=ruleset_id,
|
||||
beatmap_id=beatmap,
|
||||
required_mods=required_mods,
|
||||
)
|
||||
],
|
||||
category=RoomCategory.DAILY_CHALLENGE,
|
||||
duration=24 * 60 - 2, # remain 2 minute to apply new daily challenge
|
||||
)
|
||||
|
||||
|
||||
@get_scheduler().scheduled_job("cron", hour=0, minute=0, second=0, id="daily_challenge")
|
||||
async def daily_challenge_job():
|
||||
today = datetime.now(UTC).date()
|
||||
redis = get_redis()
|
||||
key = f"daily_challenge:{today.year}-{today.month}-{today.day}"
|
||||
if not await redis.exists(key):
|
||||
return
|
||||
try:
|
||||
beatmap = await redis.hget(key, "beatmap") # pyright: ignore[reportGeneralTypeIssues]
|
||||
ruleset_id = await redis.hget(key, "ruleset_id") # pyright: ignore[reportGeneralTypeIssues]
|
||||
required_mods = await redis.hget(key, "required_mods") # pyright: ignore[reportGeneralTypeIssues]
|
||||
|
||||
if beatmap is None or ruleset_id is None:
|
||||
logger.warning(
|
||||
f"[DailyChallenge] Missing required data for daily challenge {today}."
|
||||
" Will try again in 5 minutes."
|
||||
)
|
||||
get_scheduler().add_job(
|
||||
daily_challenge_job,
|
||||
"date",
|
||||
run_date=datetime.now(UTC) + timedelta(minutes=5),
|
||||
)
|
||||
return
|
||||
|
||||
beatmap_int = int(beatmap)
|
||||
ruleset_id_int = int(ruleset_id)
|
||||
|
||||
mods_list = []
|
||||
if required_mods:
|
||||
mods_list = json.loads(required_mods)
|
||||
|
||||
room = await create_daily_challenge_room(
|
||||
beatmap=beatmap_int,
|
||||
ruleset_id=ruleset_id_int,
|
||||
required_mods=mods_list,
|
||||
)
|
||||
await MetadataHubs.broadcast_call(
|
||||
"DailyChallengeUpdated", DailyChallengeInfo(room_id=room.id)
|
||||
)
|
||||
logger.success(
|
||||
"[DailyChallenge] Added today's daily challenge: "
|
||||
f"{beatmap=}, {ruleset_id=}, {required_mods=}"
|
||||
)
|
||||
return
|
||||
except (ValueError, json.JSONDecodeError) as e:
|
||||
logger.warning(
|
||||
f"[DailyChallenge] Error processing daily challenge data: {e}"
|
||||
" Will try again in 5 minutes."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"[DailyChallenge] Unexpected error in daily challenge job: {e}"
|
||||
" Will try again in 5 minutes."
|
||||
)
|
||||
get_scheduler().add_job(
|
||||
daily_challenge_job,
|
||||
"date",
|
||||
run_date=datetime.now(UTC) + timedelta(minutes=5),
|
||||
)
|
||||
78
app/service/room.py
Normal file
78
app/service/room.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.database.beatmap import Beatmap
|
||||
from app.database.playlists import Playlist
|
||||
from app.database.room import APIUploadedRoom, Room
|
||||
from app.dependencies.fetcher import get_fetcher
|
||||
from app.models.room import MatchType, QueueMode, RoomCategory, RoomStatus
|
||||
|
||||
from sqlalchemy import exists
|
||||
from sqlmodel import col, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
|
||||
async def create_playlist_room_from_api(
|
||||
session: AsyncSession, room: APIUploadedRoom, host_id: int
|
||||
) -> Room:
|
||||
db_room = room.to_room()
|
||||
db_room.host_id = host_id
|
||||
db_room.starts_at = datetime.now(UTC)
|
||||
db_room.ends_at = db_room.starts_at + timedelta(
|
||||
minutes=db_room.duration if db_room.duration is not None else 0
|
||||
)
|
||||
session.add(db_room)
|
||||
await session.commit()
|
||||
await session.refresh(db_room)
|
||||
await add_playlists_to_room(session, db_room.id, room.playlist, host_id)
|
||||
await session.refresh(db_room)
|
||||
return db_room
|
||||
|
||||
|
||||
async def create_playlist_room(
|
||||
session: AsyncSession,
|
||||
name: str,
|
||||
host_id: int,
|
||||
category: RoomCategory = RoomCategory.NORMAL,
|
||||
duration: int = 30,
|
||||
max_attempts: int | None = None,
|
||||
playlist: list[Playlist] = [],
|
||||
) -> Room:
|
||||
db_room = Room(
|
||||
name=name,
|
||||
category=category,
|
||||
duration=duration,
|
||||
starts_at=datetime.now(UTC),
|
||||
ends_at=datetime.now(UTC) + timedelta(minutes=duration),
|
||||
participant_count=0,
|
||||
max_attempts=max_attempts,
|
||||
type=MatchType.PLAYLISTS,
|
||||
queue_mode=QueueMode.HOST_ONLY,
|
||||
auto_skip=False,
|
||||
auto_start_duration=0,
|
||||
status=RoomStatus.IDLE,
|
||||
host_id=host_id,
|
||||
)
|
||||
session.add(db_room)
|
||||
await session.commit()
|
||||
await session.refresh(db_room)
|
||||
await add_playlists_to_room(session, db_room.id, playlist, host_id)
|
||||
await session.refresh(db_room)
|
||||
return db_room
|
||||
|
||||
|
||||
async def add_playlists_to_room(
|
||||
session: AsyncSession, room_id: int, playlist: list[Playlist], owner_id: int
|
||||
):
|
||||
for item in playlist:
|
||||
if not (
|
||||
await session.exec(select(exists().where(col(Beatmap.id) == item.beatmap)))
|
||||
).first():
|
||||
fetcher = await get_fetcher()
|
||||
await Beatmap.get_or_fetch(session, fetcher, item.beatmap_id)
|
||||
item.id = await Playlist.get_next_id_for_room(room_id, session)
|
||||
item.room_id = room_id
|
||||
item.owner_id = owner_id
|
||||
session.add(item)
|
||||
await session.commit()
|
||||
@@ -6,12 +6,19 @@ from datetime import UTC, datetime
|
||||
from typing import override
|
||||
|
||||
from app.database import Relationship, RelationshipType, User
|
||||
from app.database.room import Room
|
||||
from app.dependencies.database import engine, get_redis
|
||||
from app.models.metadata_hub import MetadataClientState, OnlineStatus, UserActivity
|
||||
from app.models.metadata_hub import (
|
||||
DailyChallengeInfo,
|
||||
MetadataClientState,
|
||||
OnlineStatus,
|
||||
UserActivity,
|
||||
)
|
||||
from app.models.room import RoomCategory
|
||||
|
||||
from .hub import Client, Hub
|
||||
|
||||
from sqlmodel import select
|
||||
from sqlmodel import col, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
ONLINE_PRESENCE_WATCHERS_GROUP = "metadata:online-presence-watchers"
|
||||
@@ -107,6 +114,23 @@ class MetadataHub(Hub[MetadataClientState]):
|
||||
)
|
||||
)
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
daily_challenge_room = (
|
||||
await session.exec(
|
||||
select(Room).where(
|
||||
col(Room.ends_at) > datetime.now(UTC),
|
||||
Room.category == RoomCategory.DAILY_CHALLENGE,
|
||||
)
|
||||
)
|
||||
).first()
|
||||
if daily_challenge_room:
|
||||
await self.call_noblock(
|
||||
client,
|
||||
"DailyChallengeUpdated",
|
||||
DailyChallengeInfo(
|
||||
room_id=daily_challenge_room.id,
|
||||
),
|
||||
)
|
||||
redis = get_redis()
|
||||
await redis.set(f"metadata:online:{user_id}", "")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user