the browser-facing portion of osu!
at master 13 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; 7 8use App\Exceptions\InvariantException; 9use App\Jobs\EsDocument; 10use App\Libraries\Transactions\AfterCommit; 11use App\Traits\Memoizes; 12use DB; 13use Illuminate\Database\Eloquent\Builder; 14use Illuminate\Database\Eloquent\Collection; 15use Illuminate\Database\Eloquent\SoftDeletes; 16 17/** 18 * @property int $approved 19 * @property-read Collection<BeatmapDiscussion> $beatmapDiscussions 20 * @property-read Collection<BeatmapOwner> $beatmapOwners 21 * @property int $beatmap_id 22 * @property Beatmapset $beatmapset 23 * @property int|null $beatmapset_id 24 * @property float $bpm 25 * @property string|null $checksum 26 * @property int $countNormal 27 * @property int $countSlider 28 * @property int $countSpinner 29 * @property int $countTotal 30 * @property \Carbon\Carbon|null $deleted_at 31 * @property float $diff_approach 32 * @property float $diff_drain 33 * @property float $diff_overall 34 * @property float $diff_size 35 * @property-read Collection<BeatmapDifficulty> $difficulty 36 * @property-read Collection<BeatmapDifficultyAttrib> $difficultyAttribs 37 * @property float $difficultyrating 38 * @property-read Collection<BeatmapFailtimes> $failtimes 39 * @property string|null $filename 40 * @property int $hit_length 41 * @property \Carbon\Carbon $last_update 42 * @property int $max_combo 43 * @property mixed $mode 44 * @property-read Collection<User> $owners 45 * @property int $passcount 46 * @property int $playcount 47 * @property int $playmode 48 * @property int $score_version 49 * @property int $total_length 50 * @property User $user 51 * @property int $user_id 52 * @property string $version 53 * @property string|null $youtube_preview 54 */ 55class Beatmap extends Model implements AfterCommit 56{ 57 use Memoizes, SoftDeletes; 58 59 public $convert = false; 60 61 protected $table = 'osu_beatmaps'; 62 protected $primaryKey = 'beatmap_id'; 63 64 protected $casts = [ 65 'last_update' => 'datetime', 66 ]; 67 68 public $timestamps = false; 69 70 const MODES = [ 71 'osu' => 0, 72 'taiko' => 1, 73 'fruits' => 2, 74 'mania' => 3, 75 ]; 76 77 const VARIANTS = [ 78 'mania' => ['4k', '7k'], 79 ]; 80 81 public static function isModeValid(?string $mode) 82 { 83 return array_key_exists($mode, static::MODES); 84 } 85 86 public static function isVariantValid(?string $mode, ?string $variant) 87 { 88 return $variant === null || in_array($variant, static::VARIANTS[$mode] ?? [], true); 89 } 90 91 public static function modeInt($str): ?int 92 { 93 return static::MODES[$str] ?? null; 94 } 95 96 public static function modeStr($int): ?string 97 { 98 static $lookupMap; 99 100 $lookupMap ??= array_flip(static::MODES); 101 102 return $lookupMap[$int] ?? null; 103 } 104 105 public function baseDifficultyRatings() 106 { 107 return $this->difficulty()->where('mods', 0); 108 } 109 110 public function baseMaxCombo() 111 { 112 return $this->difficultyAttribs()->noMods()->maxCombo(); 113 } 114 115 public function beatmapOwners() 116 { 117 return $this->hasMany(BeatmapOwner::class); 118 } 119 120 public function beatmapset() 121 { 122 return $this->belongsTo(Beatmapset::class, 'beatmapset_id')->withTrashed(); 123 } 124 125 public function beatmapDiscussions() 126 { 127 return $this->hasMany(BeatmapDiscussion::class); 128 } 129 130 public function beatmapTags() 131 { 132 return $this->hasMany(BeatmapTag::class); 133 } 134 135 public function difficulty() 136 { 137 return $this->hasMany(BeatmapDifficulty::class); 138 } 139 140 public function difficultyAttribs() 141 { 142 return $this->hasMany(BeatmapDifficultyAttrib::class); 143 } 144 145 public function user() 146 { 147 return $this->belongsTo(User::class, 'user_id'); 148 } 149 150 public function scopeDefault($query) 151 { 152 return $query 153 ->orderBy('playmode', 'ASC') 154 ->orderBy('difficultyrating', 'ASC'); 155 } 156 157 public function scopeIncreasesStatistics(Builder $query): Builder 158 { 159 return $query->whereHas('beatmapset', fn ($q) => $q->withTrashed(false)); 160 } 161 162 public function scopeScoreable($query) 163 { 164 return $query->where('approved', '>', 0); 165 } 166 167 public function scopeWithMaxCombo($query) 168 { 169 $mods = BeatmapDifficultyAttrib::NO_MODS; 170 $attrib = BeatmapDifficultyAttrib::MAX_COMBO; 171 $attribTable = (new BeatmapDifficultyAttrib())->tableName(); 172 $mode = $this->qualifyColumn('playmode'); 173 $id = $this->qualifyColumn('beatmap_id'); 174 175 return $query 176 ->select(DB::raw("*, ( 177 SELECT value 178 FROM {$attribTable} 179 WHERE beatmap_id = {$id} 180 AND mode = {$mode} 181 AND mods = {$mods} 182 AND attrib_id = {$attrib} 183 ) AS attrib_max_combo")); 184 } 185 186 public function failtimes() 187 { 188 return $this->hasMany(BeatmapFailtimes::class); 189 } 190 191 public function scores($mode = null) 192 { 193 return $this->getScores(Score::class, $mode); 194 } 195 196 public function scoresBest($mode = null) 197 { 198 return $this->getScores(Score\Best::class, $mode); 199 } 200 201 public function scoresBestOsu() 202 { 203 return $this->hasMany(Score\Best\Osu::class); 204 } 205 206 public function scoresBestTaiko() 207 { 208 return $this->hasMany(Score\Best\Taiko::class); 209 } 210 211 public function scoresBestFruits() 212 { 213 return $this->hasMany(Score\Best\Fruits::class); 214 } 215 216 public function scoresBestMania() 217 { 218 return $this->hasMany(Score\Best\Mania::class); 219 } 220 221 public function afterCommit() 222 { 223 $beatmapset = $this->beatmapset; 224 225 if ($beatmapset !== null) { 226 dispatch(new EsDocument($beatmapset)); 227 } 228 } 229 230 public function isScoreable() 231 { 232 return $this->approved > 0; 233 } 234 235 public function canBeConvertedTo(int $rulesetId) 236 { 237 return $this->playmode === static::MODES['osu'] || $this->playmode === $rulesetId; 238 } 239 240 public function getAttribute($key) 241 { 242 return match ($key) { 243 'approved', 244 'beatmap_id', 245 'beatmapset_id', 246 'bpm', 247 'checksum', 248 'countNormal', 249 'countSlider', 250 'countSpinner', 251 'countTotal', 252 'diff_approach', 253 'diff_drain', 254 'diff_overall', 255 'filename', 256 'hit_length', 257 'max_combo', 258 'passcount', 259 'playcount', 260 'playmode', 261 'score_version', 262 'total_length', 263 'user_id', 264 'youtube_preview' => $this->getRawAttribute($key), 265 266 'deleted_at', 267 'last_update' => $this->getTimeFast($key), 268 269 'deleted_at_json', 270 'last_update_json' => $this->getJsonTimeFast($key), 271 272 'diff_size' => $this->getDiffSize(), 273 'difficultyrating' => $this->getDifficultyrating(), 274 'mode' => $this->getMode(), 275 'version' => $this->getVersion(), 276 277 'baseDifficultyRatings', 278 'baseMaxCombo', 279 'beatmapDiscussions', 280 'beatmapOwners', 281 'beatmapset', 282 'difficulty', 283 'difficultyAttribs', 284 'failtimes', 285 'scoresBestFruits', 286 'scoresBestMania', 287 'scoresBestOsu', 288 'scoresBestTaiko', 289 'user' => $this->getRelationValue($key), 290 }; 291 } 292 293 /** 294 * @return Collection<User> 295 */ 296 public function getOwners(): Collection 297 { 298 $owners = $this->beatmapOwners->loadMissing('user')->map( 299 fn ($beatmapOwner) => $beatmapOwner->user ?? new DeletedUser(['user_id' => $beatmapOwner->user_id]) 300 ); 301 302 // TODO: remove when everything writes to beatmap_owners. 303 if (!$owners->contains(fn ($beatmapOwner) => $beatmapOwner->user_id === $this->user_id)) { 304 $owners->prepend($this->user ?? new DeletedUser(['user_id' => $this->user_id])); 305 } 306 307 return $owners; 308 } 309 310 public function isOwner(User $user): bool 311 { 312 if ($this->user_id === $user->getKey()) { 313 return true; 314 } 315 316 return $this->relationLoaded('beatmapOwners') 317 ? $this->beatmapOwners->contains('user_id', $user->getKey()) 318 : $this->beatmapOwners()->where('user_id', $user->getKey())->exists(); 319 } 320 321 public function maxCombo() 322 { 323 if (!$this->convert) { 324 $rowMaxCombo = $this->max_combo; 325 326 if ($rowMaxCombo > 0) { 327 return $rowMaxCombo; 328 } 329 if (array_key_exists('attrib_max_combo', $this->attributes)) { 330 return $this->attributes['attrib_max_combo']; 331 } 332 } 333 334 if ($this->relationLoaded('baseMaxCombo')) { 335 $maxCombo = $this->baseMaxCombo->firstWhere('mode', $this->playmode); 336 } else { 337 $maxCombo = $this->difficultyAttribs() 338 ->mode($this->playmode) 339 ->noMods() 340 ->maxCombo() 341 ->first(); 342 } 343 344 return $maxCombo?->value; 345 } 346 347 public function status() 348 { 349 return array_search($this->approved, Beatmapset::STATES, true); 350 } 351 352 public function topTagIds() 353 { 354 // TODO: Add option to multi query when beatmapset requests all tags for beatmaps? 355 return $this->memoize( 356 __FUNCTION__, 357 fn () => cache_remember_mutexed( 358 "beatmap_top_tag_ids:{$this->getKey()}", 359 $GLOBALS['cfg']['osu']['tags']['beatmap_tags_cache_duration'], 360 [], 361 fn () => $this->beatmapTags()->topTagIds()->limit(50)->get()->toArray(), 362 ), 363 ); 364 } 365 366 private function getDifficultyrating() 367 { 368 if ($this->convert) { 369 $value = ( 370 $this->relationLoaded('baseDifficultyRatings') 371 ? $this->baseDifficultyRatings 372 : $this->baseDifficultyRatings() 373 )->firstWhere('mode', $this->playmode) 374 ?->diff_unified ?? 0; 375 } else { 376 $value = $this->getRawAttribute('difficultyrating'); 377 } 378 379 return round($value, 2); 380 } 381 382 private function getDiffSize() 383 { 384 /* 385 * Matches client implementation. 386 * all round()s here use PHP_ROUND_HALF_EVEN to match C# default Math.Round. 387 * References: 388 * - (implementation) https://github.com/ppy/osu/blob/6bbc23c831cd73bf126b31edb0bb4fa729f947d1/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs#L40 389 * - (rounding) https://msdn.microsoft.com/en-us/library/wyk4d9cy(v=vs.110).aspx 390 */ 391 $value = $this->getRawAttribute('diff_size'); 392 if ($this->playmode === static::MODES['mania']) { 393 $roundedValue = (int) round($value, 0, PHP_ROUND_HALF_EVEN); 394 395 if ($this->convert) { 396 $sliderOrSpinner = ($this->countSlider ?? 0) + ($this->countSpinner ?? 0); 397 $total = max(1, $sliderOrSpinner + ($this->countNormal ?? 0)); 398 $percentSliderOrSpinner = $sliderOrSpinner / $total; 399 400 $accuracy = (int) round($this->diff_overall ?? 0, 0, PHP_ROUND_HALF_EVEN); 401 402 if ($percentSliderOrSpinner < 0.2) { 403 return 7; 404 } elseif ($percentSliderOrSpinner < 0.3 || $roundedValue >= 5) { 405 return $accuracy > 5 ? 7 : 6; 406 } elseif ($percentSliderOrSpinner > 0.6) { 407 return $accuracy > 4 ? 5 : 4; 408 } else { 409 return \Number::clamp($accuracy + 1, 4, 7); 410 } 411 } else { 412 return max(1, $roundedValue); 413 } 414 } 415 416 return $value; 417 } 418 419 private function getMode() 420 { 421 return static::modeStr($this->playmode); 422 } 423 424 private function getScores($modelPath, $mode) 425 { 426 $mode ?? ($mode = $this->mode); 427 428 if (!static::isModeValid($mode)) { 429 throw new InvariantException(osu_trans('errors.beatmaps.invalid_mode')); 430 } 431 432 if ($this->mode !== 'osu' && $this->mode !== $mode) { 433 throw new InvariantException(osu_trans('errors.beatmaps.standard_converts_only')); 434 } 435 436 $mode = studly_case($mode); 437 438 return $this->hasMany("{$modelPath}\\{$mode}"); 439 } 440 441 private function getVersion() 442 { 443 $value = $this->getRawAttribute('version'); 444 if ($this->mode === 'mania') { 445 $keys = $this->getDiffSize(); 446 447 if (strpos($value, "{$keys}k") === false && strpos($value, "{$keys}K") === false) { 448 return "[{$keys}K] {$value}"; 449 } 450 } 451 452 return $value; 453 } 454}