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}