the browser-facing portion of osu!
at master 11 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\Models\Score\Best; 7 8use App\Libraries\ReplayFile; 9use App\Libraries\Score\UserRank; 10use App\Libraries\Search\ScoreSearchParams; 11use App\Models\Beatmap; 12use App\Models\Country; 13use App\Models\ReplayViewCount; 14use App\Models\Score\Model as BaseModel; 15use App\Models\Traits; 16use App\Models\User; 17 18/** 19 * @property User $user 20 */ 21abstract class Model extends BaseModel implements Traits\ReportableInterface 22{ 23 use Traits\Reportable, Traits\WithDbCursorHelper, Traits\WithWeightedPp; 24 25 protected array $macros = [ 26 'accurateRankCounts', 27 'forListing', 28 'userBest', 29 ]; 30 31 const SORTS = [ 32 'score_asc' => [ 33 ['column' => 'score', 'order' => 'ASC'], 34 ['column' => 'score_id', 'columnInput' => 'id', 'order' => 'DESC'], 35 ], 36 ]; 37 38 const DEFAULT_SORT = 'score_asc'; 39 40 const RANK_TO_STATS_COLUMN_MAPPING = [ 41 'A' => 'a_rank_count', 42 'S' => 's_rank_count', 43 'SH' => 'sh_rank_count', 44 'X' => 'x_rank_count', 45 'XH' => 'xh_rank_count', 46 ]; 47 48 public static function queueIndexingForUser(User $user) 49 { 50 $instance = new static(); 51 $table = $instance->getTable(); 52 $modeId = Beatmap::MODES[$instance->getMode()]; 53 54 $instance->getConnection()->insert( 55 "INSERT INTO score_process_queue (score_id, mode, status) SELECT score_id, {$modeId}, 1 FROM {$table} WHERE user_id = {$user->getKey()}" 56 ); 57 } 58 59 public function getAttribute($key) 60 { 61 return match ($key) { 62 'beatmap_id', 63 'count100', 64 'count300', 65 'count50', 66 'countgeki', 67 'countkatu', 68 'countmiss', 69 'country_acronym', 70 'maxcombo', 71 'pp', 72 'rank', 73 'score', 74 'score_id', 75 'user_id' => $this->getRawAttribute($key), 76 77 'hidden', 78 'perfect', 79 'replay' => (bool) $this->getRawAttribute($key), 80 81 'date' => $this->getTimeFast($key), 82 83 'date_json' => $this->getJsonTimeFast($key), 84 85 'best' => $this, 86 'enabled_mods' => $this->getEnabledModsAttribute($this->getRawAttribute('enabled_mods')), 87 'pass' => true, 88 89 'best_id' => $this->getKey(), 90 'has_replay' => $this->replay, 91 92 'beatmap', 93 'replayViewCount', 94 'reportedIn', 95 'user' => $this->getRelationValue($key), 96 97 default => $this->getNewScoreAttribute($key), 98 }; 99 } 100 101 public function replayFile(): ?ReplayFile 102 { 103 return $this->replay ? new ReplayFile($this) : null; 104 } 105 106 public function getReplayFile(): ?string 107 { 108 return $this->replayFile()?->get(); 109 } 110 111 public function macroForListing(): \Closure 112 { 113 return function ($query, $limit) { 114 $limit = \Number::clamp($limit ?? 50, 1, $GLOBALS['cfg']['osu']['beatmaps']['max_scores']); 115 $newQuery = (clone $query)->with('user')->limit($limit + 100); 116 117 $result = []; 118 $offset = 0; 119 $baseResultCount = 0; 120 $finalize = function (array $result) { 121 return array_values($result); 122 }; 123 124 while (true) { 125 $baseResult = $newQuery->offset($offset)->get(); 126 $baseResultCount = count($baseResult); 127 128 if ($baseResultCount === 0) { 129 break; 130 } 131 132 $offset += $baseResultCount; 133 134 foreach ($baseResult as $entry) { 135 if (isset($result[$entry->user_id])) { 136 continue; 137 } 138 139 $result[$entry->user_id] = $entry; 140 141 if (count($result) >= $limit) { 142 return $finalize($result); 143 } 144 } 145 } 146 147 return $finalize($result); 148 }; 149 } 150 151 public function url(): string 152 { 153 return route('scores.show', ['rulesetOrScore' => $this->getMode(), 'score' => $this->getKey()]); 154 } 155 156 public function userRank(?array $params = null): int 157 { 158 // laravel model has a $hidden property 159 if ($this->getAttribute('hidden')) { 160 return 0; 161 } 162 163 return UserRank::getRank(ScoreSearchParams::fromArray([ 164 ...($params ?? []), 165 'beatmap_ids' => [$this->beatmap_id], 166 'before_total_score' => $this->score, 167 'is_legacy' => true, 168 'ruleset_id' => $this->ruleset_id, 169 'user' => $this->user, 170 ])); 171 } 172 173 public function macroUserBest(): \Closure 174 { 175 return function ($query, $limit, $offset = 0, $includes = []) { 176 $baseResult = (clone $query) 177 ->with($includes) 178 ->limit(($limit + $offset) * 2) 179 ->get(); 180 181 $results = []; 182 $beatmaps = []; 183 184 foreach ($baseResult as $entry) { 185 if (count($results) >= $limit + $offset) { 186 break; 187 } 188 189 if (isset($beatmaps[$entry->beatmap_id])) { 190 continue; 191 } 192 193 $beatmaps[$entry->beatmap_id] = true; 194 $results[] = $entry; 195 } 196 197 return array_slice($results, $offset); 198 }; 199 } 200 201 /** 202 * Gets up-to-date User score rank counts. 203 * 204 * This can be relatively slow for large numbers of scores, so 205 * prefer getting the cached counts from one of the UserStatistics objects instead. 206 * 207 * @return array [user_id => [rank => count]] 208 */ 209 public function macroAccurateRankCounts(): \Closure 210 { 211 return function ($query) { 212 $scores = (clone $query) 213 ->select(['user_id', 'beatmap_id', 'score', 'rank']) 214 ->get(); 215 216 $result = []; 217 $counted = []; 218 219 foreach ($scores as $score) { 220 if (!isset($result[$score->user_id])) { 221 $result[$score->user_id] = []; 222 } 223 224 $countedKey = "{$score->user_id}:{$score->beatmap_id}"; 225 226 if (isset($counted[$countedKey])) { 227 $countedScore = $counted[$countedKey]; 228 if ($countedScore->score < $score->score) { 229 $result[$score->user_id][$countedScore->rank] -= 1; 230 $counted[$countedKey] = $score; 231 } else { 232 continue; 233 } 234 } 235 $counted[$countedKey] = $score; 236 237 if (!isset($result[$score->user_id][$score->rank])) { 238 $result[$score->user_id][$score->rank] = 0; 239 } 240 241 $result[$score->user_id][$score->rank] += 1; 242 } 243 244 return $result; 245 }; 246 } 247 248 public function scopeDefault($query) 249 { 250 return $query 251 ->whereHas('beatmap') 252 ->orderBy('score', 'DESC') 253 ->orderBy('score_id', 'ASC'); 254 } 255 256 public function scopeVisibleUsers($query) 257 { 258 return $query->where(['hidden' => false]); 259 } 260 261 public function scopeWithType($query, $type, $options) 262 { 263 switch ($type) { 264 case 'country': 265 $countryAcronym = $options['countryAcronym'] ?? $options['user']->country_acronym ?? Country::UNKNOWN; 266 267 return $query->fromCountry($countryAcronym); 268 case 'friend': 269 return $query->friendsOf($options['user']); 270 } 271 } 272 273 public function scopeFromCountry($query, $countryAcronym) 274 { 275 return $query->where('country_acronym', $countryAcronym); 276 } 277 278 public function scopeFriendsOf($query, $user) 279 { 280 $userIds = $user->friends()->allRelatedIds(); 281 $userIds[] = $user->getKey(); 282 283 return $query->whereIn('user_id', $userIds); 284 } 285 286 /** 287 * Override parent scope with a noop as only passed scores go in here. 288 * And the `pass` column doesn't exist. 289 */ 290 public function scopeIncludeFails($query, bool $include) 291 { 292 return $query; 293 } 294 295 public function isPersonalBest(): bool 296 { 297 return $this->getKey() === (static 298 ::where([ 299 'user_id' => $this->user_id, 300 'beatmap_id' => $this->beatmap_id, 301 ])->default() 302 ->limit(1) 303 ->pluck('score_id') 304 ->first() ?? $this->getKey()); 305 } 306 307 public function replayViewCount() 308 { 309 $class = ReplayViewCount::class.'\\'.get_class_basename(static::class); 310 311 return $this->hasOne($class, 'score_id'); 312 } 313 314 public function trashed() 315 { 316 return $this->getAttribute('hidden'); 317 } 318 319 public function user() 320 { 321 return $this->belongsTo(User::class, 'user_id'); 322 } 323 324 /** 325 * This doesn't delete the score in elasticsearch. 326 */ 327 public function delete() 328 { 329 $result = $this->getConnection()->transaction(function () { 330 $statsColumn = static::RANK_TO_STATS_COLUMN_MAPPING[$this->rank] ?? null; 331 332 if ($statsColumn !== null && $this->isPersonalBest()) { 333 $userStats = $this->user?->statistics($this->getMode()); 334 335 if ($userStats !== null) { 336 $userStats->decrementInstance($statsColumn); 337 338 $nextBest = static::where([ 339 'beatmap_id' => $this->beatmap_id, 340 'user_id' => $this->user_id, 341 ])->where($this->getKeyName(), '<>', $this->getKey()) 342 ->orderBy('score', 'DESC') 343 ->orderBy($this->getKeyName(), 'ASC') 344 ->first(); 345 346 if ($nextBest !== null) { 347 $nextBestStatsColumn = static::RANK_TO_STATS_COLUMN_MAPPING[$nextBest->rank] ?? null; 348 349 if ($nextBestStatsColumn !== null) { 350 $userStats->incrementInstance($nextBestStatsColumn); 351 } 352 } 353 } 354 } 355 356 $this->replayViewCount?->delete(); 357 358 return parent::delete(); 359 }); 360 361 $this->replayFile()?->delete(); 362 363 return $result; 364 } 365 366 protected function newReportableExtraParams(): array 367 { 368 return [ 369 'mode' => Beatmap::modeInt($this->getMode()), 370 'reason' => 'Cheating', 371 'score_id' => $this->getKey(), 372 'user_id' => $this->user_id, 373 ]; 374 } 375}