From 1b92bc7c574cc723832b8bf76b4b20ca1a759d06 Mon Sep 17 00:00:00 2001 From: Krzysztof Gutkowski Date: Thu, 23 Apr 2026 17:19:50 +0200 Subject: [PATCH 1/6] Add API endpoints for ranked play rankings --- .../Ranking/MatchmakingController.php | 103 ++++++++++++++---- .../MatchmakingUserStatsTransformer.php | 8 ++ routes/web.php | 2 + tests/api_routes.json | 32 ++++++ 4 files changed, 122 insertions(+), 23 deletions(-) diff --git a/app/Http/Controllers/Ranking/MatchmakingController.php b/app/Http/Controllers/Ranking/MatchmakingController.php index 2afd908dede..5832cb35ca1 100644 --- a/app/Http/Controllers/Ranking/MatchmakingController.php +++ b/app/Http/Controllers/Ranking/MatchmakingController.php @@ -10,6 +10,10 @@ use App\Http\Controllers\Controller; use App\Models\Beatmap; use App\Models\MatchmakingPool; +use App\Models\Model; +use App\Transformers\MatchmakingPoolTransformer; +use App\Transformers\MatchmakingUserStatsTransformer; +use Illuminate\Pagination\LengthAwarePaginator; class MatchmakingController extends Controller { @@ -18,38 +22,91 @@ class MatchmakingController extends Controller 'rating' => [['rating', 'DESC'], ['total_points', 'DESC']], ]; + public function __construct() + { + parent::__construct(); + + $this->middleware('require-scopes:public'); + } + + public function index(string $rulesetName) + { + $pools = $this->getPoolsQuery($rulesetName)->get(); + + return json_collection($pools, new MatchmakingPoolTransformer()); + } + public function show(?string $rulesetName = null, ?string $poolId = null) { - $rulesetName ??= default_mode(); - $rulesetId = Beatmap::MODES[$rulesetName] ?? abort(422, 'invalid ruleset parameter'); + $poolsQuery = $this->getPoolsQuery($rulesetName); - $poolsQuery = MatchmakingPool::where([ - 'ruleset_id' => $rulesetId, - 'type' => 'ranked_play', - ])->orderByDesc('active')->orderByDesc('id'); + if (is_api_request()) { + // we can just query directly, since the api route + // requires the pool id to be specified + $pool = $poolsQuery->findOrFail($poolId); + } else { + if ($poolId === null) { + $pool = $poolsQuery->firstOrFail(); - if ($poolId === null) { - $pool = $poolsQuery->firstOrFail(); + return ujs_redirect(route('rankings.matchmaking', ['mode' => $rulesetName, 'pool' => $pool->getKey()])); + } - return ujs_redirect(route('rankings.matchmaking', ['mode' => $rulesetName, 'pool' => $pool->getKey()])); + $pools = $poolsQuery->get(); + $pool = $pools->findOrFail($poolId); } - $pools = $poolsQuery->get(); - - $pool = $pools->findOrFail($poolId); - $scores = $pool + $scoresQuery = $pool ->allUserStats() ->with('user.team') ->default() - ->orderByDesc('rating') - ->paginate() - ->withQueryString(); - - return ext_view('rankings.matchmaking', compact( - 'pool', - 'pools', - 'rulesetName', - 'scores', - )); + ->orderByDesc('rating'); + + $maxResults = $scoresQuery->count(); + $maxPages = ceil($maxResults / Model::PER_PAGE); + $page = get_int(cursor_from_params(\Request::input())['page'] ?? null) + ?? get_int(\Request::input('page')) + ?? 1; + $page = \Number::clamp($page, 1, $maxPages); + + $scores = $scoresQuery + ->limit(Model::PER_PAGE) + ->offset(Model::PER_PAGE * ($page - 1)) + ->get(); + + if (is_api_request()) { + return [ + ...cursor_for_response( + empty($scores) || $page >= $maxPages ? null : ['page' => $page + 1], + ), + 'ranking' => json_collection($scores, new MatchmakingUserStatsTransformer(), MatchmakingUserStatsTransformer::RANKING_INCLUDES), + 'total' => $maxResults, + ]; + } else { + $scores = new LengthAwarePaginator( + $scores, + $maxResults, + Model::PER_PAGE, + $page, + ['path' => route('rankings.matchmaking', ['mode' => $rulesetName, 'pool' => $pool->getKey()])], + ); + + return ext_view('rankings.matchmaking', compact( + 'pool', + 'pools', + 'rulesetName', + 'scores', + )); + } + } + + private function getPoolsQuery(string $rulesetName) + { + $rulesetName ??= default_mode(); + $rulesetId = Beatmap::MODES[$rulesetName] ?? abort(422, 'invalid ruleset parameter'); + + return MatchmakingPool::where([ + 'ruleset_id' => $rulesetId, + 'type' => 'ranked_play', + ])->orderByDesc('active')->orderByDesc('id'); } } diff --git a/app/Transformers/MatchmakingUserStatsTransformer.php b/app/Transformers/MatchmakingUserStatsTransformer.php index 60dea62347d..3fc33eb60ec 100644 --- a/app/Transformers/MatchmakingUserStatsTransformer.php +++ b/app/Transformers/MatchmakingUserStatsTransformer.php @@ -12,8 +12,11 @@ class MatchmakingUserStatsTransformer extends TransformerAbstract { + const array RANKING_INCLUDES = UserStatisticsTransformer::RANKING_INCLUDES; + protected array $availableIncludes = [ 'pool', + 'user', ]; public function transform(MatchmakingUserStats $stats): array @@ -34,4 +37,9 @@ public function includePool(MatchmakingUserStats $stats): ResourceInterface { return $this->item($stats->pool, new MatchmakingPoolTransformer()); } + + public function includeUser(MatchmakingUserStats $stats): ResourceInterface + { + return $this->item($stats->user, new UserTransformer()); + } } diff --git a/routes/web.php b/routes/web.php index c42f9c66ac5..7ccf8c62531 100644 --- a/routes/web.php +++ b/routes/web.php @@ -615,6 +615,8 @@ Route::post('notifications/mark-read', 'NotificationsController@markRead')->name('notifications.mark-read'); Route::get('rankings/kudosu', 'RankingController@kudosu'); + Route::get('rankings/{mode}/ranked-play', 'Ranking\MatchmakingController@index')->name('rankings.matchmaking.index'); + Route::get('rankings/{mode}/ranked-play/{pool}', 'Ranking\MatchmakingController@show')->name('rankings.matchmaking.show'); // GET /api/v2/rankings/:mode/:type Route::get('rankings/{mode}/{type}', 'RankingController@index')->name('rankings'); Route::resource('spotlights', 'SpotlightsController', ['only' => ['index']]); diff --git a/tests/api_routes.json b/tests/api_routes.json index aaca52ff5f3..b71ba10c968 100644 --- a/tests/api_routes.json +++ b/tests/api_routes.json @@ -1642,6 +1642,38 @@ "public" ] }, + { + "uri": "api/v2/rankings/{mode}/ranked-play", + "methods": [ + "GET", + "HEAD" + ], + "controller": "App\\Http\\Controllers\\Ranking\\MatchmakingController@index", + "middlewares": [ + "App\\Http\\Middleware\\ThrottleRequests:1200,1,api:", + "App\\Http\\Middleware\\RequireScopes", + "App\\Http\\Middleware\\RequireScopes:public" + ], + "scopes": [ + "public" + ] + }, + { + "uri": "api/v2/rankings/{mode}/ranked-play/{pool}", + "methods": [ + "GET", + "HEAD" + ], + "controller": "App\\Http\\Controllers\\Ranking\\MatchmakingController@show", + "middlewares": [ + "App\\Http\\Middleware\\ThrottleRequests:1200,1,api:", + "App\\Http\\Middleware\\RequireScopes", + "App\\Http\\Middleware\\RequireScopes:public" + ], + "scopes": [ + "public" + ] + }, { "uri": "api/v2/rankings/{mode}/{type}", "methods": [ From 23dad9d906bf47315d6dbc142a4c387a72c2d035 Mon Sep 17 00:00:00 2001 From: Krzysztof Gutkowski Date: Thu, 23 Apr 2026 17:43:03 +0200 Subject: [PATCH 2/6] Use compact user transformer --- app/Http/Controllers/Ranking/MatchmakingController.php | 2 +- app/Transformers/MatchmakingUserStatsTransformer.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/Ranking/MatchmakingController.php b/app/Http/Controllers/Ranking/MatchmakingController.php index 5832cb35ca1..d9093922de4 100644 --- a/app/Http/Controllers/Ranking/MatchmakingController.php +++ b/app/Http/Controllers/Ranking/MatchmakingController.php @@ -57,7 +57,7 @@ public function show(?string $rulesetName = null, ?string $poolId = null) $scoresQuery = $pool ->allUserStats() - ->with('user.team') + ->with(['user', 'user.country', 'user.team']) ->default() ->orderByDesc('rating'); diff --git a/app/Transformers/MatchmakingUserStatsTransformer.php b/app/Transformers/MatchmakingUserStatsTransformer.php index 3fc33eb60ec..6678ebbe1d7 100644 --- a/app/Transformers/MatchmakingUserStatsTransformer.php +++ b/app/Transformers/MatchmakingUserStatsTransformer.php @@ -40,6 +40,6 @@ public function includePool(MatchmakingUserStats $stats): ResourceInterface public function includeUser(MatchmakingUserStats $stats): ResourceInterface { - return $this->item($stats->user, new UserTransformer()); + return $this->item($stats->user, new UserCompactTransformer()); } } From fac2db39a961c0f7fc5880ee289da449c193f45d Mon Sep 17 00:00:00 2001 From: Krzysztof Gutkowski Date: Thu, 21 May 2026 21:56:48 +0200 Subject: [PATCH 3/6] update routes to match non-api --- routes/web.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routes/web.php b/routes/web.php index 7ccf8c62531..b2ee39db81a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -615,8 +615,8 @@ Route::post('notifications/mark-read', 'NotificationsController@markRead')->name('notifications.mark-read'); Route::get('rankings/kudosu', 'RankingController@kudosu'); - Route::get('rankings/{mode}/ranked-play', 'Ranking\MatchmakingController@index')->name('rankings.matchmaking.index'); - Route::get('rankings/{mode}/ranked-play/{pool}', 'Ranking\MatchmakingController@show')->name('rankings.matchmaking.show'); + Route::get('rankings/ranked-play/{mode}', 'Ranking\MatchmakingController@index')->name('rankings.matchmaking.index'); + Route::get('rankings/ranked-play/{mode}/{pool}', 'Ranking\MatchmakingController@show')->name('rankings.matchmaking.show'); // GET /api/v2/rankings/:mode/:type Route::get('rankings/{mode}/{type}', 'RankingController@index')->name('rankings'); Route::resource('spotlights', 'SpotlightsController', ['only' => ['index']]); From 36640800aca46f5391f54ba9b115cba6d3021296 Mon Sep 17 00:00:00 2001 From: Krzysztof Gutkowski Date: Thu, 21 May 2026 21:57:47 +0200 Subject: [PATCH 4/6] remove unnecessary preload --- app/Http/Controllers/Ranking/MatchmakingController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Controllers/Ranking/MatchmakingController.php b/app/Http/Controllers/Ranking/MatchmakingController.php index d9093922de4..8cba41eccaa 100644 --- a/app/Http/Controllers/Ranking/MatchmakingController.php +++ b/app/Http/Controllers/Ranking/MatchmakingController.php @@ -57,7 +57,7 @@ public function show(?string $rulesetName = null, ?string $poolId = null) $scoresQuery = $pool ->allUserStats() - ->with(['user', 'user.country', 'user.team']) + ->with(['user', 'user.team']) ->default() ->orderByDesc('rating'); From 954c042431e0a3c0877496fc2778fe7491540326 Mon Sep 17 00:00:00 2001 From: Krzysztof Gutkowski Date: Thu, 21 May 2026 22:05:07 +0200 Subject: [PATCH 5/6] use sigma value as tiebreaker for equal ratings --- .../Ranking/MatchmakingController.php | 3 ++- ...40_add_sigma_to_matchmaking_user_stats.php | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2026_05_21_191940_add_sigma_to_matchmaking_user_stats.php diff --git a/app/Http/Controllers/Ranking/MatchmakingController.php b/app/Http/Controllers/Ranking/MatchmakingController.php index 8cba41eccaa..105274af579 100644 --- a/app/Http/Controllers/Ranking/MatchmakingController.php +++ b/app/Http/Controllers/Ranking/MatchmakingController.php @@ -59,7 +59,8 @@ public function show(?string $rulesetName = null, ?string $poolId = null) ->allUserStats() ->with(['user', 'user.team']) ->default() - ->orderByDesc('rating'); + ->orderByDesc('rating') + ->orderBy('sigma'); $maxResults = $scoresQuery->count(); $maxPages = ceil($maxResults / Model::PER_PAGE); diff --git a/database/migrations/2026_05_21_191940_add_sigma_to_matchmaking_user_stats.php b/database/migrations/2026_05_21_191940_add_sigma_to_matchmaking_user_stats.php new file mode 100644 index 00000000000..74fdab7342c --- /dev/null +++ b/database/migrations/2026_05_21_191940_add_sigma_to_matchmaking_user_stats.php @@ -0,0 +1,27 @@ +. 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 +{ + public function up(): void + { + Schema::table('matchmaking_user_stats', function (Blueprint $table) { + $table->double('sigma')->storedAs("JSON_EXTRACT(elo_data, '$.approximate_posterior.sig')"); + }); + } + + public function down(): void + { + Schema::table('matchmaking_user_stats', function (Blueprint $table) { + $table->dropColumn('sigma'); + }); + } +}; From ff724a27aad96094b069c59c6f7621cc2c2a6941 Mon Sep 17 00:00:00 2001 From: Krzysztof Gutkowski Date: Thu, 21 May 2026 22:27:20 +0200 Subject: [PATCH 6/6] update api_routes.json --- tests/api_routes.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/api_routes.json b/tests/api_routes.json index b71ba10c968..38acf7f690b 100644 --- a/tests/api_routes.json +++ b/tests/api_routes.json @@ -1643,7 +1643,7 @@ ] }, { - "uri": "api/v2/rankings/{mode}/ranked-play", + "uri": "api/v2/rankings/ranked-play/{mode}", "methods": [ "GET", "HEAD" @@ -1659,7 +1659,7 @@ ] }, { - "uri": "api/v2/rankings/{mode}/ranked-play/{pool}", + "uri": "api/v2/rankings/ranked-play/{mode}/{pool}", "methods": [ "GET", "HEAD"