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}