feat(achievement): support obtain achievements
This commit is contained in:
61
app/achievements/osu_combo.py
Normal file
61
app/achievements/osu_combo.py
Normal 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
466
app/achievements/skill.py
Normal 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"),
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
37
app/models/achievement.py
Normal 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 = {}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
22
app/service/load_achievements.py
Normal file
22
app/service/load_achievements.py
Normal 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
|
||||
@@ -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(
|
||||
*[
|
||||
|
||||
@@ -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)
|
||||
|
||||
2
main.py
2
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()
|
||||
|
||||
@@ -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 ###
|
||||
Reference in New Issue
Block a user