feat(achievement): support obtain achievements

This commit is contained in:
MingxuanGame
2025-08-21 08:50:16 +00:00
parent 9fb0d0c198
commit 068697355f
15 changed files with 864 additions and 30 deletions

View File

@@ -0,0 +1,61 @@
from __future__ import annotations
from functools import partial
from app.database.score import Beatmap, Score
from app.models.achievement import Achievement, Medals
from app.models.score import GameMode
from sqlmodel.ext.asyncio.session import AsyncSession
async def process_combo(
combo: int,
next_combo: int,
session: AsyncSession,
score: Score,
beatmap: Beatmap,
) -> bool:
if (
not score.passed
or not beatmap.beatmap_status.has_pp()
or score.gamemode != GameMode.OSU
):
return False
if combo < 1:
return False
if next_combo != 0 and combo >= next_combo:
return False
if combo <= score.max_combo < next_combo:
return True
elif next_combo == 0 and score.max_combo >= combo:
return True
return False
MEDALS: Medals = {
Achievement(
id=21,
name="500 Combo",
desc="500 big ones! You''re moving up in the world!",
assets_id="osu-combo-500",
): partial(process_combo, 500, 750),
Achievement(
id=22,
name="750 Combo",
desc="750 notes back to back? Woah.",
assets_id="osu-combo-750",
): partial(process_combo, 750, 1000),
Achievement(
id=23,
name="1000 Combo",
desc="A thousand reasons why you rock at this game.",
assets_id="osu-combo-1000",
): partial(process_combo, 1000, 2000),
Achievement(
id=24,
name="2000 Combo",
desc="Nothing can stop you now.",
assets_id="osu-combo-2000",
): partial(process_combo, 2000, 0),
}

466
app/achievements/skill.py Normal file
View File

@@ -0,0 +1,466 @@
from __future__ import annotations
from functools import partial
from typing import Literal, cast
from app.database.beatmap import calculate_beatmap_attributes
from app.database.score import Beatmap, Score
from app.dependencies.database import get_redis
from app.dependencies.fetcher import get_fetcher
from app.models.achievement import Achievement, Medals
from app.models.mods import API_MODS, mods_can_get_pp_vanilla
from app.models.score import GameMode
from sqlmodel.ext.asyncio.session import AsyncSession
async def process_skill(
target_gamemode: GameMode,
star: int,
type: Literal["pass", "fc"],
session: AsyncSession,
score: Score,
beatmap: Beatmap,
) -> bool:
if target_gamemode != score.gamemode:
return False
ruleset_id = int(score.gamemode)
if not score.passed:
return False
if not beatmap.beatmap_status.has_pp():
return False
if not mods_can_get_pp_vanilla(ruleset_id, score.mods):
return False
difficulty_reduction_mods = [
mod["Acronym"]
for mod in API_MODS[cast(Literal[0, 1, 2, 3], ruleset_id)].values()
if mod["Type"] == "DifficultyReduction"
]
for mod in score.mods:
if mod["acronym"] in difficulty_reduction_mods:
return False
fetcher = await get_fetcher()
redis = get_redis()
mods_ = score.mods.copy()
mods_.sort(key=lambda x: x["acronym"])
attribute = await calculate_beatmap_attributes(
beatmap.id, score.gamemode, mods_, redis, fetcher
)
if attribute.star_rating < star or attribute.star_rating >= star + 1:
return False
if type == "fc" and not score.is_perfect_combo:
return False
return True
MEDALS: Medals = {
Achievement(
id=1,
name="Rising Star",
desc="Can't go forward without the first steps.",
assets_id="osu-skill-pass-1",
): partial(process_skill, GameMode.OSU, 1, "pass"),
Achievement(
id=2,
name="Constellation Prize",
desc="Definitely not a consolation prize. Now things start getting hard!",
assets_id="osu-skill-pass-2",
): partial(process_skill, GameMode.OSU, 2, "pass"),
Achievement(
id=3,
name="Building Confidence",
desc="Oh, you've SO got this.",
assets_id="osu-skill-pass-3",
): partial(process_skill, GameMode.OSU, 3, "pass"),
Achievement(
id=4,
name="Insanity Approaches",
desc="You're not twitching, you're just ready.",
assets_id="osu-skill-pass-4",
): partial(process_skill, GameMode.OSU, 4, "pass"),
Achievement(
id=5,
name="These Clarion Skies",
desc="Everything seems so clear now.",
assets_id="osu-skill-pass-5",
): partial(process_skill, GameMode.OSU, 5, "pass"),
Achievement(
id=6,
name="Above and Beyond",
desc="A cut above the rest.",
assets_id="osu-skill-pass-6",
): partial(process_skill, GameMode.OSU, 6, "pass"),
Achievement(
id=7,
name="Supremacy",
desc="All marvel before your prowess.",
assets_id="osu-skill-pass-7",
): partial(process_skill, GameMode.OSU, 7, "pass"),
Achievement(
id=8,
name="Absolution",
desc="My god, you're full of stars!",
assets_id="osu-skill-pass-8",
): partial(process_skill, GameMode.OSU, 8, "pass"),
Achievement(
id=9,
name="Event Horizon",
desc="No force dares to pull you under.",
assets_id="osu-skill-pass-9",
): partial(process_skill, GameMode.OSU, 9, "pass"),
Achievement(
id=10,
name="Phantasm",
desc="Fevered is your passion, extraordinary is your skill.",
assets_id="osu-skill-pass-10",
): partial(process_skill, GameMode.OSU, 10, "pass"),
Achievement(
id=11,
name="Totality",
desc="All the notes. Every single one.",
assets_id="osu-skill-fc-1",
): partial(process_skill, GameMode.OSU, 1, "fc"),
Achievement(
id=12,
name="Business As Usual",
desc="Two to go, please.",
assets_id="osu-skill-fc-2",
): partial(process_skill, GameMode.OSU, 2, "fc"),
Achievement(
id=13,
name="Building Steam",
desc="Hey, this isn't so bad.",
assets_id="osu-skill-fc-3",
): partial(process_skill, GameMode.OSU, 3, "fc"),
Achievement(
id=14,
name="Moving Forward",
desc="Bet you feel good about that.",
assets_id="osu-skill-fc-4",
): partial(process_skill, GameMode.OSU, 4, "fc"),
Achievement(
id=15,
name="Paradigm Shift",
desc="Surprisingly difficult.",
assets_id="osu-skill-fc-5",
): partial(process_skill, GameMode.OSU, 5, "fc"),
Achievement(
id=16,
name="Anguish Quelled",
desc="Don't choke.",
assets_id="osu-skill-fc-6",
): partial(process_skill, GameMode.OSU, 6, "fc"),
Achievement(
id=17,
name="Never Give Up",
desc="Excellence is its own reward.",
assets_id="osu-skill-fc-7",
): partial(process_skill, GameMode.OSU, 7, "fc"),
Achievement(
id=18,
name="Aberration",
desc="They said it couldn't be done. They were wrong.",
assets_id="osu-skill-fc-8",
): partial(process_skill, GameMode.OSU, 8, "fc"),
Achievement(
id=19,
name="Chosen",
desc="Reign among the Prometheans, where you belong.",
assets_id="osu-skill-fc-9",
): partial(process_skill, GameMode.OSU, 9, "fc"),
Achievement(
id=20,
name="Unfathomable",
desc="You have no equal.",
assets_id="osu-skill-fc-10",
): partial(process_skill, GameMode.OSU, 10, "fc"),
Achievement(
id=25,
name="My First Don",
desc="Marching to the beat of your own drum. Literally.",
assets_id="taiko-skill-pass-1",
): partial(process_skill, GameMode.TAIKO, 1, "pass"),
Achievement(
id=26,
name="Katsu Katsu Katsu",
desc="Hora! Izuko!",
assets_id="taiko-skill-pass-2",
): partial(process_skill, GameMode.TAIKO, 2, "pass"),
Achievement(
id=27,
name="Not Even Trying",
desc="Muzukashii? Not even.",
assets_id="taiko-skill-pass-3",
): partial(process_skill, GameMode.TAIKO, 3, "pass"),
Achievement(
id=28,
name="Face Your Demons",
desc="The first trials are now behind you, but are you a match for the Oni?",
assets_id="taiko-skill-pass-4",
): partial(process_skill, GameMode.TAIKO, 4, "pass"),
Achievement(
id=29,
name="The Demon Within",
desc="No rest for the wicked.",
assets_id="taiko-skill-pass-5",
): partial(process_skill, GameMode.TAIKO, 5, "pass"),
Achievement(
id=30,
name="Drumbreaker",
desc="Too strong.",
assets_id="taiko-skill-pass-6",
): partial(process_skill, GameMode.TAIKO, 6, "pass"),
Achievement(
id=31,
name="The Godfather",
desc="You are the Don of Dons.",
assets_id="taiko-skill-pass-7",
): partial(process_skill, GameMode.TAIKO, 7, "pass"),
Achievement(
id=32,
name="Rhythm Incarnate",
desc="Feel the beat. Become the beat.",
assets_id="taiko-skill-pass-8",
): partial(process_skill, GameMode.TAIKO, 8, "pass"),
Achievement(
id=33,
name="Keeping Time",
desc="Don, then katsu. Don, then katsu..",
assets_id="taiko-skill-fc-1",
): partial(process_skill, GameMode.TAIKO, 1, "fc"),
Achievement(
id=34,
name="To Your Own Beat",
desc="Straight and steady.",
assets_id="taiko-skill-fc-2",
): partial(process_skill, GameMode.TAIKO, 2, "fc"),
Achievement(
id=35,
name="Big Drums",
desc="Bigger scores to match.",
assets_id="taiko-skill-fc-3",
): partial(process_skill, GameMode.TAIKO, 3, "fc"),
Achievement(
id=36,
name="Adversity Overcome",
desc="Difficult? Not for you.",
assets_id="taiko-skill-fc-4",
): partial(process_skill, GameMode.TAIKO, 4, "fc"),
Achievement(
id=37,
name="Demonslayer",
desc="An Oni felled forevermore.",
assets_id="taiko-skill-fc-5",
): partial(process_skill, GameMode.TAIKO, 5, "fc"),
Achievement(
id=38,
name="Rhythm's Call",
desc="Heralding true skill.",
assets_id="taiko-skill-fc-6",
): partial(process_skill, GameMode.TAIKO, 6, "fc"),
Achievement(
id=39,
name="Time Everlasting",
desc="Not a single beat escapes you.",
assets_id="taiko-skill-fc-7",
): partial(process_skill, GameMode.TAIKO, 7, "fc"),
Achievement(
id=40,
name="The Drummer's Throne",
desc="Percussive brilliance befitting royalty alone.",
assets_id="taiko-skill-fc-8",
): partial(process_skill, GameMode.TAIKO, 8, "fc"),
Achievement(
id=41,
name="A Slice Of Life",
desc="Hey, this fruit catching business isn't bad.",
assets_id="fruits-skill-pass-1",
): partial(process_skill, GameMode.FRUITS, 1, "pass"),
Achievement(
id=42,
name="Dashing Ever Forward",
desc="Fast is how you do it.",
assets_id="fruits-skill-pass-2",
): partial(process_skill, GameMode.FRUITS, 2, "pass"),
Achievement(
id=43,
name="Zesty Disposition",
desc="No scurvy for you, not with that much fruit.",
assets_id="fruits-skill-pass-3",
): partial(process_skill, GameMode.FRUITS, 3, "pass"),
Achievement(
id=44,
name="Hyperdash ON!",
desc="Time and distance is no obstacle to you.",
assets_id="fruits-skill-pass-4",
): partial(process_skill, GameMode.FRUITS, 4, "pass"),
Achievement(
id=45,
name="It's Raining Fruit",
desc="And you can catch them all.",
assets_id="fruits-skill-pass-5",
): partial(process_skill, GameMode.FRUITS, 5, "pass"),
Achievement(
id=46,
name="Fruit Ninja",
desc="Legendary techniques.",
assets_id="fruits-skill-pass-6",
): partial(process_skill, GameMode.FRUITS, 6, "pass"),
Achievement(
id=47,
name="Dreamcatcher",
desc="No fruit, only dreams now.",
assets_id="fruits-skill-pass-7",
): partial(process_skill, GameMode.FRUITS, 7, "pass"),
Achievement(
id=48,
name="Lord of the Catch",
desc="Your kingdom kneels before you.",
assets_id="fruits-skill-pass-8",
): partial(process_skill, GameMode.FRUITS, 8, "pass"),
Achievement(
id=49,
name="Sweet And Sour",
desc="Apples and oranges, literally.",
assets_id="fruits-skill-fc-1",
): partial(process_skill, GameMode.FRUITS, 1, "fc"),
Achievement(
id=50,
name="Reaching The Core",
desc="The seeds of future success.",
assets_id="fruits-skill-fc-2",
): partial(process_skill, GameMode.FRUITS, 2, "fc"),
Achievement(
id=51,
name="Clean Platter",
desc="Clean only of failure. It is completely full, otherwise.",
assets_id="fruits-skill-fc-3",
): partial(process_skill, GameMode.FRUITS, 3, "fc"),
Achievement(
id=52,
name="Between The Rain",
desc="No umbrella needed.",
assets_id="fruits-skill-fc-4",
): partial(process_skill, GameMode.FRUITS, 4, "fc"),
Achievement(
id=53,
name="Addicted",
desc="That was an overdose?",
assets_id="fruits-skill-fc-5",
): partial(process_skill, GameMode.FRUITS, 5, "fc"),
Achievement(
id=54,
name="Quickening",
desc="A dash above normal limits.",
assets_id="fruits-skill-fc-6",
): partial(process_skill, GameMode.FRUITS, 6, "fc"),
Achievement(
id=55,
name="Supersonic",
desc="Faster than is reasonably necessary.",
assets_id="fruits-skill-fc-7",
): partial(process_skill, GameMode.FRUITS, 7, "fc"),
Achievement(
id=56,
name="Dashing Scarlet",
desc="Speed beyond mortal reckoning.",
assets_id="fruits-skill-fc-8",
): partial(process_skill, GameMode.FRUITS, 8, "fc"),
Achievement(
id=57,
name="First Steps",
desc="It isn't 9-to-5, but 1-to-9. Keys, that is.",
assets_id="mania-skill-pass-1",
): partial(process_skill, GameMode.MANIA, 1, "pass"),
Achievement(
id=58,
name="No Normal Player",
desc="Not anymore, at least.",
assets_id="mania-skill-pass-2",
): partial(process_skill, GameMode.MANIA, 2, "pass"),
Achievement(
id=59,
name="Impulse Drive",
desc="Not quite hyperspeed, but getting close.",
assets_id="mania-skill-pass-3",
): partial(process_skill, GameMode.MANIA, 3, "pass"),
Achievement(
id=60,
name="Hyperspeed",
desc="Woah.",
assets_id="mania-skill-pass-4",
): partial(process_skill, GameMode.MANIA, 4, "pass"),
Achievement(
id=61,
name="Ever Onwards",
desc="Another challenge is just around the corner.",
assets_id="mania-skill-pass-5",
): partial(process_skill, GameMode.MANIA, 5, "pass"),
Achievement(
id=62,
name="Another Surpassed",
desc="Is there no limit to your skills?",
assets_id="mania-skill-pass-6",
): partial(process_skill, GameMode.MANIA, 6, "pass"),
Achievement(
id=63,
name="Extra Credit",
desc="See me after class.",
assets_id="mania-skill-pass-7",
): partial(process_skill, GameMode.MANIA, 7, "pass"),
Achievement(
id=64,
name="Maniac",
desc="There's just no stopping you.",
assets_id="mania-skill-pass-8",
): partial(process_skill, GameMode.MANIA, 8, "pass"),
Achievement(
id=65,
name="Keystruck",
desc="The beginning of a new story",
assets_id="mania-skill-fc-1",
): partial(process_skill, GameMode.MANIA, 1, "fc"),
Achievement(
id=66,
name="Keying In",
desc="Finding your groove.",
assets_id="mania-skill-fc-2",
): partial(process_skill, GameMode.MANIA, 2, "fc"),
Achievement(
id=67,
name="Hyperflow",
desc="You can *feel* the rhythm.",
assets_id="mania-skill-fc-3",
): partial(process_skill, GameMode.MANIA, 3, "fc"),
Achievement(
id=68,
name="Breakthrough",
desc="Many skills mastered, rolled into one.",
assets_id="mania-skill-fc-4",
): partial(process_skill, GameMode.MANIA, 4, "fc"),
Achievement(
id=69,
name="Everything Extra",
desc="Giving your all is giving everything you have.",
assets_id="mania-skill-fc-5",
): partial(process_skill, GameMode.MANIA, 5, "fc"),
Achievement(
id=70,
name="Level Breaker",
desc="Finesse beyond reason",
assets_id="mania-skill-fc-6",
): partial(process_skill, GameMode.MANIA, 6, "fc"),
Achievement(
id=71,
name="Step Up",
desc="A precipice rarely seen.",
assets_id="mania-skill-fc-7",
): partial(process_skill, GameMode.MANIA, 7, "fc"),
Achievement(
id=72,
name="Behind The Veil",
desc="Supernatural!",
assets_id="mania-skill-fc-8",
): partial(process_skill, GameMode.MANIA, 8, "fc"),
}

View File

@@ -1,8 +1,15 @@
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from app.config import settings
from app.models.achievement import MEDALS, Achievement
from app.models.model import UTCBaseModel
from app.models.notification import UserAchievementUnlock
from .events import Event, EventType
from redis.asyncio import Redis
from sqlalchemy.orm import joinedload
from sqlmodel import (
BigInteger,
Column,
@@ -11,14 +18,16 @@ from sqlmodel import (
ForeignKey,
Relationship,
SQLModel,
select,
)
from sqlmodel.ext.asyncio.session import AsyncSession
if TYPE_CHECKING:
from .lazer_user import User
class UserAchievementBase(SQLModel, UTCBaseModel):
achievement_id: int = Field(primary_key=True)
achievement_id: int
achieved_at: datetime = Field(
default=datetime.now(UTC), sa_column=Column(DateTime(timezone=True))
)
@@ -38,3 +47,54 @@ class UserAchievementResp(UserAchievementBase):
@classmethod
def from_db(cls, db_model: UserAchievement) -> "UserAchievementResp":
return cls.model_validate(db_model)
async def process_achievements(session: AsyncSession, redis: Redis, score_id: int):
from .score import Score
score = await session.get(Score, score_id, options=[joinedload(Score.beatmap)])
if not score:
return
achieved = (
await session.exec(
select(UserAchievement.achievement_id).where(
UserAchievement.user_id == score.user_id
)
)
).all()
not_achieved = {k: v for k, v in MEDALS.items() if k.id not in achieved}
result: list[Achievement] = []
now = datetime.now(UTC)
for k, v in not_achieved.items():
if await v(session, score, score.beatmap):
result.append(k)
for r in result:
session.add(
UserAchievement(
achievement_id=r.id,
user_id=score.user_id,
achieved_at=now,
)
)
await redis.publish(
"chat:notification",
UserAchievementUnlock.init(
r, score.user_id, score.gamemode
).model_dump_json(),
)
event = Event(
created_at=now,
type=EventType.ACHIEVEMENT,
user_id=score.user_id,
event_payload={
"achievement": UserAchievementResp(
achievement_id=r.id, achieved_at=now
).model_dump(),
"user": {
"username": score.user.username,
"url": settings.web_url + "users/" + str(score.user.id),
},
},
)
session.add(event)
await session.commit()

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from collections.abc import AsyncIterator, Callable
from contextvars import ContextVar
from datetime import datetime
import json
from typing import Annotated
@@ -18,6 +19,8 @@ from sqlmodel.ext.asyncio.session import AsyncSession
def json_serializer(value):
if isinstance(value, BaseModel | SQLModel):
return value.model_dump_json()
elif isinstance(value, datetime):
return value.isoformat()
return json.dumps(value)

37
app/models/achievement.py Normal file
View File

@@ -0,0 +1,37 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, NamedTuple
from sqlmodel.ext.asyncio.session import AsyncSession
if TYPE_CHECKING:
from app.database import Beatmap, Score
class Achievement(NamedTuple):
id: int
name: str
desc: str
assets_id: str
medal_url: str | None = None
medal_url2x: str | None = None
@property
def url(self) -> str:
return (
self.medal_url
or f"https://assets.ppy.sh/medals/client/{self.assets_id}.png"
)
@property
def url2x(self) -> str:
return (
self.medal_url2x
or f"https://assets.ppy.sh/medals/client/{self.assets_id}@2x.png"
)
MedalProcessor = Callable[[AsyncSession, "Score", "Beatmap"], Awaitable[bool]]
Medals = dict[Achievement, MedalProcessor]
MEDALS: Medals = {}

View File

@@ -153,6 +153,22 @@ for i in range(4, 10):
RANKED_MODS[3][f"{i}K"] = {}
def mods_can_get_pp_vanilla(ruleset_id: int, mods: list[APIMod]) -> bool:
ranked_mods = RANKED_MODS[ruleset_id]
for mod in mods:
mod["settings"] = mod.get("settings", {})
if (settings := ranked_mods.get(mod["acronym"])) is None:
return False
if settings == {}:
continue
for setting, value in mod["settings"].items():
if (expected_value := settings.get(setting)) is None:
return False
if expected_value != NO_CHECK and value != expected_value:
return False
return True
def mods_can_get_pp(ruleset_id: int, mods: list[APIMod]) -> bool:
if app_settings.enable_all_mods_pp:
return True

View File

@@ -2,10 +2,13 @@ from __future__ import annotations
from abc import abstractmethod
from enum import Enum
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Self
from app.utils import truncate
from .achievement import Achievement
from .score import GameMode
from pydantic import BaseModel, PrivateAttr
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -109,21 +112,23 @@ class ChannelMessageBase(NotificationDetail):
_user: "User" = PrivateAttr()
_receiver: list[int] = PrivateAttr()
def __init__(
self,
@classmethod
def init(
cls,
message: "ChatMessage",
user: "User",
receiver: list[int],
channel_type: "ChannelType",
) -> None:
super().__init__(
) -> Self:
instance = cls(
title=truncate(message.content, CONTENT_TRUNCATE),
type=channel_type.value.lower(),
cover_url=user.avatar_url,
)
self._message = message
self._user = user
self._receiver = receiver
instance._message = message
instance._user = user
instance._receiver = receiver
return instance
async def get_receivers(self, session: AsyncSession) -> list[int]:
return self._receiver
@@ -142,25 +147,21 @@ class ChannelMessageBase(NotificationDetail):
class ChannelMessage(ChannelMessageBase):
def __init__(
self,
message: "ChatMessage",
user: "User",
receiver: list[int],
channel_type: "ChannelType",
) -> None:
super().__init__(message, user, receiver, channel_type)
@property
def name(self) -> NotificationName:
return NotificationName.CHANNEL_MESSAGE
class ChannelMessageTeam(ChannelMessageBase):
def __init__(self, message: "ChatMessage", user: "User") -> None:
@classmethod
def init(
cls,
message: "ChatMessage",
user: "User",
) -> ChannelMessageTeam:
from app.database import ChannelType
super().__init__(message, user, [], ChannelType.TEAM)
return super().init(message, user, [], ChannelType.TEAM)
@property
def name(self) -> NotificationName:
@@ -182,3 +183,48 @@ class ChannelMessageTeam(ChannelMessageBase):
)
).all()
return list(user_ids)
class UserAchievementUnlock(NotificationDetail):
achievement_id: int
achievement_mode: str
cover_url: str
slug: str
title: str
description: str
user_id: int
@classmethod
def init(cls, achievement: Achievement, user_id: int, mode: "GameMode") -> Self:
instance = cls(
title=achievement.name,
cover_url=achievement.url,
slug=achievement.assets_id,
achievement_id=achievement.id,
achievement_mode=mode.value.lower(),
description=achievement.desc,
user_id=user_id,
)
return instance
async def get_receivers(self, session: AsyncSession) -> list[int]:
return [self.user_id]
@property
def name(self) -> NotificationName:
return NotificationName.USER_ACHIEVEMENT_UNLOCK
@property
def object_id(self) -> int:
return self.achievement_id
@property
def source_user_id(self) -> int:
return self.user_id
@property
def object_type(self) -> str:
return "achievement"
NotificationDetails = ChannelMessage | ChannelMessageTeam | UserAchievementUnlock

View File

@@ -3,3 +3,4 @@ from __future__ import annotations
from pathlib import Path
STATIC_DIR = Path(__file__).parent.parent / "static"
ACHIEVEMENTS_DIR = Path(__file__).parent / "achievements"

View File

@@ -117,12 +117,14 @@ async def send_message(
if db_channel.type == ChannelType.PM:
user_ids = db_channel.name.split("_")[1:]
await server.new_private_notification(
ChannelMessage(
ChannelMessage.init(
msg, current_user, [int(u) for u in user_ids], db_channel.type
)
)
elif db_channel.type == ChannelType.TEAM:
await server.new_private_notification(ChannelMessageTeam(msg, current_user))
await server.new_private_notification(
ChannelMessageTeam.init(msg, current_user)
)
return resp

View File

@@ -16,6 +16,7 @@ from app.database import (
ScoreTokenResp,
User,
)
from app.database.achievement import process_achievements
from app.database.counts import ReplayWatchedCount
from app.database.daily_challenge import process_daily_challenge_score
from app.database.events import Event, EventType
@@ -33,7 +34,7 @@ from app.database.score import (
process_score,
process_user,
)
from app.dependencies.database import Database, get_redis
from app.dependencies.database import Database, get_redis, with_db
from app.dependencies.fetcher import get_fetcher
from app.dependencies.storage import get_storage_service
from app.dependencies.user import get_client_user, get_current_user
@@ -59,7 +60,6 @@ from fastapi import (
HTTPException,
Path,
Query,
Request,
Security,
)
from fastapi.responses import FileResponse, RedirectResponse
@@ -73,7 +73,13 @@ from sqlmodel.ext.asyncio.session import AsyncSession
READ_SCORE_TIMEOUT = 10
async def process_user_achievement(score_id: int):
async with with_db() as session:
await process_achievements(session, get_redis(), score_id)
async def submit_score(
background_task: BackgroundTasks,
info: SoloScoreSubmissionInfo,
beatmap: int,
token: int,
@@ -176,6 +182,7 @@ async def submit_score(
}
db.add(rank_event)
await db.commit()
background_task.add_task(process_user_achievement, resp.id)
return resp
@@ -387,7 +394,7 @@ async def create_solo_score(
description="**客户端专属**\n使用令牌提交单曲成绩。",
)
async def submit_solo_score(
req: Request,
background_task: BackgroundTasks,
db: Database,
beatmap_id: int = Path(description="谱面 ID"),
token: int = Path(description="成绩令牌 ID"),
@@ -397,7 +404,9 @@ async def submit_solo_score(
fetcher=Depends(get_fetcher),
):
assert current_user.id is not None
return await submit_score(info, beatmap_id, token, current_user, db, redis, fetcher)
return await submit_score(
background_task, info, beatmap_id, token, current_user, db, redis, fetcher
)
@router.post(
@@ -484,6 +493,7 @@ async def create_playlist_score(
description="**客户端专属**\n提交房间游玩项目成绩。",
)
async def submit_playlist_score(
background_task: BackgroundTasks,
session: Database,
room_id: int,
playlist_id: int,
@@ -510,6 +520,7 @@ async def submit_playlist_score(
user_id = current_user.id
score_resp = await submit_score(
background_task,
info,
item.beatmap_id,
token,

View File

@@ -0,0 +1,22 @@
from __future__ import annotations
import importlib
from app.log import logger
from app.models.achievement import MEDALS, Medals
from app.path import ACHIEVEMENTS_DIR
def load_achievements() -> Medals:
for module in ACHIEVEMENTS_DIR.iterdir():
if module.is_file() and module.suffix == ".py":
module_name = module.stem
module_achievements = importlib.import_module(
f"app.achievements.{module_name}"
)
medals = getattr(module_achievements, "MEDALS", {})
MEDALS.update(medals)
logger.success(
f"Successfully loaded {len(medals)} achievements from {module_name}.py"
)
return MEDALS

View File

@@ -35,12 +35,22 @@ class RedisSubscriber:
ignore_subscribe_messages=True, timeout=None
)
if message is not None and message["type"] == "message":
matched_handlers = []
matched_handlers: list[Callable[[str, str], Awaitable[Any]]] = []
if message["channel"] in self.handlers:
matched_handlers.extend(self.handlers[message["channel"]])
chan = message["channel"]
for pattern, handlers in self.handlers.items():
if fnmatch(message["channel"], pattern):
matched_handlers.extend(handlers)
if pattern == chan:
continue
if not any(ch in pattern for ch in "*?[]"):
continue
if fnmatch(chan, pattern):
for h in handlers:
if h not in matched_handlers:
matched_handlers.append(h)
if matched_handlers:
await asyncio.gather(
*[

View File

@@ -2,14 +2,20 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from app.log import logger
from app.models.notification import NotificationDetails
from .base import RedisSubscriber
from pydantic import TypeAdapter
if TYPE_CHECKING:
from app.router.chat.server import ChatServer
from app.router.notification.server import ChatServer
JOIN_CHANNEL = "chat:room:joined"
EXIT_CHANNEL = "chat:room:left"
ON_NOTIFICATION = "chat:notification"
class ChatSubscriber(RedisSubscriber):
@@ -23,6 +29,8 @@ class ChatSubscriber(RedisSubscriber):
self.add_handler(JOIN_CHANNEL, self.on_join_room)
await self.subscribe(EXIT_CHANNEL)
self.add_handler(EXIT_CHANNEL, self.on_leave_room)
await self.subscribe(ON_NOTIFICATION)
self.add_handler(ON_NOTIFICATION, self.on_notification)
self.start()
async def on_join_room(self, c: str, s: str):
@@ -36,3 +44,16 @@ class ChatSubscriber(RedisSubscriber):
if self.chat_server is None:
return
await self.chat_server.leave_room_channel(int(channel_id), int(user_id))
async def on_notification(self, c: str, s: str):
try:
detail = TypeAdapter(NotificationDetails).validate_json(s)
except ValueError:
logger.exception("")
return
except Exception:
logger.exception("Failed to parse notification detail")
return
if self.chat_server is None:
return
await self.chat_server.new_private_notification(detail)

View File

@@ -29,6 +29,7 @@ from app.service.create_banchobot import create_banchobot
from app.service.daily_challenge import daily_challenge_job, process_daily_challenge_top
from app.service.geoip_scheduler import schedule_geoip_updates
from app.service.init_geoip import init_geoip
from app.service.load_achievements import load_achievements
from app.service.osu_rx_statistics import create_rx_statistics
from app.service.recalculate import recalculate
@@ -76,6 +77,7 @@ async def lifespan(app: FastAPI):
await create_banchobot()
await download_service.start_health_check() # 启动下载服务健康检查
await start_cache_scheduler() # 启动缓存调度器
load_achievements()
# on shutdown
yield
stop_scheduler()

View File

@@ -0,0 +1,76 @@
"""achievement: remove primary key `achievement_id`
Revision ID: e96a649e18ca
Revises: 4f46c43d8601
Create Date: 2025-08-21 08:03:00.670670
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "e96a649e18ca"
down_revision: str | Sequence[str] | None = "4f46c43d8601"
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.drop_constraint(
constraint_name="PRIMARY", table_name="lazer_user_achievements", type_="primary"
)
op.create_primary_key(
"pk_lazer_user_achievements", "lazer_user_achievements", ["id"]
)
op.create_index(
"ix_lazer_user_achievements_achievement_id",
"lazer_user_achievements",
["achievement_id"],
)
op.create_unique_constraint(
"uq_user_achievement", "lazer_user_achievements", ["user_id", "achievement_id"]
)
op.alter_column(
"lazer_user_achievements",
"id",
existing_type=sa.Integer(),
type_=sa.Integer(),
autoincrement=True,
existing_nullable=True,
nullable=False,
existing_server_default=None,
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"lazer_user_achievements",
"id",
existing_type=sa.Integer(),
type_=sa.Integer(),
autoincrement=False,
existing_nullable=True,
nullable=False,
existing_server_default=None,
)
op.drop_constraint(
constraint_name="PRIMARY", table_name="lazer_user_achievements", type_="primary"
)
op.drop_constraint("uq_user_achievement", "lazer_user_achievements", type_="unique")
op.drop_index(
"ix_lazer_user_achievements_achievement_id", "lazer_user_achievements"
)
op.create_primary_key(
"PRIMARY", "lazer_user_achievements", ["id", "achievement_id"]
)
# ### end Alembic commands ###