From 068697355fbdea53db571934a3f3481768b219ca Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Thu, 21 Aug 2025 08:50:16 +0000 Subject: [PATCH] feat(achievement): support obtain achievements --- app/achievements/osu_combo.py | 61 +++ app/achievements/skill.py | 466 ++++++++++++++++++ app/database/achievement.py | 62 ++- app/dependencies/database.py | 3 + app/models/achievement.py | 37 ++ app/models/mods.py | 16 + app/models/notification.py | 84 +++- app/path.py | 1 + app/router/notification/message.py | 6 +- app/router/v2/score.py | 19 +- app/service/load_achievements.py | 22 + app/service/subscribers/base.py | 16 +- app/service/subscribers/chat.py | 23 +- main.py | 2 + ...49e18ca_achievement_remove_primary_key_.py | 76 +++ 15 files changed, 864 insertions(+), 30 deletions(-) create mode 100644 app/achievements/osu_combo.py create mode 100644 app/achievements/skill.py create mode 100644 app/models/achievement.py create mode 100644 app/service/load_achievements.py create mode 100644 migrations/versions/e96a649e18ca_achievement_remove_primary_key_.py diff --git a/app/achievements/osu_combo.py b/app/achievements/osu_combo.py new file mode 100644 index 0000000..cb67276 --- /dev/null +++ b/app/achievements/osu_combo.py @@ -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), +} diff --git a/app/achievements/skill.py b/app/achievements/skill.py new file mode 100644 index 0000000..bc1f8e6 --- /dev/null +++ b/app/achievements/skill.py @@ -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"), +} diff --git a/app/database/achievement.py b/app/database/achievement.py index 4be587f..977ea69 100644 --- a/app/database/achievement.py +++ b/app/database/achievement.py @@ -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() diff --git a/app/dependencies/database.py b/app/dependencies/database.py index eb5b94d..83e5876 100644 --- a/app/dependencies/database.py +++ b/app/dependencies/database.py @@ -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) diff --git a/app/models/achievement.py b/app/models/achievement.py new file mode 100644 index 0000000..6f14dd9 --- /dev/null +++ b/app/models/achievement.py @@ -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 = {} diff --git a/app/models/mods.py b/app/models/mods.py index fc16238..49a479e 100644 --- a/app/models/mods.py +++ b/app/models/mods.py @@ -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 diff --git a/app/models/notification.py b/app/models/notification.py index 14a44fd..1d7b6f6 100644 --- a/app/models/notification.py +++ b/app/models/notification.py @@ -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 diff --git a/app/path.py b/app/path.py index d723c53..ce02502 100644 --- a/app/path.py +++ b/app/path.py @@ -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" diff --git a/app/router/notification/message.py b/app/router/notification/message.py index b9594aa..44b9f6e 100644 --- a/app/router/notification/message.py +++ b/app/router/notification/message.py @@ -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 diff --git a/app/router/v2/score.py b/app/router/v2/score.py index bec9fed..192f383 100644 --- a/app/router/v2/score.py +++ b/app/router/v2/score.py @@ -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, diff --git a/app/service/load_achievements.py b/app/service/load_achievements.py new file mode 100644 index 0000000..6d20503 --- /dev/null +++ b/app/service/load_achievements.py @@ -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 diff --git a/app/service/subscribers/base.py b/app/service/subscribers/base.py index 693d4d8..39af4cb 100644 --- a/app/service/subscribers/base.py +++ b/app/service/subscribers/base.py @@ -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( *[ diff --git a/app/service/subscribers/chat.py b/app/service/subscribers/chat.py index 0c61c06..060ac3f 100644 --- a/app/service/subscribers/chat.py +++ b/app/service/subscribers/chat.py @@ -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) diff --git a/main.py b/main.py index 4db161d..51d1f6c 100644 --- a/main.py +++ b/main.py @@ -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() diff --git a/migrations/versions/e96a649e18ca_achievement_remove_primary_key_.py b/migrations/versions/e96a649e18ca_achievement_remove_primary_key_.py new file mode 100644 index 0000000..b55a663 --- /dev/null +++ b/migrations/versions/e96a649e18ca_achievement_remove_primary_key_.py @@ -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 ###