1<?php
2
3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
4// See the LICENCE file in the repository root for full licence text.
5
6namespace App\Http\Controllers;
7
8use App\Models\Beatmap;
9use App\Models\Country;
10use App\Models\CountryStatistics;
11use App\Models\Model;
12use App\Models\Spotlight;
13use App\Models\User;
14use App\Models\UserStatistics;
15use App\Transformers\SelectOptionTransformer;
16use App\Transformers\UserCompactTransformer;
17use DB;
18use Illuminate\Pagination\LengthAwarePaginator;
19
20/**
21 * @group Ranking
22 */
23class RankingController extends Controller
24{
25 private $country;
26 private $countryStats;
27 private array $defaultViewVars;
28 private $params;
29 private $friendsOnly;
30
31 const MAX_RESULTS = 10000;
32 const PAGE_SIZE = Model::PER_PAGE;
33 const RANKING_TYPES = ['performance', 'charts', 'score', 'country'];
34 const SPOTLIGHT_TYPES = ['charts'];
35 // in display order
36 const TYPES = [
37 'performance',
38 'score',
39 'country',
40 'multiplayer',
41 'daily_challenge',
42 'seasons',
43 'charts',
44 'kudosu',
45 ];
46
47 public function __construct()
48 {
49 parent::__construct();
50
51 $this->middleware('require-scopes:public');
52
53 $this->middleware(function ($request, $next) {
54 $this->params = get_params(array_merge($request->all(), $request->route()->parameters()), null, [
55 'country', // overridden later for view
56 'filter',
57 'mode',
58 'spotlight:int', // will be overriden by spotlight object for view
59 'type',
60 'variant',
61 ]);
62
63 // these parts of the route are optional.
64 $mode = $this->params['mode'] ?? null;
65 $type = $this->params['type'] ?? null;
66
67 $this->params['filter'] = $this->params['filter'] ?? null;
68 $this->friendsOnly = auth()->check() && $this->params['filter'] === 'friends';
69 $this->setVariantParam();
70
71 $this->defaultViewVars = array_merge([
72 'hasPager' => !in_array($type, static::SPOTLIGHT_TYPES, true),
73 // so variable capture in selector function doesn't die when spotlight is null.
74 'spotlight' => null,
75 ], $this->params);
76
77 if ($mode === null) {
78 return ujs_redirect(route('rankings', ['mode' => default_mode(), 'type' => 'performance']));
79 }
80
81 if (!Beatmap::isModeValid($mode)) {
82 abort(404);
83 }
84
85 if ($type === null) {
86 return ujs_redirect(route('rankings', ['mode' => $mode, 'type' => 'performance']));
87 }
88
89 if (!in_array($type, static::RANKING_TYPES, true)) {
90 abort(404);
91 }
92
93 if (isset($this->params['country']) && $type === 'performance') {
94 $this->countryStats = CountryStatistics::where('display', 1)
95 ->where('country_code', $this->params['country'])
96 ->where('mode', Beatmap::modeInt($mode))
97 ->first();
98
99 if ($this->countryStats === null) {
100 return ujs_redirect(route('rankings', ['mode' => $mode, 'type' => $type]));
101 }
102
103 $this->country = $this->countryStats->country;
104 }
105
106 $this->defaultViewVars['country'] = $this->country;
107 if ($type === 'performance') {
108 $this->defaultViewVars['countries'] = json_collection(
109 Country::whereHasRuleset($mode)->get(),
110 new SelectOptionTransformer(),
111 );
112 }
113
114 return $next($request);
115 }, ['except' => ['kudosu']]);
116 }
117
118 public static function url(
119 string $type,
120 string $rulesetName,
121 ?Country $country = null,
122 ?Spotlight $spotlight = null,
123 ): string {
124 return match ($type) {
125 'country' => route('rankings', ['mode' => $rulesetName, 'type' => $type]),
126 'daily_challenge' => route('daily-challenge.index'),
127 'kudosu' => route('rankings.kudosu'),
128 'multiplayer' => route('multiplayer.rooms.show', ['room' => 'latest']),
129 'seasons' => route('seasons.show', ['season' => 'latest']),
130 default => route('rankings', [
131 'country' => $country !== null && $type === 'performance' ? $country->getKey() : null,
132 'mode' => $rulesetName,
133 'spotlight' => $spotlight !== null && $type === 'charts' ? $spotlight->getKey() : null,
134 'type' => $type,
135 ]),
136 };
137 }
138
139 /**
140 * Get Ranking
141 *
142 * Gets the current ranking for the specified type and game mode.
143 *
144 * ---
145 *
146 * ### Response Format
147 *
148 * Returns [Rankings](#rankings)
149 *
150 * @urlParam mode string required [Ruleset](#ruleset). Example: mania
151 * @urlParam type string required [RankingType](#rankingtype). Example: performance
152 *
153 * @queryParam country string Filter ranking by country code. Only available for `type` of `performance`. Example: JP
154 * @queryParam cursor [Cursor](#cursor). No-example
155 * @queryParam filter Either `all` (default) or `friends`. Example: all
156 * @queryParam spotlight The id of the spotlight if `type` is `charts`. Ranking for latest spotlight will be returned if not specified. No-example
157 * @queryParam variant Filter ranking to specified mode variant. For `mode` of `mania`, it's either `4k` or `7k`. Only available for `type` of `performance`. Example: 4k
158 */
159 public function index($mode, $type)
160 {
161 if ($type === 'charts') {
162 return $this->spotlight($mode);
163 }
164
165 $modeInt = Beatmap::modeInt($mode);
166
167 if ($type === 'country') {
168 $stats = CountryStatistics::where('display', 1)
169 ->with('country')
170 ->where('mode', $modeInt)
171 ->orderBy('performance', 'desc');
172 } else {
173 $class = UserStatistics\Model::getClass($mode, $this->params['variant']);
174 $table = (new $class())->getTable();
175 $ppColumn = $class::ppColumn();
176 $stats = $class
177 ::with(['user', 'user.country', 'user.team'])
178 ->where($ppColumn, '>', 0)
179 ->whereHas('user', function ($userQuery) {
180 $userQuery->default();
181 });
182
183 if ($type === 'performance') {
184 $isExperimentalRank = $GLOBALS['cfg']['osu']['scores']['experimental_rank_as_default'];
185 if ($this->country !== null) {
186 $stats->where('country_acronym', $this->country['acronym']);
187 // preferrable to rank_score when filtering by country.
188 // On a few countries the default index is slightly better but much worse on the rest.
189 $forceIndex = $isExperimentalRank ? 'country_acronym_exp' : 'country_acronym_2';
190 } else {
191 // force to order by rank_score instead of sucking down entire users table first.
192 $forceIndex = $isExperimentalRank ? 'rank_score_exp' : 'rank_score';
193 }
194
195 $stats->orderBy($ppColumn, 'desc');
196 } else { // 'score'
197 $stats->orderBy('ranked_score', 'desc');
198 // force to order by ranked_score instead of sucking down entire users table first.
199 $forceIndex = 'ranked_score';
200 }
201
202 if ($this->friendsOnly) {
203 $stats->friendsOf(auth()->user());
204 // still uses temporary table and filesort but over a more limited number of rows.
205 $forceIndex = null;
206 }
207
208 if (isset($forceIndex)) {
209 $stats->from(DB::raw("{$table} FORCE INDEX ($forceIndex)"));
210 }
211 }
212
213 $maxResults = $this->maxResults($modeInt, $stats);
214 $maxPages = ceil($maxResults / static::PAGE_SIZE);
215 $params = get_params(\Request::all(), null, ['cursor.page:int', 'page:int']);
216 $page = \Number::clamp($params['cursor']['page'] ?? $params['page'] ?? 1, 1, $maxPages);
217
218 $stats = $stats->limit(static::PAGE_SIZE)
219 ->offset(static::PAGE_SIZE * ($page - 1))
220 ->get();
221
222 $showRankChange =
223 $type === 'performance' &&
224 $this->country === null &&
225 !$this->friendsOnly &&
226 $this->params['variant'] === null;
227
228 if ($showRankChange) {
229 $stats->loadMissing('rankHistory.currentStart');
230
231 foreach ($stats as $stat) {
232 // Set rankHistory.user.statistics{ruleset} relation
233 $stat->rankHistory?->setRelation('user', $stat->user);
234 $stat->user->setRelation(User::statisticsRelationName($mode), $stat);
235 }
236 }
237
238 if (is_api_request()) {
239 switch ($type) {
240 case 'country':
241 $ranking = json_collection($stats, 'CountryStatistics', ['country']);
242 break;
243
244 default:
245 $includes = ['user', 'user.cover', 'user.country'];
246
247 if ($this->country !== null) {
248 $includes[] = 'country_rank';
249 $startRank = (max($page, 1) - 1) * static::PAGE_SIZE + 1;
250 foreach ($stats as $index => $entry) {
251 $entry->countryRank = $startRank + $index;
252 }
253 }
254
255 if ($showRankChange) {
256 $includes[] = 'rank_change_since_30_days';
257 }
258
259 $ranking = json_collection($stats, 'UserStatistics', $includes);
260 break;
261 }
262
263 return [
264 // TODO: switch to offset?
265 'cursor' => empty($ranking) || ($page >= $maxPages) ? null : ['page' => $page + 1],
266 'ranking' => $ranking,
267 'total' => $maxResults,
268 ];
269 }
270
271 $scores = new LengthAwarePaginator(
272 $stats,
273 $maxPages * static::PAGE_SIZE,
274 static::PAGE_SIZE,
275 $page,
276 ['path' => route('rankings', [
277 'filter' => $this->params['filter'],
278 'mode' => $mode,
279 'type' => $type,
280 'variant' => $this->params['variant'],
281 ])]
282 );
283
284 return ext_view("rankings.{$type}", array_merge($this->defaultViewVars, compact('scores', 'showRankChange')));
285 }
286
287 /**
288 * Get Kudosu Ranking
289 *
290 * Gets the kudosu ranking.
291 *
292 * ---
293 *
294 * ### Response format
295 *
296 * Field | Type | Description
297 * ------- | --------------- | -----------
298 * ranking | [User](#user)[] | Includes `kudosu`.
299 *
300 * @queryParam page Ranking page. Example: 1
301 */
302 public function kudosu()
303 {
304 static $maxResults = 1000;
305
306 $maxPage = $maxResults / static::PAGE_SIZE;
307 $page = min(get_int(request('page')) ?? 1, $maxPage);
308
309 $scores = User::default()
310 ->with('team')
311 ->orderBy('osu_kudostotal', 'desc')
312 ->paginate(static::PAGE_SIZE, ['*'], 'page', $page, $maxResults);
313
314 if (is_json_request()) {
315 return ['ranking' => json_collection(
316 $scores,
317 new UserCompactTransformer(),
318 'kudosu',
319 )];
320 }
321
322 return ext_view('rankings.kudosu', compact('scores'));
323 }
324
325 public function spotlight($mode)
326 {
327 $chartId = get_int($this->params['spotlight'] ?? null);
328
329 $spotlights = Spotlight::orderBy('chart_id', 'desc')->get();
330 if ($chartId === null) {
331 $spotlight = $spotlights->first() ?? abort(404);
332 } else {
333 $spotlight = Spotlight::findOrFail($chartId);
334 }
335
336 if ($spotlight->hasMode($mode)) {
337 $beatmapsets = $spotlight->beatmapsets($mode)->with('beatmaps')->get();
338 $scores = $spotlight->ranking($mode);
339
340 if ($this->friendsOnly) {
341 $scores->friendsOf(auth()->user());
342 }
343
344 if (is_api_request()) {
345 return [
346 // transformer can't do nested includes with params properly.
347 // https://github.com/thephpleague/fractal/issues/239
348 'beatmapsets' => json_collection($beatmapsets, 'Beatmapset', ['beatmaps']),
349 'ranking' => json_collection($scores->get(), 'UserStatistics', ['user', 'user.cover', 'user.country']),
350 'spotlight' => json_item($spotlight, 'Spotlight', ["participant_count:mode({$mode})"]),
351 ];
352 } else {
353 $scores = $scores->get();
354 $scoreCount = $spotlight->participantCount($mode);
355 }
356 } else {
357 if (is_api_request()) {
358 abort(404);
359 }
360
361 $beatmapsets = collect();
362 $scores = collect();
363 $scoreCount = 0;
364 }
365
366 $selectOptionTransformer = new SelectOptionTransformer();
367 $selectOptions = [
368 'currentItem' => json_item($spotlight, $selectOptionTransformer),
369 'items' => json_collection($spotlights, $selectOptionTransformer),
370 'type' => 'spotlight',
371 ];
372
373 return ext_view(
374 'rankings.charts',
375 array_merge($this->defaultViewVars, compact(
376 'beatmapsets',
377 'scoreCount',
378 'scores',
379 'selectOptions',
380 'spotlight',
381 ))
382 );
383 }
384
385 private function maxResults($modeInt, $stats)
386 {
387 if ($this->friendsOnly) {
388 return $stats->count();
389 }
390
391 if ($this->params['type'] === 'country') {
392 return CountryStatistics::where('display', 1)
393 ->where('mode', $modeInt)
394 ->count();
395 }
396
397 $maxResults = static::MAX_RESULTS;
398
399 if ($this->params['variant'] !== null) {
400 $countryCode = optional($this->country)->getKey() ?? '_all';
401 $cacheKey = "ranking_count:{$this->params['type']}:{$this->params['mode']}:{$this->params['variant']}:{$countryCode}";
402
403 return cache_remember_mutexed($cacheKey, 300, $maxResults, function () use ($maxResults, $stats) {
404 return min($stats->count(), $maxResults);
405 });
406 }
407
408 if ($this->countryStats !== null) {
409 return min($this->countryStats->user_count, $maxResults);
410 }
411
412 return $maxResults;
413 }
414
415 private function setVariantParam()
416 {
417 $variant = presence($this->params['variant'] ?? null);
418 $type = $this->params['type'] ?? null;
419 $mode = $this->params['mode'] ?? null;
420
421 if ($type !== 'performance' || !Beatmap::isVariantValid($mode, $variant)) {
422 $variant = null;
423 }
424
425 $this->params['variant'] = $variant;
426 }
427}