the browser-facing portion of osu!
at master 15 kB view raw
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}