From c6ce540b3b5aa0e01ac8b623d49f61966c94ed2e Mon Sep 17 00:00:00 2001 From: Venix <30481900+venix12@users.noreply.github.com> Date: Mon, 20 Jan 2025 23:27:52 +0100 Subject: [PATCH] add seasonal leaderboards --- app/Http/Controllers/SeasonsController.php | 12 ++- app/Models/Division.php | 20 +++++ app/Models/Season.php | 41 ++++++++++ app/Models/UserSeasonScoreAggregate.php | 16 ++++ config/osu.php | 8 +- ...25_01_06_203319_create_divisions_table.php | 37 ++++++++++ resources/lang/en/rankings.php | 1 + .../views/seasons/_rankings_table.blade.php | 74 +++++++++++++++++++ resources/views/seasons/show.blade.php | 10 ++- 9 files changed, 211 insertions(+), 8 deletions(-) create mode 100644 app/Models/Division.php create mode 100644 database/migrations/2025_01_06_203319_create_divisions_table.php create mode 100644 resources/views/seasons/_rankings_table.blade.php diff --git a/app/Http/Controllers/SeasonsController.php b/app/Http/Controllers/SeasonsController.php index 3f8e914be35..c8b78a79af1 100644 --- a/app/Http/Controllers/SeasonsController.php +++ b/app/Http/Controllers/SeasonsController.php @@ -32,7 +32,17 @@ public function show($id) $seasons = Season::orderByDesc('id')->get(); $seasonsJson = json_collection($seasons, new SelectOptionTransformer()); - return ext_view('seasons.show', compact('roomsJson', 'season', 'seasonJson', 'seasonsJson')); + $divisions = $season->divisionsWithAbsoluteThresholds(); + $scores = $season->topScores()->paginate(); + + return ext_view('seasons.show', compact( + 'divisions', + 'roomsJson', + 'scores', + 'season', + 'seasonJson', + 'seasonsJson', + )); } private function paramsForResponse(int $seasonId, ?array $rawParams = null) diff --git a/app/Models/Division.php b/app/Models/Division.php new file mode 100644 index 00000000000..6de1523442a --- /dev/null +++ b/app/Models/Division.php @@ -0,0 +1,20 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Models; + +/** + * @property int $id + * @property string $image_url + * @property string $name + * @property int $season_id + * @property int $threshold + */ +class Division extends Model +{ + public $timestamps = false; +} diff --git a/app/Models/Season.php b/app/Models/Season.php index f3ce08685cf..75eb8c0792c 100644 --- a/app/Models/Season.php +++ b/app/Models/Season.php @@ -10,6 +10,7 @@ use Carbon\Carbon; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; /** * @property bool $finalised @@ -45,6 +46,36 @@ public static function latestOrId($id) return $season; } + public function divisions(): HasMany + { + return $this->hasMany(Division::class); + } + + public function divisionsOrdered(): array + { + return cache_remember_mutexed( + "divisions:{$this->id}", + $GLOBALS['cfg']['osu']['seasons']['divisions_cache_duration'], + [], + fn () => $this->divisions()->orderBy('threshold')->get(), + ); + } + + public function divisionsWithAbsoluteThresholds(): array + { + $divisions = []; + $userCount = $this->userScores()->count(); + + foreach ($this->divisionsOrdered() as $division) { + $divisions[] = [ + 'division' => $division, + 'absolute_threshold' => $division->threshold * $userCount, + ]; + } + + return $divisions; + } + public function endDate(): ?Carbon { return $this->finalised @@ -57,8 +88,18 @@ public function startDate(): ?Carbon return $this->rooms->min('starts_at'); } + public function topScores(): HasMany + { + return $this->userScores()->forRanking()->with(['user.country', 'user.team']); + } + public function rooms(): BelongsToMany { return $this->belongsToMany(Multiplayer\Room::class, SeasonRoom::class); } + + public function userScores(): HasMany + { + return $this->hasMany(UserSeasonScoreAggregate::class); + } } diff --git a/app/Models/UserSeasonScoreAggregate.php b/app/Models/UserSeasonScoreAggregate.php index 62bcd1cb96b..22e54968a16 100644 --- a/app/Models/UserSeasonScoreAggregate.php +++ b/app/Models/UserSeasonScoreAggregate.php @@ -9,6 +9,7 @@ use App\Exceptions\InvariantException; use App\Models\Multiplayer\UserScoreAggregate; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsTo; /** @@ -72,8 +73,23 @@ public function calculate(bool $muteExceptions = true): void $this->total_score = $total; } + public function scopeForRanking($query): Builder + { + return $query + ->whereHas('user', function ($userQuery) { + $userQuery->default(); + }) + ->orderByDesc('total_score'); + } + + public function season(): BelongsTo { return $this->belongsTo(Season::class); } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } } diff --git a/config/osu.php b/config/osu.php index de037d65fbd..05c892e3a08 100644 --- a/config/osu.php +++ b/config/osu.php @@ -18,7 +18,6 @@ 'achievement' => [ 'icon_prefix' => env('USER_ACHIEVEMENT_ICON_PREFIX', 'https://assets.ppy.sh/user-achievements/'), ], - 'api' => [ // changing the throttle rate doesn't reset any existing timers, // changing the prefix key is the only way to invalidate them. @@ -27,7 +26,6 @@ 'scores_download' => env('API_THROTTLE_SCORES_DOWNLOAD', '10,1,api-scores-download'), ], ], - 'avatar' => [ 'cache_purge_prefix' => env('AVATAR_CACHE_PURGE_PREFIX'), 'cache_purge_method' => env('AVATAR_CACHE_PURGE_METHOD'), @@ -35,7 +33,6 @@ 'default' => env('DEFAULT_AVATAR', env('APP_URL', 'http://localhost').'/images/layout/avatar-guest@2x.png'), 'storage' => env('AVATAR_STORAGE', 'local-avatar'), ], - 'bbcode' => [ // this should be random or a config variable. // ...who am I kidding, this shouldn't even exist at all. @@ -193,12 +190,13 @@ 'processing_queue' => presence(env('SCORES_PROCESSING_QUEUE')) ?? 'osu-queue:score-statistics', 'submission_enabled' => get_bool(env('SCORES_SUBMISSION_ENABLED')) ?? true, ], - 'seasonal' => [ 'contest_id' => get_int(env('SEASONAL_CONTEST_ID')), 'ends_at' => env('SEASONAL_ENDS_AT'), ], - + 'seasons' => [ + 'divisions_cache_duration' => 60 * (get_float(env('DIVISIONS_CACHE_DURATION')) ?? 60), // in minutes, converted to seconds + ], 'store' => [ 'notice' => presence(str_replace('\n', "\n", env('STORE_NOTICE') ?? '')), ], diff --git a/database/migrations/2025_01_06_203319_create_divisions_table.php b/database/migrations/2025_01_06_203319_create_divisions_table.php new file mode 100644 index 00000000000..e13f8762e94 --- /dev/null +++ b/database/migrations/2025_01_06_203319_create_divisions_table.php @@ -0,0 +1,37 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + */ + public function up(): void + { + Schema::create('divisions', function (Blueprint $table) { + $table->id(); + $table->integer('season_id')->unsigned(); + $table->string('name'); + $table->string('image_url'); + $table->float('threshold'); + + $table->index(['season_id', 'threshold']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('divisions'); + } +}; diff --git a/resources/lang/en/rankings.php b/resources/lang/en/rankings.php index cdafa9bfd26..36ecd2630e7 100644 --- a/resources/lang/en/rankings.php +++ b/resources/lang/en/rankings.php @@ -65,6 +65,7 @@ 'accuracy' => 'Accuracy', 'active_users' => 'Active Users', 'country' => 'Country', + 'division' => 'Division', 'play_count' => 'Play Count', 'performance' => 'Performance', 'total_score' => 'Total Score', diff --git a/resources/views/seasons/_rankings_table.blade.php b/resources/views/seasons/_rankings_table.blade.php new file mode 100644 index 00000000000..ae1551ca145 --- /dev/null +++ b/resources/views/seasons/_rankings_table.blade.php @@ -0,0 +1,74 @@ +{{-- + Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. + See the LICENCE file in the repository root for full licence text. +--}} + +@php + $currentDivision = $divisions[0]; +@endphp + + + + + + + + + + + + + @foreach ($scores as $index => $score) + @php + $rank = $scores->firstItem() + $index; + + if ($rank > $currentDivision['absolute_threshold']) { + foreach ($divisions as $division) { + if ($rank <= $division['absolute_threshold']) { + $currentDivision = $division; + break; + } + } + } + @endphp + + + + + + + + @endforeach + +
+ {{ osu_trans('rankings.stat.total_score') }} + + {{ osu_trans('rankings.stat.division') }} +
+ #{{ $rank }} + + + + {!! i18n_number_format($score->total_score) !!} + + {{ $currentDivision['division']->name }} +
diff --git a/resources/views/seasons/show.blade.php b/resources/views/seasons/show.blade.php index 7855cd70961..c135d7ed734 100644 --- a/resources/views/seasons/show.blade.php +++ b/resources/views/seasons/show.blade.php @@ -6,8 +6,8 @@ 'country' => null, 'hasFilter' => false, 'hasMode' => false, - 'hasPager' => false, - 'hasScores' => false, + 'hasPager' => true, + 'hasScores' => true, 'spotlight' => null, 'titlePrepend' => osu_trans('rankings.type.seasons').': '.$season->name, 'type' => 'seasons', @@ -34,6 +34,12 @@ class="btn-osu-big btn-osu-big--rounded-thin"
@endsection +@if (!empty($divisions)) + @section('scores') + @include('seasons._rankings_table', [$divisions, $scores]) + @endsection +@endif + @section ("script") @parent