diff --git a/app/achievements/daily_challenge.py b/app/achievements/daily_challenge.py new file mode 100644 index 0000000..b2b3f4c --- /dev/null +++ b/app/achievements/daily_challenge.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from functools import partial + +from app.database.daily_challenge import DailyChallengeStats +from app.database.score import Beatmap, Score +from app.models.achievement import Achievement + +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + + +async def process_streak( + streak: int, + next_streak: int, + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + if not score.passed: + return False + if streak < 1: + return False + if next_streak != 0 and streak >= next_streak: + return False + stats = ( + await session.exec( + select(DailyChallengeStats).where( + DailyChallengeStats.user_id == score.user_id, + ) + ) + ).first() + if not stats: + return False + if streak <= stats.daily_streak_best < next_streak: + return True + elif next_streak == 0 and stats.daily_streak_best >= streak: + return True + return False + + +MEDALS = { + Achievement( + id=102, + name="Daily Sprout", + desc="Ready for anything.", + assets_id="all-skill-dc-1", + ): partial(process_streak, 1, 7), + Achievement( + id=103, + name="Weekly Sapling", + desc="Circadian rhythm calibrated.", + assets_id="all-skill-dc-7", + ): partial(process_streak, 7, 30), + Achievement( + id=104, + name="Monthly Shrub", + desc="In for the grind.", + assets_id="all-skill-dc-30", + ): partial(process_streak, 30, 0), +} diff --git a/app/achievements/hush_hush.py b/app/achievements/hush_hush.py new file mode 100644 index 0000000..9f65043 --- /dev/null +++ b/app/achievements/hush_hush.py @@ -0,0 +1,650 @@ +from __future__ import annotations + +from datetime import datetime + +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.beatmap import BeatmapRankStatus +from app.models.mods import get_speed_rate, mod_to_save +from app.models.score import Rank + +from sqlmodel.ext.asyncio.session import AsyncSession + + +async def jackpot( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + # Pass a map with a score above 100,000, where every digit of the score is + # identical (222,222 / 7,777,777 / 99,999,999 / etc). + return ( + score.passed + and score.total_score > 100000 + and all(d == str(score.total_score)[0] for d in str(score.total_score)) + ) + + +async def nonstop( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + # PFC any map with a drain time of 8:41 or longer (before mods). + return (score.rank == Rank.X or score.rank == Rank.XH) and beatmap.hit_length > 521 + + +async def time_dilation( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + # Pass any map that is 8 minutes or longer (after mods), + # Using either of the specified mods: DT, NC. + # NF is not allowed, but all other difficulty reducing mods are. + if not score.passed: + return False + mods_ = mod_to_save(score.mods) + if "NF" in mods_: + return False + if "DT" not in mods_ and "NC" not in mods_: + return False + rate = get_speed_rate(score.mods) + return beatmap.hit_length / rate > 480 + + +async def to_the_core( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + # Pass any map that contains 'Nightcore' in the title or artist name, + # using either of the mods specified: DT, NC + if not score.passed: + return False + if ( + "Nightcore" not in beatmap.beatmapset.title + ) and "Nightcore" not in beatmap.beatmapset.artist: + return False + mods_ = mod_to_save(score.mods) + if "DT" not in mods_ or "NC" not in mods_: + return False + return True + + +async def wysi( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + # Pass any map by song artist "xi" with X7.27% accuracy (97.27%, 87.27%, etc.). + if not score.passed: + return False + if str(round(score.accuracy, ndigits=4))[3:] != "727": + return False + if "xi" not in beatmap.beatmapset.artist: + return False + return True + + +async def prepared( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + # PFC any map, using the mods specified: NF + if score.rank != Rank.X or score.rank != Rank.XH: + return False + mods_ = mod_to_save(score.mods) + if "NF" not in mods_: + return False + return True + + +async def reckless_adandon( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + # PFC any map, using the mods specified: HR, SD, that is star 3+ after mods. + if score.rank != Rank.X or score.rank != Rank.XH: + return False + mods_ = mod_to_save(score.mods) + if "HR" not in mods_ or "SD" not in mods_: + return False + fetcher = await get_fetcher() + redis = get_redis() + mods_ = score.mods.copy() + attribute = await calculate_beatmap_attributes( + beatmap.id, score.gamemode, mods_, redis, fetcher + ) + if attribute.star_rating < 3: + return False + return True + + +async def lights_out( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +): + # Pass any map, using the mods specified: FL, NC + if not score.passed: + return False + mods_ = mod_to_save(score.mods) + return "FL" in mods_ and "NC" in mods_ + + +async def camera_shy( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +): + # PFC any map, using the mods specified: HD, NF + if score.rank != Rank.X and score.rank != Rank.XH: + return False + mods_ = mod_to_save(score.mods) + return "HD" in mods_ and "NF" in mods_ + + +async def the_sun_of_all_fears( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +): + # "PFC" any map, but miss on the very first or very last combo. + if not score.passed: + return False + return score.max_combo == (beatmap.max_combo or 0) - 1 + + +async def hour_before_the_down( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + # PFC any difficulty of ginkiha - EOS. + if score.rank != Rank.X and score.rank != Rank.XH: + return False + return beatmap.beatmapset.artist == "ginkiha" and beatmap.beatmapset.title == "EOS" + + +async def slow_and_steady( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + # PFC any map, using the mods specified: HT, PF, that is star 3+ after mods. + if score.rank != Rank.X and score.rank != Rank.XH: + return False + mods_ = mod_to_save(score.mods) + if "HT" not in mods_ or "PF" not in mods_: + return False + fetcher = await get_fetcher() + redis = get_redis() + mods_ = score.mods.copy() + attribute = await calculate_beatmap_attributes( + beatmap.id, score.gamemode, mods_, redis, fetcher + ) + return attribute.star_rating >= 3 + + +async def no_time_to_spare( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + # PFC any map, using the mods specified, that is 30 seconds or shorter (after mods). + if score.rank != Rank.X and score.rank != Rank.XH: + return False + mods_ = mod_to_save(score.mods) + if "DT" not in mods_ and "NC" not in mods_: + return False + rate = get_speed_rate(score.mods) + return (beatmap.total_length / rate) <= 30 + + +async def sognare( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + # Pass LeaF - Evanescent using HT (difficulty reduction allowed) + if not score.passed: + return False + mods_ = mod_to_save(score.mods) + if "HT" not in mods_: + return False + return ( + beatmap.beatmapset.artist == "LeaF" and beatmap.beatmapset.title == "Evanescent" + ) + + +async def realtor_extraordinaire( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + # PFC any difficulty of cYsmix - House With Legs using DT/HR (DT/NC interchangeable) + if score.rank != Rank.X and score.rank != Rank.XH: + return False + mods_ = mod_to_save(score.mods) + if not ("DT" in mods_ or "NC" in mods_) or "HR" not in mods_: + return False + return ( + beatmap.beatmapset.artist == "cYsmix" + and beatmap.beatmapset.title == "House With Legs" + ) + + +async def impeccable( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + # Pass any map using the mods specified: DT, PF, that is star 4+ after mods. + if not score.passed: + return False + mods_ = mod_to_save(score.mods) + # DT and NC interchangeable + if not ("DT" in mods_ or "NC" in mods_) or "PF" not in mods_: + return False + fetcher = await get_fetcher() + redis = get_redis() + mods_ = score.mods.copy() + attribute = await calculate_beatmap_attributes( + beatmap.id, score.gamemode, mods_, redis, fetcher + ) + return attribute.star_rating >= 4 + + +async def aeon( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + # PFC any map that was ranked before or during 2011, + # using the mods specified: FL, HD, HT. + # The map must be at least star 4+ and at least 3 minutes long after mods. + if not score.passed: + return False + mods_ = mod_to_save(score.mods) + if "FL" not in mods_ or "HD" not in mods_ or "HT" not in mods_: + return False + if not beatmap.beatmapset.ranked_date or beatmap.beatmapset.ranked_date > datetime( + 2012, 1, 1 + ): + return False + if beatmap.total_length < 180: + return False + fetcher = await get_fetcher() + redis = get_redis() + mods_ = score.mods.copy() + attribute = await calculate_beatmap_attributes( + beatmap.id, score.gamemode, mods_, redis, fetcher + ) + return attribute.star_rating >= 4 + + +async def quick_maths( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + # Get exactly 34 misses on any difficulty of Function Phantom - Variable. + if score.nmiss != 34: + return False + return ( + beatmap.beatmapset.artist == "Function Phantom" + and beatmap.beatmapset.title == "Variable" + ) + + +async def kaleidoscope( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + # Pass The Flashbulb - DIDJ PVC [EX III] with 80% accuracy or higher, + # using the mods specified: EZ HT + if not score.passed: + return False + mods_ = mod_to_save(score.mods) + if "EZ" not in mods_ or "HT" not in mods_: + return False + return beatmap.id == 2022237 and score.accuracy >= 0.8 + + +async def valediction( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + # Pass a_hisa - Alexithymia | Lupinus | Tokei no Heya to Seishin Sekai + # with 90% accuracy or higher. + return ( + score.passed + and beatmap.beatmapset.artist == "a_hisa" + and beatmap.beatmapset.title + == "Alexithymia | Lupinus | Tokei no Heya to Seishin Sekai" + and score.accuracy >= 0.9 + ) + + +async def right_on_time( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + # Submit a score on Kola Kid - timer on the first minute of any hour + if not score.passed: + return False + if not ( + beatmap.beatmapset.artist == "Kola Kid" and beatmap.beatmapset.title == "timer" + ): + return False + return score.ended_at.minute == 0 + + +async def not_again( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + # Pass ARForest - Regret. with 1x Miss and 99%+ accuracy + if not score.passed: + return False + if score.nmiss != 1: + return False + if score.accuracy < 0.99: + return False + return ( + beatmap.beatmapset.artist == "ARForest" and beatmap.beatmapset.title == "Regret" + ) + + +async def deliberation( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + # PFC any ranked or loved map, with HT, that is 6+ stars after mods + if score.rank != Rank.X and score.rank != Rank.XH: + return False + mods_ = mod_to_save(score.mods) + if "HT" not in mods_: + return False + if ( + not beatmap.beatmap_status.has_pp() + or beatmap.beatmap_status != BeatmapRankStatus.LOVED + ): + return False + + fetcher = await get_fetcher() + redis = get_redis() + mods_copy = score.mods.copy() + attribute = await calculate_beatmap_attributes( + beatmap.id, score.gamemode, mods_copy, redis, fetcher + ) + return attribute.star_rating >= 6 + + +async def clarity( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + # Pass rrtyui's mapset of Camellia vs Akira Complex - Reality Distortion + if not score.passed: + return False + return beatmap.beatmapset.id == 582089 + + +async def autocreation( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + # Pass any map where the artist and the host of the mapset are the same person + if not score.passed: + return False + return beatmap.beatmapset.creator == beatmap.beatmapset.artist + + +async def value_your_identity( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + # Achieve a score where max combo equals the last 3 digits of User ID + if not score.passed: + return False + user_id = score.user_id + last_3_digits = user_id % 1000 + if last_3_digits == 0: + last_3_digits = 1000 + return score.max_combo == last_3_digits + + +async def by_the_skin_of_the_teeth( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + # Set a play using Accuracy Challenge that is exactly the accuracy specified + if not score.passed: + return False + + mods_ = mod_to_save(score.mods) + if "AC" not in mods_: + return False + + for mod in score.mods: + if mod.get("acronym") == "AC": + if "settings" in mod and "minimum_accuracy" in mod["settings"]: + target_accuracy = mod["settings"]["minimum_accuracy"] + if isinstance(target_accuracy, int | float): + return abs(score.accuracy - float(target_accuracy)) < 0.0001 + return False + + +async def meticulous_mayhem( + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + # Pass any map with 15 or more mods enabled + if not score.passed: + return False + return len(score.mods) >= 15 + + +# TODO: Quick Draw, Obsessed, Jack of All Trades, Ten To One, Persistence Is Key +# Tribulation, Replica, All Good, Time Sink, You're Here Forever, Hospitality, +# True North, Superfan, Resurgence, Festive Fever, Deciduous Arborist, +# Infectious Enthusiasm, Exquisite, Mad Scientist +MEDALS: Medals = { + Achievement( + id=105, + name="Jackpot", + desc="Lucky sevens is a mild understatement.", + assets_id="all-secret-jackpot", + ): jackpot, + Achievement( + id=106, + name="Nonstop", + desc="Breaks? What are those?", + assets_id="all-secret-nonstop", + ): nonstop, + Achievement( + id=107, + name="Time Dilation", + desc="Longer is shorter when all is said and done.", + assets_id="all-secret-tidi", + ): time_dilation, + Achievement( + id=108, + name="To The Core", + desc="In for a penny, in for a pound. Pounding bass, that is.", + assets_id="all-secret-tothecore", + ): to_the_core, + Achievement( + id=109, + name="When You See It", + desc="Three numbers which will haunt you forevermore.", + assets_id="all-secret-when-you-see-it", + ): wysi, + Achievement( + id=110, + name="Prepared", + desc="Do it for real next time.", + assets_id="all-secret-prepared", + ): prepared, + Achievement( + id=111, + name="Reckless Abandon", + desc="Throw it all to the wind.", + assets_id="all-secret-reckless", + ): reckless_adandon, + Achievement( + id=112, + name="Lights Out", + desc="The party's just getting started.", + assets_id="all-secret-lightsout", + ): lights_out, + Achievement( + id=113, + name="Camera Shy", + desc="Stop being cute.", + assets_id="all-secret-uguushy", + ): camera_shy, + Achievement( + id=114, + name="The Sun of All Fears", + desc="Unfortunate.", + assets_id="all-secret-nuked", + ): the_sun_of_all_fears, + Achievement( + id=115, + name="Hour Before The Down", + desc="Eleven skies of everlasting sunrise.", + assets_id="all-secret-hourbeforethedawn", + ): hour_before_the_down, + Achievement( + id=116, + name="Slow And Steady", + desc="Win the race, or start again.", + assets_id="all-secret-slowandsteady", + ): slow_and_steady, + Achievement( + id=117, + name="No Time To Spare", + desc="Places to be, things to do.", + assets_id="all-secret-ntts", + ): no_time_to_spare, + Achievement( + id=118, + name="Sognare", + desc="A dream in stop-motion, soon forever gone.", + assets_id="all-secret-sognare", + ): sognare, + Achievement( + id=119, + name="Realtor Extraordinaire", + desc="An acre-wide stride.", + assets_id="all-secret-realtor", + ): realtor_extraordinaire, + Achievement( + id=120, + name="Impeccable", + desc="Speed matters not to the exemplary.", + assets_id="all-secret-impeccable", + ): impeccable, + Achievement( + id=121, + name="Aeon", + desc="In the mire of thawing time, memory shall be your guide.", + assets_id="all-secret-aeon", + ): aeon, + Achievement( + id=122, + name="Quick Maths", + desc="Beats per minute over... this isn't quick at all!", + assets_id="all-secret-quickmaffs", + ): quick_maths, + Achievement( + id=123, + name="Kaleidoscope", + desc="So many pretty colours. Most of them red.", + assets_id="all-secret-kaleidoscope", + ): kaleidoscope, + Achievement( + id=124, + name="Valediction", + desc="One last time.", + assets_id="all-secret-valediction", + ): valediction, + # Achievement( + # id=125, + # name="Exquisite", + # desc="Indubitably.", + # assets_id="all-secret-exquisite", + # ): exquisite, + # Achievement( + # id=126, + # name="Mad Scientist", + # desc="The experiment... it's all gone!", + # assets_id="all-secret-madscientist", + # ): mad_scientist, + Achievement( + id=127, + name="Right On Time", + desc="The first minute is always the hardest.", + assets_id="all-secret-rightontime", + ): right_on_time, + Achievement( + id=128, + name="Not Again", + desc="Regret everything.", + assets_id="all-secret-notagain", + ): not_again, + Achievement( + id=129, + name="Deliberation", + desc="The challenge remains.", + assets_id="all-secret-deliberation", + ): deliberation, + Achievement( + id=130, + name="Clarity", + desc="And yet in our memories, you remain crystal clear.", + assets_id="all-secret-clarity", + ): clarity, + Achievement( + id=131, + name="Autocreation", + desc="Absolute rule.", + assets_id="all-secret-autocreation", + ): autocreation, + Achievement( + id=132, + name="Value Your Identity", + desc="As perfect as you are.", + assets_id="all-secret-identity", + ): value_your_identity, + Achievement( + id=133, + name="By The Skin Of The Teeth", + desc="You're that accurate.", + assets_id="all-secret-skinoftheteeth", + ): by_the_skin_of_the_teeth, + Achievement( + id=134, + name="Meticulous Mayhem", + desc="How did we get here?", + assets_id="all-secret-meticulousmayhem", + ): meticulous_mayhem, +} diff --git a/app/achievements/mods.py b/app/achievements/mods.py new file mode 100644 index 0000000..798261d --- /dev/null +++ b/app/achievements/mods.py @@ -0,0 +1,124 @@ +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.mods import API_MODS + +from sqlmodel.ext.asyncio.session import AsyncSession + + +async def process_mod( + mod: str, + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + if not score.passed: + return False + if not beatmap.beatmap_status.has_leaderboard(): + return False + if len(score.mods) != 1 or score.mods[0]["acronym"] != mod: + return False + return True + + +async def process_category_mod( + category: str, + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + if not score.passed: + return False + if not beatmap.beatmap_status.has_leaderboard(): + return False + api_mods = { + k + for k, v in API_MODS[int(score.gamemode)].items() # pyright: ignore[reportArgumentType] + if v["Type"] == category + } + return all(mod["acronym"] in api_mods for mod in score.mods) + + +MEDALS: Medals = { + Achievement( + id=89, + name="Finality", + desc="High stakes, no regrets.", + assets_id="all-intro-suddendeath", + ): partial(process_mod, "SD"), + Achievement( + id=90, + name="Perfectionist", + desc="Accept nothing but the best.", + assets_id="all-intro-perfect", + ): partial(process_mod, "PF"), + Achievement( + id=91, + name="Rock Around The Clock", + desc="You can't stop the rock.", + assets_id="all-intro-hardrock", + ): partial(process_mod, "HR"), + Achievement( + id=92, + name="Time And A Half", + desc="Having a right ol' time. One and a half of them, almost.", + assets_id="all-intro-doubletime", + ): partial(process_mod, "DT"), + Achievement( + id=93, + name="Sweet Rave Party", + desc="Founded in the fine tradition of changing things that were just fine as they were.", # noqa: E501 + assets_id="all-intro-nightcore", + ): partial(process_mod, "NC"), + Achievement( + id=94, + name="Blindsight", + desc="I can see just perfectly.", + assets_id="all-intro-hidden", + ): partial(process_mod, "HD"), + Achievement( + id=95, + name="Are You Afraid Of The Dark?", + desc="Harder than it looks, probably because it's hard to look.", + assets_id="all-intro-flashlight", + ): partial(process_mod, "FL"), + Achievement( + id=96, + name="Dial It Right Back", + desc="Sometimes you just want to take it easy.", + assets_id="all-intro-easy", + ): partial(process_mod, "EZ"), + Achievement( + id=97, + name="Risk Averse", + desc="Safety nets are fun!", + assets_id="all-intro-nofail", + ): partial(process_mod, "NF"), + Achievement( + id=98, + name="Slowboat", + desc="You got there. Eventually.", + assets_id="all-intro-halftime", + ): partial(process_mod, "HT"), + Achievement( + id=99, + name="Burned Out", + desc="One cannot always spin to win.", + assets_id="all-intro-spunout", + ): partial(process_mod, "SO"), + Achievement( + id=100, + name="Gear Shift", + desc="Tailor your experience to your perfect fit.", + assets_id="all-intro-conversion", + ): partial(process_category_mod, "Conversion"), + Achievement( + id=101, + name="Game Night", + desc="Mum said it's my turn with the beatmap!", + assets_id="all-intro-fun", + ): partial(process_category_mod, "Fun"), +} diff --git a/app/achievements/osu_playcount.py b/app/achievements/osu_playcount.py new file mode 100644 index 0000000..934e1c0 --- /dev/null +++ b/app/achievements/osu_playcount.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from functools import partial + +from app.database import UserStatistics +from app.database.beatmap import Beatmap +from app.database.score import Score +from app.models.achievement import Achievement, Medals +from app.models.score import GameMode + +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + + +async def process_playcount( + pc: int, + next_pc: int, + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + if pc < 1: + return False + if next_pc != 0 and pc >= next_pc: + return False + if score.gamemode != GameMode.OSU: + return False + stats = ( + await session.exec( + select(UserStatistics).where( + UserStatistics.mode == GameMode.OSU, + UserStatistics.user_id == score.user_id, + ) + ) + ).first() + if not stats: + return False + if pc <= stats.play_count < next_pc: + return True + elif next_pc == 0 and stats.play_count >= pc: + return True + return False + + +MEDALS: Medals = { + Achievement( + id=73, + name="5,000 Plays", + desc="There's a lot more where that came from", + assets_id="osu-plays-5000", + ): partial(process_playcount, 5000, 15000), + Achievement( + id=74, + name="15,000 Plays", + desc="Must.. click.. circles..", + assets_id="osu-plays-15000", + ): partial(process_playcount, 15000, 25000), + Achievement( + id=75, + name="25,000 Plays", + desc="There's no going back.", + assets_id="osu-plays-25000", + ): partial(process_playcount, 25000, 50000), + Achievement( + id=76, + name="50,000 Plays", + desc="You're here forever.", + assets_id="osu-plays-50000", + ): partial(process_playcount, 50000, 0), +} diff --git a/app/achievements/total_hits.py b/app/achievements/total_hits.py new file mode 100644 index 0000000..5f3d13d --- /dev/null +++ b/app/achievements/total_hits.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from functools import partial + +from app.database.score import Beatmap, Score +from app.database.statistics import UserStatistics +from app.models.achievement import Achievement, Medals +from app.models.score import GameMode + +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + + +async def process_tth( + tth: int, + next_tth: int, + gamemode: GameMode, + session: AsyncSession, + score: Score, + beatmap: Beatmap, +) -> bool: + if tth < 1: + return False + if next_tth != 0 and tth >= next_tth: + return False + if score.gamemode != gamemode: + return False + stats = ( + await session.exec( + select(UserStatistics).where( + UserStatistics.mode == score.gamemode, + UserStatistics.user_id == score.user_id, + ) + ) + ).first() + if not stats: + return False + if tth <= stats.total_hits < next_tth: + return True + elif next_tth == 0 and stats.play_count >= tth: + return True + return False + + +MEDALS: Medals = { + Achievement( + id=77, + name="30,000 Drum Hits", + desc="Did that drum have a face?", + assets_id="taiko-hits-30000", + ): partial(process_tth, 30000, 300000, GameMode.TAIKO), + Achievement( + id=78, + name="300,000 Drum Hits", + desc="The rhythm never stops.", + assets_id="taiko-hits-300000", + ): partial(process_tth, 300000, 3000000, GameMode.TAIKO), + Achievement( + id=79, + name="3,000,000 Drum Hits", + desc="Truly, the Don of dons.", + assets_id="taiko-hits-3000000", + ): partial(process_tth, 3000000, 30000000, GameMode.TAIKO), + Achievement( + id=80, + name="30,000,000 Drum Hits", + desc="Your rhythm, eternal.", + assets_id="taiko-hits-30000000", + ): partial(process_tth, 30000000, 0, GameMode.TAIKO), + Achievement( + id=81, + name="Catch 20,000 fruits", + desc="That is a lot of dietary fiber.", + assets_id="fruits-hits-20000", + ): partial(process_tth, 20000, 200000, GameMode.FRUITS), + Achievement( + id=82, + name="Catch 200,000 fruits", + desc="So, I heard you like fruit...", + assets_id="fruits-hits-200000", + ): partial(process_tth, 200000, 2000000, GameMode.FRUITS), + Achievement( + id=83, + name="Catch 2,000,000 fruits", + desc="Downright healthy.", + assets_id="fruits-hits-2000000", + ): partial(process_tth, 2000000, 20000000, GameMode.FRUITS), + Achievement( + id=84, + name="Catch 20,000,000 fruits", + desc="Nothing left behind.", + assets_id="fruits-hits-20000000", + ): partial(process_tth, 20000000, 0, GameMode.FRUITS), + Achievement( + id=85, + name="40,000 Keys", + desc="Just the start of the rainbow.", + assets_id="mania-hits-40000", + ): partial(process_tth, 40000, 400000, GameMode.MANIA), + Achievement( + id=86, + name="400,000 Keys", + desc="Four hundred thousand and still not even close.", + assets_id="mania-hits-400000", + ): partial(process_tth, 400000, 4000000, GameMode.MANIA), + Achievement( + id=87, + name="4,000,000 Keys", + desc="Is this the end of the rainbow?", + assets_id="mania-hits-4000000", + ): partial(process_tth, 4000000, 40000000, GameMode.MANIA), + Achievement( + id=88, + name="40,000,000 Keys", + desc="When someone asks which keys you play, the answer is now 'yes'.", + assets_id="mania-hits-40000000", + ): partial(process_tth, 40000000, 0, GameMode.MANIA), +} diff --git a/app/database/achievement.py b/app/database/achievement.py index 977ea69..2782c7c 100644 --- a/app/database/achievement.py +++ b/app/database/achievement.py @@ -87,9 +87,7 @@ async def process_achievements(session: AsyncSession, redis: Redis, score_id: in type=EventType.ACHIEVEMENT, user_id=score.user_id, event_payload={ - "achievement": UserAchievementResp( - achievement_id=r.id, achieved_at=now - ).model_dump(), + "achievement": {"achievement_id": r.id, "achieved_at": now.isoformat()}, "user": { "username": score.user.username, "url": settings.web_url + "users/" + str(score.user.id),